Add the MBN-branded startup intro, semantic sidebar icons, and refined card-based operator visuals. Strengthen packaged UI and input smoke coverage for the updated experience.
6637 lines
274 KiB
JavaScript
6637 lines
274 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
const webview = window.chrome && window.chrome.webview;
|
|
const searchInput = document.getElementById("stock-search");
|
|
const searchButton = document.getElementById("stock-search-button");
|
|
const stockSearchMeta = document.getElementById("stock-search-meta");
|
|
const stockResults = document.getElementById("stock-results");
|
|
const cutList = document.getElementById("cut-list");
|
|
const cutDragFeedback = document.getElementById("cut-drag-feedback");
|
|
const playlistRows = document.getElementById("playlist-rows");
|
|
const playlistHeadingCount = document.getElementById("playlist-heading-count");
|
|
const playlistDropZone = document.getElementById("playlist-drop-zone");
|
|
const playlistMoveUp = document.getElementById("playlist-move-up");
|
|
const playlistMoveDown = document.getElementById("playlist-move-down");
|
|
const playlistDeleteSelected = document.getElementById("playlist-delete-selected");
|
|
const playlistDeleteAll = document.getElementById("playlist-delete-all");
|
|
const playlistEnableAll = document.getElementById("playlist-enable-all");
|
|
const marketTabs = document.getElementById("market-tabs");
|
|
const categoryTree = document.getElementById("category-tree");
|
|
const catalogExpandAll = document.getElementById("catalog-expand-all");
|
|
const movingAverage5 = document.getElementById("moving-average-5");
|
|
const movingAverage20 = document.getElementById("moving-average-20");
|
|
const status = document.getElementById("operator-status");
|
|
const operatorSettingsMessage = document.getElementById("operator-settings-message");
|
|
const operatorSettingsRestart = document.getElementById("operator-settings-restart");
|
|
const operatorNavigationExpanded = document.getElementById("operator-navigation-expanded");
|
|
const operatorAppearanceControls = Object.freeze({
|
|
colorTheme: Object.freeze({
|
|
element: document.getElementById("operator-color-theme"),
|
|
values: Object.freeze(["system", "light", "dark"]),
|
|
fallback: "system"
|
|
}),
|
|
viewMode: Object.freeze({
|
|
element: document.getElementById("operator-view-mode"),
|
|
values: Object.freeze(["automatic", "compact", "cards"]),
|
|
fallback: "automatic"
|
|
}),
|
|
startWorkspace: Object.freeze({
|
|
element: document.getElementById("operator-start-workspace"),
|
|
values: Object.freeze(["stockCut", "lastWorkspace"]),
|
|
fallback: "stockCut"
|
|
})
|
|
});
|
|
const operatorSettingsAppVersion = document.getElementById("operator-settings-app-version");
|
|
const operatorSettingsPlayoutMode = document.getElementById("operator-settings-playout-mode");
|
|
const operatorSettingsPlayoutConnection = document.getElementById(
|
|
"operator-settings-playout-connection");
|
|
const operatorSettingsOracleState = document.getElementById("operator-settings-oracle-state");
|
|
const operatorSettingsMariaState = document.getElementById("operator-settings-maria-state");
|
|
const operatorSettingsDbMonitor = document.getElementById("operator-settings-db-monitor");
|
|
const operatorFolderControls = Object.freeze({
|
|
design: Object.freeze({
|
|
name: document.getElementById("operator-design-folder-name"),
|
|
status: document.getElementById("operator-design-folder-status"),
|
|
source: document.getElementById("operator-design-folder-source"),
|
|
select: document.getElementById("operator-design-folder-select"),
|
|
reset: document.getElementById("operator-design-folder-reset")
|
|
}),
|
|
resource: Object.freeze({
|
|
name: document.getElementById("operator-resource-folder-name"),
|
|
status: document.getElementById("operator-resource-folder-status"),
|
|
source: document.getElementById("operator-resource-folder-source"),
|
|
select: document.getElementById("operator-resource-folder-select"),
|
|
reset: document.getElementById("operator-resource-folder-reset")
|
|
}),
|
|
background: Object.freeze({
|
|
name: document.getElementById("operator-background-folder-name"),
|
|
status: document.getElementById("operator-background-folder-status"),
|
|
source: document.getElementById("operator-background-folder-source"),
|
|
select: document.getElementById("operator-background-folder-select"),
|
|
reset: document.getElementById("operator-background-folder-reset")
|
|
})
|
|
});
|
|
const dialog = document.getElementById("legacy-dialog");
|
|
const dialogCaption = document.getElementById("legacy-dialog-caption");
|
|
const dialogMessage = document.getElementById("legacy-dialog-message");
|
|
const dialogOk = document.getElementById("legacy-dialog-ok");
|
|
const dialogNo = document.getElementById("legacy-dialog-no");
|
|
const dbSaveButton = document.getElementById("db-save-button");
|
|
const dbLoadButton = document.getElementById("db-load-button");
|
|
const namedPlaylistModal = document.getElementById("named-playlist-modal");
|
|
const namedPlaylistTitle = document.getElementById("named-playlist-title");
|
|
const namedPlaylistClose = document.getElementById("named-playlist-close");
|
|
const namedPlaylistStatus = document.getElementById("named-playlist-status");
|
|
const namedPlaylistDefinitions = document.getElementById("named-playlist-definitions");
|
|
const namedPlaylistNewTitle = document.getElementById("named-playlist-new-title");
|
|
const namedPlaylistCreateButton = document.getElementById("named-playlist-create-button");
|
|
const namedPlaylistDeleteButton = document.getElementById("named-playlist-delete-button");
|
|
const namedPlaylistCancelButton = document.getElementById("named-playlist-cancel-button");
|
|
const namedPlaylistConfirmButton = document.getElementById("named-playlist-confirm-button");
|
|
const namedPlaylistConfirmationModal = document.getElementById(
|
|
"named-playlist-confirmation");
|
|
const namedPlaylistConfirmationMessage = document.getElementById(
|
|
"named-playlist-confirmation-message");
|
|
const namedPlaylistConfirmationYes = document.getElementById(
|
|
"named-playlist-confirmation-yes");
|
|
const namedPlaylistConfirmationNo = document.getElementById(
|
|
"named-playlist-confirmation-no");
|
|
const manualButtons = Array.from(document.querySelectorAll("button[data-manual-screen]"));
|
|
const manualModal = document.getElementById("manual-financial-modal");
|
|
const manualTitle = document.getElementById("manual-financial-title");
|
|
const manualClose = document.getElementById("manual-financial-close");
|
|
const manualExit = document.getElementById("manual-financial-exit");
|
|
const manualWorkspace = document.getElementById("manual-financial-workspace");
|
|
const manualListButtons = Array.from(
|
|
document.querySelectorAll("button[data-manual-list-screen]"));
|
|
const manualListModal = document.getElementById("manual-list-modal");
|
|
const manualListTitle = document.getElementById("manual-list-title");
|
|
const manualListClose = document.getElementById("manual-list-close");
|
|
const manualListCancel = document.getElementById("manual-list-cancel");
|
|
const manualListWorkspace = document.getElementById("manual-list-workspace");
|
|
const operatorCatalogModal = document.getElementById("operator-catalog-modal");
|
|
const operatorCatalogTitle = document.getElementById("operator-catalog-title");
|
|
const operatorCatalogClose = document.getElementById("operator-catalog-close");
|
|
const operatorCatalogStatus = document.getElementById("operator-catalog-status");
|
|
const operatorCatalogToolbar = document.getElementById("operator-catalog-toolbar");
|
|
const operatorCatalogBody = document.getElementById("operator-catalog-body");
|
|
const operatorCatalogListPanel = document.getElementById("operator-catalog-list-panel");
|
|
const operatorCatalogThemeTab = document.getElementById("operator-catalog-theme-tab");
|
|
const operatorCatalogExpertTab = document.getElementById("operator-catalog-expert-tab");
|
|
const operatorCatalogQuery = document.getElementById("operator-catalog-query");
|
|
const operatorCatalogSession = document.getElementById("operator-catalog-session");
|
|
const operatorCatalogSearch = document.getElementById("operator-catalog-search");
|
|
const operatorCatalogNewMarket = document.getElementById("operator-catalog-new-market");
|
|
const operatorCatalogNew = document.getElementById("operator-catalog-new");
|
|
const operatorCatalogResults = document.getElementById("operator-catalog-results");
|
|
const operatorCatalogEditor = document.getElementById("operator-catalog-editor");
|
|
const operatorCatalogEditorMarketField = document.getElementById(
|
|
"operator-catalog-editor-market-field");
|
|
const operatorCatalogEditorMarket = document.getElementById(
|
|
"operator-catalog-editor-market");
|
|
const operatorCatalogCode = document.getElementById("operator-catalog-code");
|
|
const operatorCatalogNameLabel = document.getElementById("operator-catalog-name-label");
|
|
const operatorCatalogName = document.getElementById("operator-catalog-name");
|
|
const operatorCatalogNameCheck = document.getElementById("operator-catalog-name-check");
|
|
const operatorCatalogNameStatus = document.getElementById("operator-catalog-name-status");
|
|
const operatorCatalogStockSearchLine = document.getElementById("operator-catalog-stock-search-line");
|
|
const operatorCatalogStockQuery = document.getElementById("operator-catalog-stock-query");
|
|
const operatorCatalogStockSearch = document.getElementById("operator-catalog-stock-search");
|
|
const operatorCatalogStockResults = document.getElementById("operator-catalog-stock-results");
|
|
const operatorCatalogAmountLabel = document.getElementById("operator-catalog-amount-label");
|
|
const operatorCatalogAmount = document.getElementById("operator-catalog-amount");
|
|
const operatorCatalogAddStock = document.getElementById("operator-catalog-add-stock");
|
|
const operatorCatalogAddLine = document.getElementById("operator-catalog-add-line");
|
|
const operatorCatalogDraftHeader = document.getElementById("operator-catalog-draft-header");
|
|
const operatorCatalogDraftRows = document.getElementById("operator-catalog-draft-rows");
|
|
const operatorCatalogDeleteAll = document.getElementById("operator-catalog-delete-all");
|
|
const operatorCatalogCancel = document.getElementById("operator-catalog-cancel");
|
|
const operatorCatalogSave = document.getElementById("operator-catalog-save");
|
|
const namedPlaylistDialog = namedPlaylistModal.querySelector("[role='dialog']");
|
|
const namedPlaylistConfirmationDialog = namedPlaylistConfirmationModal.querySelector(
|
|
"[role='alertdialog']");
|
|
const manualDialog = manualModal.querySelector("[role='dialog']");
|
|
const manualListDialog = manualListModal.querySelector("[role='dialog']");
|
|
const operatorCatalogDialog = operatorCatalogModal.querySelector("[role='dialog']");
|
|
const legacyAlertDialog = dialog.querySelector("[role='alertdialog']");
|
|
if (!window.LegacyModalFocus || typeof window.LegacyModalFocus.create !== "function") {
|
|
throw new Error("Legacy modal focus support was not loaded.");
|
|
}
|
|
if (!window.LegacyCutDrag ||
|
|
typeof window.LegacyCutDrag.createDragRectangle !== "function" ||
|
|
typeof window.LegacyCutDrag.shouldStart !== "function" ||
|
|
typeof window.LegacyCutDrag.toCssDragSize !== "function") {
|
|
throw new Error("Legacy cut drag support was not loaded.");
|
|
}
|
|
if (!window.LegacyNamedPlaylistSelection ||
|
|
typeof window.LegacyNamedPlaylistSelection.create !== "function") {
|
|
throw new Error("Legacy named-playlist selection support was not loaded.");
|
|
}
|
|
const modalFocusManager = window.LegacyModalFocus.create({ document: document });
|
|
const namedPlaylistSelection = window.LegacyNamedPlaylistSelection.create();
|
|
modalFocusManager.start();
|
|
let uiBusy = false;
|
|
const searchDraftInputIds = new Set([
|
|
"stock-search",
|
|
"overseas-industry-search",
|
|
"overseas-stock-search",
|
|
"comparison-domestic-search",
|
|
"comparison-world-search",
|
|
"theme-search",
|
|
"expert-search",
|
|
"halt-search",
|
|
"manual-financial-list-query",
|
|
"manual-financial-find-query",
|
|
"manual-vi-search",
|
|
"operator-catalog-query",
|
|
"operator-catalog-stock-query"
|
|
]);
|
|
const searchDrafts = new Map();
|
|
let namedPlaylistMode = null;
|
|
let namedPlaylistState = null;
|
|
let namedPlaylistConfirmation = null;
|
|
let manualDraft = null;
|
|
let manualDraftKey = "";
|
|
let manualDraftResetPending = false;
|
|
let operatorCatalogState = null;
|
|
let operatorCatalogNameDraft = "";
|
|
let operatorCatalogDraftKey = "";
|
|
let manualResultClickTimer = null;
|
|
let comparisonTargetClickTimer = null;
|
|
let themeResultClickTimer = null;
|
|
let manualViResultClickTimer = null;
|
|
let manualResultPendingSelection = null;
|
|
let comparisonTargetPendingSelection = null;
|
|
let themeResultPendingSelection = null;
|
|
let manualViResultPendingSelection = null;
|
|
let pendingNamedPlaylistCommand = null;
|
|
let nextNamedPlaylistRequestId = 1;
|
|
let lastCommandSequence = 0;
|
|
let operatorMutationLocked = true;
|
|
const operatorCatalogDragMime = "text/x-mbn-operator-catalog-row";
|
|
let deferredPlaylistPlainSelectionRowId = null;
|
|
let playlistDragGesture = null;
|
|
let playlistMutationLocked = false;
|
|
let playlistStructureMutationLocked = true;
|
|
let playlistProgramEnableMutationLocked = true;
|
|
let playlistDeleteSelectedLocked = true;
|
|
let cutSelectionEnabled = false;
|
|
let cutDragSize = { width: 4, height: 4 };
|
|
let cutDragGesture = null;
|
|
let lastPresentedPlayoutRowKey = "";
|
|
let draggedTabId = null;
|
|
let tabDragLastTargetId = null;
|
|
let pendingTabHoverSwap = null;
|
|
let draggedOperatorCatalogRowId = null;
|
|
let operatorCatalogDragBlockedRowId = null;
|
|
let operatorCatalogMutationLocked = false;
|
|
let legacyDialogIsConfirmation = false;
|
|
let industryRenderStructureKey = "";
|
|
let overseasRenderStructureKey = "";
|
|
let themeRenderStructureKey = "";
|
|
let comparisonRenderStructureKey = "";
|
|
let expertRenderStructureKey = "";
|
|
let tradingHaltRenderStructureKey = "";
|
|
let manualFinancialRenderStateKey = "";
|
|
let manualListRenderStateKey = "";
|
|
let operatorCatalogRenderStateKey = "";
|
|
let manualFinancialSearchFocus = null;
|
|
let manualListSearchFocus = null;
|
|
let operatorCatalogSearchFocus = null;
|
|
let operatorSettingsRenderRevision = null;
|
|
let operatorSettingsRevisionInitialized = false;
|
|
let operatorAppearanceSnapshot = Object.freeze({
|
|
colorTheme: "system",
|
|
viewMode: "automatic",
|
|
startWorkspace: "stockCut"
|
|
});
|
|
let pendingOperatorSettingsFocusId = null;
|
|
let pendingOperatorSettingsFallbackFocusId = null;
|
|
let operatorSettingsWasBusy = false;
|
|
let renderedTreeTabId = null;
|
|
let treeInteractionLocked = false;
|
|
let treeFocusOwnedBeforeRender = false;
|
|
const treeUiStateByTab = new Map();
|
|
const pendingTreeResetTabs = new Set();
|
|
|
|
function preferredModalFocus(modal) {
|
|
if (modal === legacyAlertDialog) return dialogOk;
|
|
if (modal === namedPlaylistConfirmationDialog) return namedPlaylistConfirmationYes;
|
|
if (modal === namedPlaylistDialog) {
|
|
return namedPlaylistDefinitions.querySelector("button[aria-selected='true']") ||
|
|
namedPlaylistDefinitions.querySelector("button") || namedPlaylistNewTitle;
|
|
}
|
|
if (modal === manualDialog) {
|
|
return manualWorkspace.querySelector(
|
|
"button.selected[data-manual-stock-result-id]") ||
|
|
manualWorkspace.querySelector("button.selected[data-manual-result-id]") ||
|
|
manualWorkspace.querySelector("button[data-manual-result-id]") ||
|
|
manualWorkspace.querySelector(".manual-financial-list-header");
|
|
}
|
|
if (modal === manualListDialog) {
|
|
return manualListWorkspace.querySelector(
|
|
"button.selected[data-manual-vi-result-id]") ||
|
|
manualListWorkspace.querySelector("input:not([disabled])") ||
|
|
manualListWorkspace.querySelector("button:not([disabled])");
|
|
}
|
|
if (modal === operatorCatalogDialog) {
|
|
if (!operatorCatalogEditor.hidden && !operatorCatalogName.disabled) {
|
|
return operatorCatalogName;
|
|
}
|
|
return operatorCatalogResults.querySelector("button[aria-selected='true']") ||
|
|
operatorCatalogResults.querySelector("button") || operatorCatalogQuery;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function createModalOpenerReference() {
|
|
const opener = document.activeElement;
|
|
if (!renderedTreeTabId || !categoryTree.contains(opener)) return opener;
|
|
|
|
captureTreeUiState(renderedTreeTabId);
|
|
const node = opener.closest("[data-tree-node-key]");
|
|
const descriptor = {
|
|
tabId: renderedTreeTabId,
|
|
controlId: opener.id || null,
|
|
nodeKey: node ? node.dataset.treeNodeKey : null,
|
|
selectionStart: Number.isInteger(opener.selectionStart) ? opener.selectionStart : null,
|
|
selectionEnd: Number.isInteger(opener.selectionEnd) ? opener.selectionEnd : null
|
|
};
|
|
return function resolveTreeModalOpener() {
|
|
if (descriptor.tabId !== renderedTreeTabId) return null;
|
|
let resolved = descriptor.controlId
|
|
? document.getElementById(descriptor.controlId) : null;
|
|
if (!resolved && descriptor.nodeKey) {
|
|
resolved = findTreeNodeByKey(descriptor.nodeKey);
|
|
}
|
|
if (!resolved || !categoryTree.contains(resolved) || resolved.disabled === true) {
|
|
return null;
|
|
}
|
|
if (Number.isInteger(descriptor.selectionStart) &&
|
|
Number.isInteger(descriptor.selectionEnd) &&
|
|
typeof resolved.setSelectionRange === "function") {
|
|
try {
|
|
resolved.setSelectionRange(descriptor.selectionStart, descriptor.selectionEnd);
|
|
} catch (_) {
|
|
// A recreated non-text control may expose, but reject, selection ranges.
|
|
}
|
|
}
|
|
return resolved;
|
|
};
|
|
}
|
|
|
|
function rememberModalOpener(modal) {
|
|
if (modal) modalFocusManager.rememberOpener(modal, createModalOpenerReference());
|
|
}
|
|
|
|
function syncModalFocus() {
|
|
modalFocusManager.sync(preferredModalFocus);
|
|
}
|
|
|
|
function activeTreeTabId(state) {
|
|
if (!state || !Array.isArray(state.tabs)) return null;
|
|
const active = state.tabs.find(function (tab) { return tab.isActive === true; });
|
|
return active ? active.id : null;
|
|
}
|
|
|
|
function treeSectionIdentity(summary, index) {
|
|
if (summary.dataset.sectionIndex != null) {
|
|
return "fixed:" + summary.dataset.sectionIndex;
|
|
}
|
|
if (summary.dataset.industrySectionIndex != null) {
|
|
return "industry:" + summary.dataset.industrySectionIndex;
|
|
}
|
|
if (summary.dataset.comparisonSectionIndex != null) {
|
|
return "comparison:" + summary.dataset.comparisonSectionIndex;
|
|
}
|
|
return "section:" + index + ":" + (summary.textContent || "").trim();
|
|
}
|
|
|
|
function markVisualCardList(element, context) {
|
|
if (!element) return element;
|
|
element.dataset.visualCardList = "true";
|
|
if (context) element.dataset.visualCardContext = context;
|
|
return element;
|
|
}
|
|
|
|
function setVisualCardContent(button, options) {
|
|
if (!button) return;
|
|
const content = options || {};
|
|
const titleText = content.title == null ? "" : String(content.title);
|
|
const metaText = content.meta == null ? "" : String(content.meta);
|
|
|
|
button.classList.add("visual-card");
|
|
button.dataset.visualCardKind = content.kind || "graphic";
|
|
if (titleText) button.setAttribute("aria-label", titleText);
|
|
|
|
const copy = document.createElement("span");
|
|
copy.className = "visual-card-copy";
|
|
const title = document.createElement("span");
|
|
title.className = "visual-card-title";
|
|
title.textContent = titleText;
|
|
copy.appendChild(title);
|
|
if (metaText) {
|
|
const meta = document.createElement("span");
|
|
meta.className = "visual-card-meta";
|
|
meta.textContent = metaText;
|
|
copy.appendChild(meta);
|
|
}
|
|
|
|
button.replaceChildren(copy);
|
|
}
|
|
|
|
function treeLeafIdentity(button, index) {
|
|
return button.dataset.actionId ||
|
|
button.dataset.industryActionId ||
|
|
button.dataset.comparisonActionId ||
|
|
button.dataset.overseasActionId ||
|
|
button.dataset.themeActionId ||
|
|
button.dataset.expertActionId ||
|
|
button.dataset.haltActionId ||
|
|
("leaf:" + index + ":" + (button.textContent || "").trim());
|
|
}
|
|
|
|
function syncTreeSectionExpanded(details) {
|
|
const summary = details.querySelector(":scope > summary[data-tree-root]");
|
|
if (summary) summary.setAttribute("aria-expanded", details.open ? "true" : "false");
|
|
}
|
|
|
|
function clearTreeRovingLists() {
|
|
const lists = [];
|
|
if (categoryTree.dataset.rovingTree === "true") lists.push(categoryTree);
|
|
categoryTree.querySelectorAll("[data-roving-tree=\"true\"]")
|
|
.forEach(function (list) { lists.push(list); });
|
|
lists.forEach(function (list) {
|
|
list.querySelectorAll("[data-tree-node-key]").forEach(function (node) {
|
|
delete node.dataset.rovingRow;
|
|
node.removeAttribute("tabindex");
|
|
});
|
|
delete list.dataset.rovingTree;
|
|
delete list.dataset.rovingList;
|
|
delete list.dataset.rovingPageSize;
|
|
delete list.dataset.rovingSelectionFollowsFocus;
|
|
delete list.dataset.rovingActivation;
|
|
list.removeAttribute("tabindex");
|
|
list.removeAttribute("role");
|
|
});
|
|
}
|
|
|
|
function configureTreeRovingLists() {
|
|
const containers = new Set();
|
|
categoryTree.querySelectorAll("details[data-tree-section-key]")
|
|
.forEach(function (details) {
|
|
if (details.parentElement) containers.add(details.parentElement);
|
|
});
|
|
containers.forEach(function (list) {
|
|
const rows = Array.from(list.querySelectorAll("[data-tree-node-key]"))
|
|
.filter(function (row) {
|
|
const details = row.closest("details[data-tree-section-key]");
|
|
return details && details.parentElement === list;
|
|
});
|
|
const selected = rows.find(function (row) {
|
|
return row.classList.contains("tree-node-selected");
|
|
}) || null;
|
|
configureRovingList(list, rows, selected, {
|
|
listRole: "tree",
|
|
rowRole: "treeitem",
|
|
pageSize: 9,
|
|
selectionFollowsFocus: false
|
|
});
|
|
list.dataset.rovingTree = "true";
|
|
});
|
|
}
|
|
|
|
function decorateTreeNodes(tabId) {
|
|
if (!tabId) return;
|
|
clearTreeRovingLists();
|
|
categoryTree.querySelectorAll("details").forEach(function (details, sectionIndex) {
|
|
const summary = details.querySelector(":scope > summary");
|
|
if (!summary) return;
|
|
const sectionKey = tabId + "|" + treeSectionIdentity(summary, sectionIndex);
|
|
details.dataset.treeSectionKey = sectionKey;
|
|
summary.dataset.treeRoot = "true";
|
|
summary.dataset.treeNodeKey = "root|" + sectionKey;
|
|
summary.setAttribute("role", "treeitem");
|
|
summary.setAttribute("aria-selected", "false");
|
|
summary.setAttribute("aria-disabled", treeInteractionLocked ? "true" : "false");
|
|
|
|
if (!summary.querySelector("[data-tree-disclosure]")) {
|
|
const labelText = summary.textContent || "";
|
|
const disclosure = document.createElement("span");
|
|
disclosure.className = "tree-disclosure";
|
|
disclosure.dataset.treeDisclosure = "true";
|
|
disclosure.setAttribute("aria-hidden", "true");
|
|
const label = document.createElement("span");
|
|
label.className = "tree-root-label";
|
|
label.dataset.treeRootLabel = "true";
|
|
label.textContent = labelText;
|
|
summary.replaceChildren(disclosure, label);
|
|
}
|
|
|
|
details.querySelectorAll(".tree-item").forEach(function (button, leafIndex) {
|
|
button.dataset.treeNodeKey = "leaf|" + sectionKey + "|" +
|
|
treeLeafIdentity(button, leafIndex);
|
|
button.setAttribute("role", "treeitem");
|
|
if (!button.classList.contains("tree-node-selected")) {
|
|
button.setAttribute("aria-selected", "false");
|
|
}
|
|
});
|
|
syncTreeSectionExpanded(details);
|
|
});
|
|
configureTreeRovingLists();
|
|
}
|
|
|
|
function findTreeNodeByKey(key) {
|
|
if (!key) return null;
|
|
return Array.from(categoryTree.querySelectorAll("[data-tree-node-key]")).find(
|
|
function (node) { return node.dataset.treeNodeKey === key; }) || null;
|
|
}
|
|
|
|
function selectTreeNode(node, focus) {
|
|
if (!node || !renderedTreeTabId) return;
|
|
categoryTree.querySelectorAll("[data-tree-node-key].tree-node-selected")
|
|
.forEach(function (selected) {
|
|
selected.classList.remove("tree-node-selected");
|
|
selected.setAttribute("aria-selected", "false");
|
|
});
|
|
node.classList.add("tree-node-selected");
|
|
node.setAttribute("aria-selected", "true");
|
|
const previous = treeUiStateByTab.get(renderedTreeTabId) || {};
|
|
treeUiStateByTab.set(renderedTreeTabId, Object.assign({}, previous, {
|
|
selectedKey: node.dataset.treeNodeKey
|
|
}));
|
|
const list = node.closest("[data-roving-tree=\"true\"]");
|
|
if (list) setRovingListTabStop(list, node);
|
|
if (focus !== false && typeof node.focus === "function") {
|
|
node.focus({ preventScroll: true });
|
|
}
|
|
}
|
|
|
|
function captureTreeUiState(tabId) {
|
|
if (!tabId) return;
|
|
decorateTreeNodes(tabId);
|
|
const previous = treeUiStateByTab.get(tabId) || {};
|
|
const openSections = new Map();
|
|
categoryTree.querySelectorAll("details[data-tree-section-key]")
|
|
.forEach(function (details) {
|
|
openSections.set(details.dataset.treeSectionKey, details.open === true);
|
|
});
|
|
const selected = categoryTree.querySelector(
|
|
"[data-tree-node-key].tree-node-selected");
|
|
const activeElement = categoryTree.contains(document.activeElement)
|
|
? document.activeElement
|
|
: null;
|
|
const active = activeElement
|
|
? activeElement.closest("[data-tree-node-key]")
|
|
: null;
|
|
const controlFocus = activeElement && activeElement.id
|
|
? {
|
|
id: activeElement.id,
|
|
value: typeof activeElement.value === "string" ? activeElement.value : null,
|
|
selectionStart: Number.isInteger(activeElement.selectionStart)
|
|
? activeElement.selectionStart : null,
|
|
selectionEnd: Number.isInteger(activeElement.selectionEnd)
|
|
? activeElement.selectionEnd : null
|
|
}
|
|
: null;
|
|
treeUiStateByTab.set(tabId, {
|
|
openSections: openSections,
|
|
selectedKey: selected ? selected.dataset.treeNodeKey : previous.selectedKey || null,
|
|
focusKey: active ? active.dataset.treeNodeKey : previous.focusKey || null,
|
|
controlFocus: activeElement ? controlFocus : previous.controlFocus || null,
|
|
scrollTop: categoryTree.scrollTop
|
|
});
|
|
}
|
|
|
|
function beginTreeRender(state) {
|
|
const nextTabId = activeTreeTabId(state);
|
|
treeFocusOwnedBeforeRender = categoryTree.contains(document.activeElement);
|
|
if (renderedTreeTabId) captureTreeUiState(renderedTreeTabId);
|
|
if (nextTabId !== renderedTreeTabId) {
|
|
clearThemeResultPendingSelection(false);
|
|
clearComparisonTargetPendingSelection(false);
|
|
renderedTreeTabId = nextTabId;
|
|
pendingTreeResetTabs.clear();
|
|
if (nextTabId) pendingTreeResetTabs.add(nextTabId);
|
|
if (nextTabId) treeUiStateByTab.delete(nextTabId);
|
|
catalogExpandAll.checked = true;
|
|
}
|
|
treeInteractionLocked = state.isBusy === true || state.fixedSectionBatch != null;
|
|
return nextTabId;
|
|
}
|
|
|
|
function finishTreeRender(tabId) {
|
|
if (!tabId) return;
|
|
const keyboardMarketFocusPending = typeof window !== "undefined" &&
|
|
window.LegacyWorkspaceNavigation?.hasPendingKeyboardMarketFocus?.() === true;
|
|
decorateTreeNodes(tabId);
|
|
const details = Array.from(categoryTree.querySelectorAll("details[data-tree-section-key]"));
|
|
const roots = Array.from(categoryTree.querySelectorAll("summary[data-tree-root]"));
|
|
if (pendingTreeResetTabs.has(tabId) && roots.length === 0) {
|
|
const originalFocusedControl = tabId === "expert"
|
|
? categoryTree.querySelector("#expert-preview")
|
|
: null;
|
|
if (originalFocusedControl && !modalFocusManager.getActiveModal() &&
|
|
!keyboardMarketFocusPending) {
|
|
originalFocusedControl.focus({ preventScroll: true });
|
|
}
|
|
pendingTreeResetTabs.delete(tabId);
|
|
return;
|
|
}
|
|
if (pendingTreeResetTabs.has(tabId) && roots.length > 0) {
|
|
details.forEach(function (section) {
|
|
section.open = true;
|
|
syncTreeSectionExpanded(section);
|
|
});
|
|
const firstRoot = roots[0];
|
|
selectTreeNode(firstRoot,
|
|
!modalFocusManager.getActiveModal() && !keyboardMarketFocusPending);
|
|
if (typeof firstRoot.scrollIntoView === "function") {
|
|
firstRoot.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
}
|
|
treeUiStateByTab.set(tabId, {
|
|
openSections: new Map(details.map(function (section) {
|
|
return [section.dataset.treeSectionKey, true];
|
|
})),
|
|
selectedKey: firstRoot.dataset.treeNodeKey,
|
|
focusKey: firstRoot.dataset.treeNodeKey,
|
|
scrollTop: categoryTree.scrollTop
|
|
});
|
|
pendingTreeResetTabs.delete(tabId);
|
|
return;
|
|
}
|
|
|
|
const saved = treeUiStateByTab.get(tabId);
|
|
if (!saved) return;
|
|
if (saved.openSections instanceof Map) {
|
|
details.forEach(function (section) {
|
|
if (saved.openSections.has(section.dataset.treeSectionKey)) {
|
|
section.open = saved.openSections.get(section.dataset.treeSectionKey) === true;
|
|
}
|
|
syncTreeSectionExpanded(section);
|
|
});
|
|
}
|
|
const selected = findTreeNodeByKey(saved.selectedKey);
|
|
if (selected) selectTreeNode(selected, false);
|
|
const canRestoreFocus = treeFocusOwnedBeforeRender &&
|
|
!keyboardMarketFocusPending &&
|
|
!modalFocusManager.getActiveModal();
|
|
const focused = findTreeNodeByKey(saved.focusKey);
|
|
const control = canRestoreFocus && saved.controlFocus && saved.controlFocus.id
|
|
? document.getElementById(saved.controlFocus.id)
|
|
: null;
|
|
const canRestoreControl = control && categoryTree.contains(control) &&
|
|
control.disabled !== true && typeof control.focus === "function";
|
|
// A focused editor must never be blurred through the previously selected
|
|
// tree node. Apart from losing the local draft, that blur commits/cancels
|
|
// an in-progress Korean IME composition on every native status refresh.
|
|
if (canRestoreFocus && !canRestoreControl && focused &&
|
|
typeof focused.focus === "function") {
|
|
focused.focus({ preventScroll: true });
|
|
}
|
|
if (canRestoreControl) {
|
|
const controlWasRecreated = document.activeElement !== control;
|
|
if (controlWasRecreated && typeof saved.controlFocus.value === "string" &&
|
|
typeof control.value === "string") {
|
|
control.value = saved.controlFocus.value;
|
|
}
|
|
if (controlWasRecreated) control.focus({ preventScroll: true });
|
|
if (Number.isInteger(saved.controlFocus.selectionStart) &&
|
|
Number.isInteger(saved.controlFocus.selectionEnd) &&
|
|
typeof control.setSelectionRange === "function" && controlWasRecreated) {
|
|
try {
|
|
control.setSelectionRange(
|
|
saved.controlFocus.selectionStart,
|
|
saved.controlFocus.selectionEnd);
|
|
} catch (_) {
|
|
// Non-text controls can expose selection properties without accepting a range.
|
|
}
|
|
}
|
|
}
|
|
if (Number.isFinite(saved.scrollTop)) categoryTree.scrollTop = saved.scrollTop;
|
|
}
|
|
|
|
function send(type, payload) {
|
|
if (!webview || uiBusy) return false;
|
|
try {
|
|
webview.postMessage({ type: type, payload: payload || {} });
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function rememberSearchDraft(input) {
|
|
if (!input || !searchDraftInputIds.has(input.id)) return;
|
|
const previous = searchDrafts.get(input.id);
|
|
const stateValue = previous ? previous.stateValue : "";
|
|
searchDrafts.set(input.id, {
|
|
value: input.value,
|
|
stateValue: stateValue,
|
|
dirty: input.value !== stateValue,
|
|
scope: previous ? previous.scope : null
|
|
});
|
|
}
|
|
|
|
function syncSearchDraft(input, stateValue, scope) {
|
|
if (!input) return;
|
|
const canonicalStateValue = stateValue || "";
|
|
const canonicalScope = scope === undefined || scope === null ? null : String(scope);
|
|
const remembered = searchDrafts.get(input.id);
|
|
const previous = remembered && remembered.scope === canonicalScope
|
|
? remembered : null;
|
|
let next;
|
|
if (!previous) {
|
|
next = {
|
|
value: canonicalStateValue,
|
|
stateValue: canonicalStateValue,
|
|
dirty: false,
|
|
scope: canonicalScope
|
|
};
|
|
} else if (previous.value === canonicalStateValue) {
|
|
next = {
|
|
value: canonicalStateValue,
|
|
stateValue: canonicalStateValue,
|
|
dirty: false,
|
|
scope: canonicalScope
|
|
};
|
|
} else if (previous.dirty) {
|
|
next = {
|
|
value: previous.value,
|
|
stateValue: canonicalStateValue,
|
|
dirty: true,
|
|
scope: canonicalScope
|
|
};
|
|
} else {
|
|
next = {
|
|
value: canonicalStateValue,
|
|
stateValue: canonicalStateValue,
|
|
dirty: false,
|
|
scope: canonicalScope
|
|
};
|
|
}
|
|
searchDrafts.set(input.id, next);
|
|
if (document.activeElement !== input) input.value = next.value;
|
|
}
|
|
|
|
function forgetSearchDraft() {
|
|
Array.from(arguments).forEach(function (inputId) {
|
|
searchDrafts.delete(inputId);
|
|
});
|
|
}
|
|
|
|
function captureSearchDraftFocus(root) {
|
|
const input = document.activeElement;
|
|
if (!root || !input || !searchDraftInputIds.has(input.id) ||
|
|
typeof root.contains !== "function" || !root.contains(input)) return null;
|
|
rememberSearchDraft(input);
|
|
return {
|
|
inputId: input.id,
|
|
selectionStart: Number.isInteger(input.selectionStart) ? input.selectionStart : null,
|
|
selectionEnd: Number.isInteger(input.selectionEnd) ? input.selectionEnd : null,
|
|
selectionDirection: typeof input.selectionDirection === "string"
|
|
? input.selectionDirection : null
|
|
};
|
|
}
|
|
|
|
function restoreSearchDraftFocus(root, saved) {
|
|
if (!root || !saved || !saved.inputId) return false;
|
|
const input = document.getElementById(saved.inputId);
|
|
if (!input || input.disabled === true || typeof root.contains !== "function" ||
|
|
!root.contains(input) || typeof input.focus !== "function") return false;
|
|
if (document.activeElement !== input) input.focus({ preventScroll: true });
|
|
if (Number.isInteger(saved.selectionStart) && Number.isInteger(saved.selectionEnd) &&
|
|
typeof input.setSelectionRange === "function") {
|
|
try {
|
|
input.setSelectionRange(
|
|
saved.selectionStart,
|
|
saved.selectionEnd,
|
|
saved.selectionDirection || "none");
|
|
} catch (_) {
|
|
// Text-like modal inputs support ranges; retain focus if a host rejects one.
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function isImeComposing(event) {
|
|
return event.isComposing === true || event.keyCode === 229;
|
|
}
|
|
|
|
function setTransientControlsBusy(root, busy) {
|
|
if (!root) return;
|
|
root.querySelectorAll("button, input, select, textarea").forEach(function (control) {
|
|
if (busy) {
|
|
if (control.dataset.busyDisabled === undefined) {
|
|
control.dataset.busyDisabled = control.disabled === true ? "true" : "false";
|
|
}
|
|
control.disabled = true;
|
|
} else if (control.dataset.busyDisabled !== undefined) {
|
|
control.disabled = control.dataset.busyDisabled === "true";
|
|
delete control.dataset.busyDisabled;
|
|
}
|
|
});
|
|
}
|
|
|
|
function isOperatorMutationLocked(state) {
|
|
const playout = state && state.playout;
|
|
return !state || state.isBusy === true || state.fixedSectionBatch != null ||
|
|
(playout && (playout.isBusy === true || playout.outcomeUnknown === true ||
|
|
playout.isPlayCompletionPending === true ||
|
|
playout.isTakeOutCompletionPending === true));
|
|
}
|
|
|
|
function isPlaylistStructureMutationLocked(state) {
|
|
const playout = state && state.playout;
|
|
return isOperatorMutationLocked(state) ||
|
|
(playout && playout.phase !== "idle");
|
|
}
|
|
|
|
function isPlaylistProgramEnableMutationLocked(state) {
|
|
const playout = state && state.playout;
|
|
return isOperatorMutationLocked(state) || !playout ||
|
|
(playout.phase !== "idle" && playout.phase !== "program");
|
|
}
|
|
|
|
function canDeleteSelectedPlaylistRows(state) {
|
|
if (isOperatorMutationLocked(state) || !state || !Array.isArray(state.playlist)) {
|
|
return false;
|
|
}
|
|
const selectedIndices = [];
|
|
state.playlist.forEach(function (row, index) {
|
|
if (row.isSelected === true) selectedIndices.push(index);
|
|
});
|
|
if (selectedIndices.length === 0) return false;
|
|
const playout = state.playout;
|
|
if (!playout || playout.phase === "idle") return true;
|
|
const currentIndex = Number(playout.currentCueIndexZeroBased);
|
|
return Number.isSafeInteger(currentIndex) && currentIndex >= 0 &&
|
|
selectedIndices.every(function (index) { return index > currentIndex; });
|
|
}
|
|
|
|
function isCutSelectionLocked(state) {
|
|
const playout = state && state.playout;
|
|
return !state || state.isBusy === true || state.fixedSectionBatch != null ||
|
|
(playout && (playout.isBusy === true || playout.outcomeUnknown === true ||
|
|
playout.isPlayCompletionPending === true ||
|
|
playout.isTakeOutCompletionPending === true));
|
|
}
|
|
|
|
function dragDropPosition(event, element) {
|
|
const bounds = element.getBoundingClientRect();
|
|
return event.clientY < bounds.top + (bounds.height / 2) ? "before" : "after";
|
|
}
|
|
|
|
function playlistPlayoutPresentation(state, row, position) {
|
|
const playout = state && state.playout;
|
|
const phase = playout && typeof playout.phase === "string"
|
|
? playout.phase : "idle";
|
|
const hasActiveScene = phase === "prepared" || phase === "program";
|
|
const currentEntryId = playout && typeof playout.currentEntryId === "string" &&
|
|
playout.currentEntryId.length > 0 ? playout.currentEntryId : null;
|
|
const currentIndex = playout && Number.isInteger(playout.currentCueIndexZeroBased)
|
|
? playout.currentCueIndexZeroBased : -1;
|
|
const isCurrent = hasActiveScene && (currentEntryId !== null
|
|
? currentEntryId === row.rowId
|
|
: currentIndex === position);
|
|
const pageCount = isCurrent && Number.isInteger(playout.pageCount) &&
|
|
playout.pageCount > 0 ? playout.pageCount : 0;
|
|
const pageIndex = isCurrent && Number.isInteger(playout.pageIndexZeroBased) &&
|
|
playout.pageIndexZeroBased >= 0 && playout.pageIndexZeroBased < pageCount
|
|
? playout.pageIndexZeroBased : -1;
|
|
|
|
return {
|
|
isSelected: row.isSelected === true,
|
|
isCurrent: isCurrent,
|
|
isOnAir: isCurrent && phase === "program",
|
|
pageText: pageIndex >= 0
|
|
? String(pageIndex + 1) + "/" + String(pageCount)
|
|
: row.pageText,
|
|
focusKey: isCurrent
|
|
? [phase, currentEntryId || row.rowId || currentIndex, pageIndex, pageCount].join(":")
|
|
: ""
|
|
};
|
|
}
|
|
|
|
function presentCurrentPlaylistRow(element, focusKey) {
|
|
if (!element || !focusKey) {
|
|
lastPresentedPlayoutRowKey = "";
|
|
return;
|
|
}
|
|
if (lastPresentedPlayoutRowKey === focusKey) return;
|
|
|
|
lastPresentedPlayoutRowKey = focusKey;
|
|
element.focus({ preventScroll: true });
|
|
if (typeof element.scrollIntoView === "function") {
|
|
element.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
}
|
|
}
|
|
|
|
function selectPlaylistBoundary(boundary) {
|
|
const rows = playlistRows.querySelectorAll(".playlist-data-row");
|
|
const element = boundary === "last"
|
|
? rows.item(rows.length - 1)
|
|
: rows.item(0);
|
|
if (!element) return;
|
|
|
|
// MainForm's KeyPreview changed the playlist selection but returned the
|
|
// base key result. Keep the textbox/ListBox caret and focus where the
|
|
// operator pressed Home/End; only mirror the playlist scroll/selection.
|
|
if (typeof element.scrollIntoView === "function") {
|
|
element.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
}
|
|
send("select-playlist-boundary", { boundary: boundary });
|
|
}
|
|
|
|
function movePlaylistKeyboardSelection(element, event) {
|
|
if (!element || uiBusy) return false;
|
|
const rows = Array.from(playlistRows.querySelectorAll(".playlist-data-row"));
|
|
const currentIndex = rows.indexOf(element);
|
|
if (currentIndex < 0 || rows.length === 0) return false;
|
|
const offset = event.key === "ArrowUp" ? -1 : 1;
|
|
const nextIndex = Math.max(0, Math.min(rows.length - 1, currentIndex + offset));
|
|
const next = rows[nextIndex];
|
|
next.focus({ preventScroll: true });
|
|
if (typeof next.scrollIntoView === "function") {
|
|
next.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
}
|
|
if (nextIndex !== currentIndex) {
|
|
send("select-playlist-row", {
|
|
rowId: next.dataset.rowId,
|
|
control: event.ctrlKey,
|
|
shift: event.shiftKey
|
|
});
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const fallbackDoubleClickTimeMilliseconds = 500;
|
|
const maximumDoubleClickTimeMilliseconds = 5000;
|
|
const doubleClickTimerGraceMilliseconds = 50;
|
|
let doubleClickTimeMilliseconds = fallbackDoubleClickTimeMilliseconds;
|
|
|
|
function updateDoubleClickTime(state) {
|
|
const candidate = state && state.interactionMetrics &&
|
|
state.interactionMetrics.doubleClickTimeMilliseconds;
|
|
doubleClickTimeMilliseconds = Number.isInteger(candidate) && candidate > 0 &&
|
|
candidate <= maximumDoubleClickTimeMilliseconds
|
|
? candidate
|
|
: fallbackDoubleClickTimeMilliseconds;
|
|
}
|
|
|
|
function deferLegacySingleClick(callback) {
|
|
return window.setTimeout(
|
|
callback,
|
|
doubleClickTimeMilliseconds + doubleClickTimerGraceMilliseconds);
|
|
}
|
|
|
|
function clearDelayedClick(timer) {
|
|
if (timer !== null) window.clearTimeout(timer);
|
|
}
|
|
|
|
function pendingSelectionContainer(row) {
|
|
if (!row || typeof row.closest !== "function") return null;
|
|
return row.closest("[role=\"listbox\"]") || row.parentElement;
|
|
}
|
|
|
|
function applyPendingRowSelection(pending, focus) {
|
|
if (!pending || !pending.container || !pending.row ||
|
|
pending.container.isConnected === false || pending.row.isConnected === false) {
|
|
return false;
|
|
}
|
|
pending.rows.forEach(function (entry) {
|
|
if (!entry.element || entry.element.isConnected === false) return;
|
|
const selected = entry.element === pending.row;
|
|
entry.element.classList.toggle("selected", selected);
|
|
entry.element.setAttribute("aria-selected", selected ? "true" : "false");
|
|
entry.element.tabIndex = selected ? 0 : -1;
|
|
});
|
|
if (pending.row.id) {
|
|
pending.container.setAttribute("aria-activedescendant", pending.row.id);
|
|
}
|
|
if (focus !== false && typeof pending.row.focus === "function") {
|
|
pending.row.focus({ preventScroll: true });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function createPendingRowSelection(row, rowSelector, key) {
|
|
const container = pendingSelectionContainer(row);
|
|
if (!container) return null;
|
|
const rows = Array.from(container.querySelectorAll(rowSelector)).map(function (element) {
|
|
return {
|
|
element: element,
|
|
selected: element.classList.contains("selected"),
|
|
ariaSelected: element.getAttribute("aria-selected"),
|
|
tabIndex: element.tabIndex
|
|
};
|
|
});
|
|
const pending = {
|
|
key: key,
|
|
row: row,
|
|
container: container,
|
|
rows: rows,
|
|
activeDescendant: container.getAttribute("aria-activedescendant"),
|
|
sent: false,
|
|
lockedControls: []
|
|
};
|
|
applyPendingRowSelection(pending, true);
|
|
return pending;
|
|
}
|
|
|
|
function restorePendingRowSelection(pending) {
|
|
if (!pending || !pending.container || pending.container.isConnected === false) return;
|
|
pending.rows.forEach(function (entry) {
|
|
if (!entry.element || entry.element.isConnected === false) return;
|
|
entry.element.classList.toggle("selected", entry.selected);
|
|
entry.element.setAttribute(
|
|
"aria-selected",
|
|
entry.ariaSelected === null ? "false" : entry.ariaSelected);
|
|
entry.element.tabIndex = entry.tabIndex;
|
|
});
|
|
if (pending.activeDescendant) {
|
|
pending.container.setAttribute("aria-activedescendant", pending.activeDescendant);
|
|
} else if (typeof pending.container.removeAttribute === "function") {
|
|
pending.container.removeAttribute("aria-activedescendant");
|
|
}
|
|
}
|
|
|
|
function lockPendingControls(root, selector) {
|
|
if (!root) return [];
|
|
const controls = Array.from(root.querySelectorAll(selector));
|
|
const locked = controls.map(function (control) {
|
|
return { control: control, disabled: control.disabled === true };
|
|
});
|
|
controls.forEach(function (control) { control.disabled = true; });
|
|
return locked;
|
|
}
|
|
|
|
function applyPendingControlLocks(lockedControls) {
|
|
(lockedControls || []).forEach(function (entry) {
|
|
if (entry.control && entry.control.isConnected !== false) {
|
|
entry.control.disabled = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
function releasePendingControlLocks(lockedControls) {
|
|
(lockedControls || []).forEach(function (entry) {
|
|
const control = entry.control;
|
|
if (!control || control.isConnected === false) return;
|
|
if (control.dataset && control.dataset.busyDisabled !== undefined) {
|
|
// Keep a host busy snapshot authoritative while correcting the semantic
|
|
// value that setTransientControlsBusy will restore when that snapshot ends.
|
|
control.dataset.busyDisabled = entry.disabled ? "true" : "false";
|
|
} else {
|
|
control.disabled = entry.disabled;
|
|
}
|
|
});
|
|
}
|
|
|
|
function finishPendingRowSelection(pending, restoreVisual) {
|
|
if (!pending) return;
|
|
releasePendingControlLocks(pending.lockedControls);
|
|
if (restoreVisual === true) restorePendingRowSelection(pending);
|
|
}
|
|
|
|
function clearManualResultPendingSelection(restoreVisual) {
|
|
clearDelayedClick(manualResultClickTimer);
|
|
manualResultClickTimer = null;
|
|
const pending = manualResultPendingSelection;
|
|
manualResultPendingSelection = null;
|
|
finishPendingRowSelection(pending, restoreVisual);
|
|
}
|
|
|
|
function clearThemeResultPendingSelection(restoreVisual) {
|
|
clearDelayedClick(themeResultClickTimer);
|
|
themeResultClickTimer = null;
|
|
const pending = themeResultPendingSelection;
|
|
themeResultPendingSelection = null;
|
|
finishPendingRowSelection(pending, restoreVisual);
|
|
}
|
|
|
|
function clearComparisonTargetPendingSelection(restoreVisual) {
|
|
clearDelayedClick(comparisonTargetClickTimer);
|
|
comparisonTargetClickTimer = null;
|
|
const pending = comparisonTargetPendingSelection;
|
|
comparisonTargetPendingSelection = null;
|
|
finishPendingRowSelection(pending, restoreVisual);
|
|
}
|
|
|
|
function clearManualViResultPendingSelection(restoreVisual) {
|
|
clearDelayedClick(manualViResultClickTimer);
|
|
manualViResultClickTimer = null;
|
|
const pending = manualViResultPendingSelection;
|
|
manualViResultPendingSelection = null;
|
|
finishPendingRowSelection(pending, restoreVisual);
|
|
}
|
|
|
|
function beginManualResultPendingSelection(row) {
|
|
const key = row.dataset.manualResultId;
|
|
if (manualResultPendingSelection && !manualResultPendingSelection.sent &&
|
|
manualResultPendingSelection.key === key) {
|
|
applyPendingRowSelection(manualResultPendingSelection, true);
|
|
applyPendingControlLocks(manualResultPendingSelection.lockedControls);
|
|
return manualResultPendingSelection;
|
|
}
|
|
clearManualResultPendingSelection(true);
|
|
manualResultPendingSelection = createPendingRowSelection(
|
|
row,
|
|
"button[data-manual-result-id]",
|
|
key);
|
|
if (manualResultPendingSelection) {
|
|
manualResultPendingSelection.lockedControls = lockPendingControls(
|
|
manualWorkspace,
|
|
".manual-financial-editor input, .manual-financial-editor select, " +
|
|
".manual-financial-editor textarea, button[data-manual-stock-search], " +
|
|
"button[data-manual-stock-result-id], button[data-manual-save], " +
|
|
"button[data-manual-delete], button[data-manual-delete-all], " +
|
|
"button[data-manual-action-id]");
|
|
}
|
|
return manualResultPendingSelection;
|
|
}
|
|
|
|
function beginThemeResultPendingSelection(row) {
|
|
const key = row.dataset.themeResultId;
|
|
if (themeResultPendingSelection && !themeResultPendingSelection.sent &&
|
|
themeResultPendingSelection.key === key) {
|
|
applyPendingRowSelection(themeResultPendingSelection, true);
|
|
applyPendingControlLocks(themeResultPendingSelection.lockedControls);
|
|
return themeResultPendingSelection;
|
|
}
|
|
clearThemeResultPendingSelection(true);
|
|
themeResultPendingSelection = createPendingRowSelection(
|
|
row,
|
|
"button[data-theme-result-id]",
|
|
key);
|
|
if (themeResultPendingSelection) {
|
|
themeResultPendingSelection.lockedControls = lockPendingControls(
|
|
categoryTree,
|
|
"button[data-theme-action-id], #uc4-theme-edit, #uc4-theme-delete");
|
|
}
|
|
return themeResultPendingSelection;
|
|
}
|
|
|
|
function beginComparisonTargetPendingSelection(row, key) {
|
|
if (comparisonTargetPendingSelection && !comparisonTargetPendingSelection.sent &&
|
|
comparisonTargetPendingSelection.key === key) {
|
|
applyPendingRowSelection(comparisonTargetPendingSelection, true);
|
|
return comparisonTargetPendingSelection;
|
|
}
|
|
clearComparisonTargetPendingSelection(true);
|
|
comparisonTargetPendingSelection = createPendingRowSelection(
|
|
row,
|
|
row.dataset.comparisonTargetId
|
|
? "button[data-comparison-target-id]"
|
|
: "button[data-comparison-selection-id]",
|
|
key);
|
|
if (comparisonTargetPendingSelection) {
|
|
comparisonTargetPendingSelection.kind = row.dataset.comparisonTargetId
|
|
? "fixed" : row.dataset.comparisonChoice;
|
|
}
|
|
return comparisonTargetPendingSelection;
|
|
}
|
|
|
|
function beginManualViResultPendingSelection(row, key) {
|
|
if (manualViResultPendingSelection && !manualViResultPendingSelection.sent &&
|
|
manualViResultPendingSelection.key === key) {
|
|
applyPendingRowSelection(manualViResultPendingSelection, true);
|
|
return manualViResultPendingSelection;
|
|
}
|
|
clearManualViResultPendingSelection(true);
|
|
manualViResultPendingSelection = createPendingRowSelection(
|
|
row,
|
|
"button[data-manual-vi-result-id]",
|
|
key);
|
|
return manualViResultPendingSelection;
|
|
}
|
|
|
|
function isRovingListNavigationKey(key) {
|
|
return key === "ArrowUp" || key === "ArrowDown" ||
|
|
key === "Home" || key === "End" ||
|
|
key === "PageUp" || key === "PageDown";
|
|
}
|
|
|
|
function rovingListRows(list) {
|
|
return list
|
|
? Array.from(list.querySelectorAll("[data-roving-row]"))
|
|
: [];
|
|
}
|
|
|
|
function configureRovingList(list, rows, selectedRow, options) {
|
|
if (!list) return;
|
|
const settings = options || {};
|
|
list.dataset.rovingList = "true";
|
|
list.dataset.rovingPageSize = String(
|
|
Number.isSafeInteger(settings.pageSize) && settings.pageSize > 0
|
|
? settings.pageSize : 9);
|
|
list.dataset.rovingSelectionFollowsFocus =
|
|
settings.selectionFollowsFocus === false ? "false" : "true";
|
|
list.dataset.rovingActivation = settings.activation === "focus"
|
|
? "focus" : "select";
|
|
list.setAttribute("role", settings.listRole || "listbox");
|
|
const enabledRows = rows.filter(isRovingListRowAvailable);
|
|
const tabStop = selectedRow && isRovingListRowAvailable(selectedRow)
|
|
? selectedRow
|
|
: (enabledRows[0] || null);
|
|
rows.forEach(function (row) {
|
|
row.dataset.rovingRow = "true";
|
|
row.setAttribute("role", settings.rowRole || "option");
|
|
row.tabIndex = row === tabStop ? 0 : -1;
|
|
});
|
|
// An empty WinForms ListBox is still one tab stop. Once it has rows, the
|
|
// selected (or first selectable) row carries that one stop for WebView2.
|
|
list.tabIndex = tabStop ? -1 : 0;
|
|
}
|
|
|
|
function isRovingListRowAvailable(row) {
|
|
if (!row || row.disabled === true ||
|
|
row.getAttribute("aria-disabled") === "true") return false;
|
|
if (row.dataset && row.dataset.treeNodeKey &&
|
|
row.dataset.treeRoot !== "true" && typeof row.closest === "function") {
|
|
const details = row.closest("details");
|
|
if (details && details.open !== true) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function rovingListPageSize(list) {
|
|
const value = list && list.dataset
|
|
? Number(list.dataset.rovingPageSize) : NaN;
|
|
return Number.isSafeInteger(value) && value > 0 ? value : 9;
|
|
}
|
|
|
|
function rovingListKeyTarget(list, currentRow, key) {
|
|
if (!list || !isRovingListNavigationKey(key)) return null;
|
|
const rows = rovingListRows(list).filter(isRovingListRowAvailable);
|
|
if (rows.length === 0) return null;
|
|
let index = rows.indexOf(currentRow);
|
|
if (index < 0) {
|
|
index = rows.findIndex(function (row) {
|
|
return row.getAttribute("aria-selected") === "true";
|
|
});
|
|
}
|
|
if (key === "Home") return rows[0];
|
|
if (key === "End") return rows[rows.length - 1];
|
|
if (index < 0) {
|
|
return key === "PageDown"
|
|
? rows[Math.min(rows.length - 1, rovingListPageSize(list))]
|
|
: rows[0];
|
|
}
|
|
const offset = key === "PageUp" || key === "PageDown"
|
|
? rovingListPageSize(list) : 1;
|
|
return key === "ArrowUp" || key === "PageUp"
|
|
? rows[Math.max(0, index - offset)]
|
|
: rows[Math.min(rows.length - 1, index + offset)];
|
|
}
|
|
|
|
function setRovingListTabStop(list, row) {
|
|
if (!list || !row || !isRovingListRowAvailable(row)) return false;
|
|
rovingListRows(list).forEach(function (candidate) {
|
|
candidate.tabIndex = candidate === row ? 0 : -1;
|
|
});
|
|
list.tabIndex = -1;
|
|
return true;
|
|
}
|
|
|
|
function focusRovingListRow(list, row, select) {
|
|
if (!setRovingListTabStop(list, row)) return false;
|
|
rovingListRows(list).forEach(function (candidate) {
|
|
const isTarget = candidate === row;
|
|
if (select === true) {
|
|
candidate.classList.toggle("selected", isTarget);
|
|
candidate.setAttribute("aria-selected", isTarget ? "true" : "false");
|
|
}
|
|
});
|
|
if (document.activeElement !== row) row.focus({ preventScroll: true });
|
|
if (typeof row.scrollIntoView === "function") {
|
|
row.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function moveRovingListSelection(list, currentRow, key, activate) {
|
|
const target = rovingListKeyTarget(list, currentRow, key);
|
|
if (!target) return false;
|
|
const selectionChanged = target.getAttribute("aria-selected") !== "true";
|
|
const focusOnly = list.dataset.rovingActivation === "focus";
|
|
if (!focusOnly && selectionChanged && activate(target) === false) return false;
|
|
return focusRovingListRow(
|
|
list,
|
|
target,
|
|
list.dataset.rovingSelectionFollowsFocus !== "false");
|
|
}
|
|
|
|
function stockResultPageOffset(button) {
|
|
const itemHeight = button && Number.isFinite(button.offsetHeight)
|
|
? button.offsetHeight
|
|
: 0;
|
|
const viewportHeight = Number.isFinite(stockResults.clientHeight)
|
|
? stockResults.clientHeight
|
|
: 0;
|
|
if (itemHeight <= 0 || viewportHeight <= 0) return 9;
|
|
|
|
// The native 174 px ListBox exposes ten 17 px rows and PageUp/PageDown
|
|
// retain one boundary row, so its selection moves by visibleRows - 1.
|
|
const visibleRows = Math.max(1, Math.floor(viewportHeight / itemHeight));
|
|
return Math.max(1, visibleRows - 1);
|
|
}
|
|
|
|
function stockResultKeyTarget(results, key) {
|
|
if (!results || results.length === 0) return null;
|
|
|
|
let selectedPosition = -1;
|
|
for (let position = 0; position < results.length; position += 1) {
|
|
if (results.item(position).getAttribute("aria-selected") === "true") {
|
|
selectedPosition = position;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const pageOffset = stockResultPageOffset(results.item(0));
|
|
let targetPosition;
|
|
if (key === "End") {
|
|
targetPosition = results.length - 1;
|
|
} else if (key === "Home") {
|
|
targetPosition = 0;
|
|
} else if (key === "ArrowUp" || key === "PageUp") {
|
|
const offset = key === "ArrowUp" ? 1 : pageOffset;
|
|
targetPosition = selectedPosition < 0 ? 0 : selectedPosition - offset;
|
|
} else if (key === "ArrowDown" || key === "PageDown") {
|
|
const offset = key === "ArrowDown" ? 1 : pageOffset;
|
|
targetPosition = selectedPosition < 0
|
|
? (key === "PageDown" ? offset : 0)
|
|
: selectedPosition + offset;
|
|
} else {
|
|
return null;
|
|
}
|
|
|
|
return results.item(Math.max(0, Math.min(results.length - 1, targetPosition)));
|
|
}
|
|
|
|
function selectStockResult(button, focus) {
|
|
if (!button || button.disabled || uiBusy) return;
|
|
const results = stockResults.querySelectorAll("button[data-result-index]");
|
|
const selectionChanged = button.getAttribute("aria-selected") !== "true";
|
|
for (let position = 0; position < results.length; position += 1) {
|
|
const result = results.item(position);
|
|
const isSelected = result === button;
|
|
result.tabIndex = isSelected ? 0 : -1;
|
|
result.classList.toggle("selected", isSelected);
|
|
result.setAttribute("aria-selected", isSelected ? "true" : "false");
|
|
}
|
|
if (focus === true) {
|
|
button.focus({ preventScroll: true });
|
|
if (typeof button.scrollIntoView === "function") {
|
|
button.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
}
|
|
}
|
|
if (selectionChanged) {
|
|
send("select-stock", { resultIndex: Number(button.dataset.resultIndex) });
|
|
}
|
|
}
|
|
|
|
function createStockResultElement(row) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.dataset.resultIndex = String(row.index);
|
|
button.setAttribute("role", "option");
|
|
button.addEventListener("click", function () {
|
|
selectStockResult(button, false);
|
|
});
|
|
button.addEventListener("keydown", function (event) {
|
|
const results = stockResults.querySelectorAll("button[data-result-index]");
|
|
const target = stockResultKeyTarget(results, event.key);
|
|
if (!target) return;
|
|
// Consume only the native ListBox navigation behavior. Home/End continue
|
|
// bubbling to MainForm's KeyPreview-equivalent playlist boundary handler.
|
|
event.preventDefault();
|
|
selectStockResult(target, true);
|
|
});
|
|
return button;
|
|
}
|
|
|
|
function renderStockResults(state) {
|
|
let hasSelectedResult = false;
|
|
state.searchResults.forEach(function (row, position) {
|
|
let button = stockResults.children.item(position);
|
|
if (!button || button.dataset.resultIndex !== String(row.index)) {
|
|
button = createStockResultElement(row);
|
|
if (position < stockResults.children.length) {
|
|
stockResults.replaceChild(button, stockResults.children.item(position));
|
|
} else {
|
|
stockResults.appendChild(button);
|
|
}
|
|
}
|
|
|
|
const isSelected = state.selectedStockIndex === row.index;
|
|
if (isSelected) hasSelectedResult = true;
|
|
button.className = "stock-result" + (isSelected ? " selected" : "");
|
|
button.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false");
|
|
button.disabled = state.isBusy === true;
|
|
button.textContent = row.displayName;
|
|
button.setAttribute("aria-selected", isSelected ? "true" : "false");
|
|
button.tabIndex = isSelected ? 0 : -1;
|
|
});
|
|
|
|
while (stockResults.children.length > state.searchResults.length) {
|
|
stockResults.lastElementChild.remove();
|
|
}
|
|
|
|
// A WinForms ListBox is one tab stop even before it has a selection.
|
|
if (!hasSelectedResult && stockResults.children.length > 0) {
|
|
stockResults.children.item(0).tabIndex = 0;
|
|
}
|
|
}
|
|
|
|
function renderStockSearchMeta(state) {
|
|
const results = Array.isArray(state.searchResults) ? state.searchResults : [];
|
|
const selected = results.find(function (row) {
|
|
return row.index === state.selectedStockIndex;
|
|
});
|
|
let label = "";
|
|
|
|
if (state.isBusy === true) {
|
|
label = "검색 중";
|
|
} else if (results.length > 0 || String(state.searchText || "").trim().length > 0) {
|
|
label = String(results.length) + "건";
|
|
if (selected && selected.displayName) {
|
|
label += " · " + selected.displayName;
|
|
}
|
|
}
|
|
|
|
stockSearchMeta.textContent = label;
|
|
stockSearchMeta.hidden = label.length === 0;
|
|
if (state.isBusy !== true && results.length === 0) {
|
|
stockResults.dataset.emptyLabel = "결과 없음";
|
|
} else {
|
|
delete stockResults.dataset.emptyLabel;
|
|
}
|
|
}
|
|
|
|
function pointerPosition(event) {
|
|
const bounds = cutList.getBoundingClientRect();
|
|
return {
|
|
x: event.clientX - bounds.left,
|
|
y: event.clientY - bounds.top
|
|
};
|
|
}
|
|
|
|
function pointInsideElement(element, event) {
|
|
if (!element || typeof element.getBoundingClientRect !== "function") return false;
|
|
const bounds = element.getBoundingClientRect();
|
|
return event.clientX >= bounds.left && event.clientX < bounds.right &&
|
|
event.clientY >= bounds.top && event.clientY < bounds.bottom;
|
|
}
|
|
|
|
function clearCutDragGesture(releaseCapture) {
|
|
const gesture = cutDragGesture;
|
|
cutDragGesture = null;
|
|
playlistDropZone.classList.remove("cut-drag-over");
|
|
cutDragFeedback.hidden = true;
|
|
cutDragFeedback.classList.remove("allowed");
|
|
delete cutDragFeedback.dataset.dropAllowed;
|
|
cutDragFeedback.style.removeProperty("left");
|
|
cutDragFeedback.style.removeProperty("top");
|
|
if (!gesture) return;
|
|
gesture.element.classList.remove("dragging", "drag-allowed");
|
|
if (releaseCapture !== false &&
|
|
typeof gesture.element.hasPointerCapture === "function" &&
|
|
gesture.element.hasPointerCapture(gesture.pointerId) &&
|
|
typeof gesture.element.releasePointerCapture === "function") {
|
|
try {
|
|
gesture.element.releasePointerCapture(gesture.pointerId);
|
|
} catch (_) {
|
|
// The browser may release capture between the key event and cleanup.
|
|
}
|
|
}
|
|
}
|
|
|
|
function showCutDragFeedback(event, allowed) {
|
|
cutDragFeedback.hidden = false;
|
|
cutDragFeedback.classList.toggle("allowed", allowed);
|
|
cutDragFeedback.dataset.dropAllowed = allowed ? "true" : "false";
|
|
const gap = 14;
|
|
const viewportWidth = document.documentElement.clientWidth;
|
|
const viewportHeight = document.documentElement.clientHeight;
|
|
const width = cutDragFeedback.offsetWidth;
|
|
const height = cutDragFeedback.offsetHeight;
|
|
const left = Math.max(4, Math.min(event.clientX + gap, viewportWidth - width - 4));
|
|
const top = Math.max(4, Math.min(event.clientY + gap, viewportHeight - height - 4));
|
|
cutDragFeedback.style.left = Math.round(left) + "px";
|
|
cutDragFeedback.style.top = Math.round(top) + "px";
|
|
}
|
|
|
|
function beginCutDragGesture(element, event, position) {
|
|
clearCutDragGesture();
|
|
if (event.button !== 0) return;
|
|
cutDragGesture = {
|
|
element: element,
|
|
pointerId: event.pointerId,
|
|
down: position,
|
|
rectangle: window.LegacyCutDrag.createDragRectangle(position, cutDragSize),
|
|
started: false
|
|
};
|
|
if (typeof element.setPointerCapture === "function") {
|
|
try {
|
|
element.setPointerCapture(event.pointerId);
|
|
} catch (_) {
|
|
// Pointer capture can fail if the WebView has already released it.
|
|
}
|
|
}
|
|
}
|
|
|
|
function onCutPointerMove(event) {
|
|
const gesture = cutDragGesture;
|
|
if (!gesture || gesture.element !== event.currentTarget ||
|
|
gesture.pointerId !== event.pointerId) return;
|
|
if ((event.buttons & 1) !== 1) {
|
|
clearCutDragGesture();
|
|
return;
|
|
}
|
|
if (!gesture.started &&
|
|
window.LegacyCutDrag.shouldStart(gesture.rectangle, pointerPosition(event))) {
|
|
gesture.started = true;
|
|
gesture.element.classList.add("dragging");
|
|
}
|
|
if (gesture.started) {
|
|
event.preventDefault();
|
|
const dropAllowed = !playlistMutationLocked &&
|
|
pointInsideElement(playlistDropZone, event);
|
|
gesture.element.classList.toggle("drag-allowed", dropAllowed);
|
|
playlistDropZone.classList.toggle("cut-drag-over", dropAllowed);
|
|
showCutDragFeedback(event, dropAllowed);
|
|
}
|
|
}
|
|
|
|
function onCutPointerUp(event) {
|
|
const gesture = cutDragGesture;
|
|
if (!gesture || gesture.element !== event.currentTarget ||
|
|
gesture.pointerId !== event.pointerId) return;
|
|
const shouldDrop = gesture.started && !playlistMutationLocked &&
|
|
pointInsideElement(playlistDropZone, event);
|
|
clearCutDragGesture();
|
|
if (shouldDrop) send("drop-selected-cuts", {});
|
|
}
|
|
|
|
function onCutPointerCancel(event) {
|
|
const gesture = cutDragGesture;
|
|
if (gesture && gesture.element === event.currentTarget &&
|
|
gesture.pointerId === event.pointerId) {
|
|
clearCutDragGesture(event.type !== "lostpointercapture");
|
|
}
|
|
}
|
|
|
|
function legacyCutKey(key) {
|
|
return {
|
|
ArrowUp: "arrow-up",
|
|
ArrowDown: "arrow-down",
|
|
Home: "home",
|
|
End: "end",
|
|
" ": "space"
|
|
}[key] || null;
|
|
}
|
|
|
|
function focusCutElement(element, scroll) {
|
|
if (!element) return;
|
|
Array.from(cutList.children).forEach(function (row) {
|
|
row.tabIndex = row === element ? 0 : -1;
|
|
});
|
|
element.focus({ preventScroll: true });
|
|
if (scroll && typeof element.scrollIntoView === "function") {
|
|
element.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
}
|
|
}
|
|
|
|
function cutKeyboardTarget(element, key) {
|
|
const rows = Array.from(cutList.children);
|
|
const index = rows.indexOf(element);
|
|
if (index < 0 || rows.length === 0) return null;
|
|
if (key === "ArrowUp") return rows[Math.max(0, index - 1)];
|
|
if (key === "ArrowDown") return rows[Math.min(rows.length - 1, index + 1)];
|
|
if (key === "Home") return rows[0];
|
|
if (key === "End") return rows[rows.length - 1];
|
|
return element;
|
|
}
|
|
|
|
function createCutElement(row) {
|
|
const element = document.createElement("div");
|
|
element.setAttribute("role", "option");
|
|
element.dataset.physicalIndex = String(row.physicalIndex);
|
|
element.tabIndex = -1;
|
|
element.draggable = false;
|
|
|
|
const number = document.createElement("span");
|
|
number.className = "cut-number";
|
|
number.textContent = row.displayOrdinal === null ? "" : String(row.displayOrdinal);
|
|
const label = document.createElement("span");
|
|
label.textContent = row.rawLabel;
|
|
element.append(number, label);
|
|
|
|
element.addEventListener("pointerdown", function (event) {
|
|
if (!cutSelectionEnabled) return event.preventDefault();
|
|
focusCutElement(element, false);
|
|
const position = pointerPosition(event);
|
|
send("cut-pointer-down", {
|
|
physicalIndex: row.physicalIndex,
|
|
control: event.ctrlKey,
|
|
shift: event.shiftKey,
|
|
// C# retains integer coordinates only for the native double-click
|
|
// equality contract. Drag geometry keeps the raw fractional CSS point.
|
|
x: Math.round(position.x),
|
|
y: Math.round(position.y),
|
|
timestampMilliseconds: Math.max(0, Math.round(event.timeStamp)),
|
|
allowAppend: !playlistMutationLocked
|
|
});
|
|
if (!playlistMutationLocked) {
|
|
beginCutDragGesture(element, event, position);
|
|
}
|
|
});
|
|
element.addEventListener("pointermove", onCutPointerMove);
|
|
element.addEventListener("pointerup", onCutPointerUp);
|
|
element.addEventListener("pointercancel", onCutPointerCancel);
|
|
element.addEventListener("lostpointercapture", onCutPointerCancel);
|
|
element.addEventListener("keydown", function (event) {
|
|
const key = legacyCutKey(event.key);
|
|
if (!key) return;
|
|
// The native ListView consumes these keys. Home/End still bubble to the
|
|
// document-level MainForm KeyPreview equivalent for its playlist side effect.
|
|
event.preventDefault();
|
|
if (!cutSelectionEnabled) return;
|
|
const target = cutKeyboardTarget(element, event.key);
|
|
focusCutElement(target, true);
|
|
send("cut-key-down", {
|
|
key: key,
|
|
control: event.ctrlKey,
|
|
shift: event.shiftKey
|
|
});
|
|
});
|
|
return element;
|
|
}
|
|
|
|
function renderCuts(state) {
|
|
const cutDragLocked = isOperatorMutationLocked(state);
|
|
cutSelectionEnabled = Number.isInteger(state.selectedStockIndex) &&
|
|
!isCutSelectionLocked(state);
|
|
const focusedCutIndex = state.cutRows.findIndex(function (row) {
|
|
return row.isFocused === true;
|
|
});
|
|
// renderCuts precedes renderPlaylist, so publish the same lock before a
|
|
// cut row can initiate an append drag for this state revision.
|
|
playlistMutationLocked = cutDragLocked;
|
|
if (!cutSelectionEnabled || cutDragLocked ||
|
|
(cutDragGesture && !cutList.contains(cutDragGesture.element))) {
|
|
clearCutDragGesture();
|
|
}
|
|
state.cutRows.forEach(function (row, index) {
|
|
let element = cutList.children.item(index);
|
|
if (!element || element.dataset.physicalIndex !== String(row.physicalIndex)) {
|
|
element = createCutElement(row);
|
|
if (index < cutList.children.length) {
|
|
cutList.replaceChild(element, cutList.children.item(index));
|
|
} else {
|
|
cutList.appendChild(element);
|
|
}
|
|
}
|
|
|
|
element.className = "cut-row" +
|
|
(row.isSelected ? " selected" : "") +
|
|
(row.isSeparator ? " separator" : "") +
|
|
(cutDragGesture && cutDragGesture.element === element && cutDragGesture.started
|
|
? " dragging" : "");
|
|
element.setAttribute("aria-selected", row.isSelected ? "true" : "false");
|
|
element.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false");
|
|
element.tabIndex = row.isFocused === true || (focusedCutIndex < 0 && index === 0)
|
|
? 0
|
|
: -1;
|
|
element.draggable = false;
|
|
element.dataset.cutDragEnabled = cutSelectionEnabled && !cutDragLocked
|
|
? "true"
|
|
: "false";
|
|
});
|
|
|
|
while (cutList.children.length > state.cutRows.length) {
|
|
cutList.lastElementChild.remove();
|
|
}
|
|
if (cutDragGesture && !cutList.contains(cutDragGesture.element)) {
|
|
clearCutDragGesture();
|
|
}
|
|
|
|
if (state.cutRows.length === 0) {
|
|
cutList.dataset.emptyLabel = Number.isInteger(state.selectedStockIndex)
|
|
? "컷 없음"
|
|
: "종목을 선택하세요";
|
|
} else {
|
|
delete cutList.dataset.emptyLabel;
|
|
}
|
|
}
|
|
|
|
cutList.addEventListener("pointerdown", function (event) {
|
|
if (event.target !== cutList || !cutSelectionEnabled) return;
|
|
send("clear-cut-selection", {});
|
|
});
|
|
|
|
function clearPlaylistDragVisuals() {
|
|
playlistRows.querySelectorAll(".dragging, .drag-allowed, .drag-before, .drag-after")
|
|
.forEach(function (row) {
|
|
row.classList.remove("dragging", "drag-allowed", "drag-before", "drag-after");
|
|
});
|
|
}
|
|
|
|
function playlistPointerPosition(event) {
|
|
const bounds = playlistRows.getBoundingClientRect();
|
|
return {
|
|
x: event.clientX - bounds.left,
|
|
y: event.clientY - bounds.top
|
|
};
|
|
}
|
|
|
|
function playlistRowHeaderAtPoint(event) {
|
|
if (typeof document.elementFromPoint !== "function") return null;
|
|
const pointed = document.elementFromPoint(event.clientX, event.clientY);
|
|
const header = pointed && typeof pointed.closest === "function"
|
|
? pointed.closest(".playlist-row-header") : null;
|
|
return header && playlistRows.contains(header) ? header : null;
|
|
}
|
|
|
|
function clearPlaylistDragGesture(releaseCapture) {
|
|
const gesture = playlistDragGesture;
|
|
if (!gesture) return;
|
|
playlistDragGesture = null;
|
|
clearPlaylistDragVisuals();
|
|
if (releaseCapture !== false &&
|
|
typeof gesture.handle.hasPointerCapture === "function" &&
|
|
gesture.handle.hasPointerCapture(gesture.pointerId) &&
|
|
typeof gesture.handle.releasePointerCapture === "function") {
|
|
try {
|
|
gesture.handle.releasePointerCapture(gesture.pointerId);
|
|
} catch (_) {
|
|
// Chromium can release capture before the final cleanup callback.
|
|
}
|
|
}
|
|
}
|
|
|
|
function beginPlaylistDragGesture(handle, row, event) {
|
|
clearPlaylistDragGesture();
|
|
if (playlistStructureMutationLocked || event.button !== 0 || !row.dataset.rowId) return;
|
|
const position = playlistPointerPosition(event);
|
|
playlistDragGesture = {
|
|
handle: handle,
|
|
row: row,
|
|
rowId: row.dataset.rowId,
|
|
pointerId: event.pointerId,
|
|
down: position,
|
|
rectangle: window.LegacyCutDrag.createDragRectangle(position, cutDragSize),
|
|
started: false,
|
|
targetRowId: null,
|
|
position: null
|
|
};
|
|
if (typeof handle.setPointerCapture === "function") {
|
|
try {
|
|
handle.setPointerCapture(event.pointerId);
|
|
} catch (_) {
|
|
// Pointer capture can fail if WebView2 has already released the pointer.
|
|
}
|
|
}
|
|
}
|
|
|
|
function onPlaylistPointerMove(event) {
|
|
const gesture = playlistDragGesture;
|
|
if (!gesture || gesture.handle !== event.currentTarget ||
|
|
gesture.pointerId !== event.pointerId) return;
|
|
if ((event.buttons & 1) !== 1) {
|
|
deferredPlaylistPlainSelectionRowId = null;
|
|
clearPlaylistDragGesture();
|
|
return;
|
|
}
|
|
if (!gesture.started && window.LegacyCutDrag.shouldStart(
|
|
gesture.rectangle, playlistPointerPosition(event))) {
|
|
gesture.started = true;
|
|
deferredPlaylistPlainSelectionRowId = null;
|
|
gesture.row.classList.add("dragging");
|
|
}
|
|
if (!gesture.started) return;
|
|
|
|
event.preventDefault();
|
|
playlistRows.querySelectorAll(".drag-before, .drag-after").forEach(function (row) {
|
|
row.classList.remove("drag-before", "drag-after");
|
|
});
|
|
const targetHeader = playlistRowHeaderAtPoint(event);
|
|
const targetRow = targetHeader ? targetHeader.closest(".playlist-data-row") : null;
|
|
if (!targetRow || targetRow === gesture.row) {
|
|
gesture.targetRowId = null;
|
|
gesture.position = null;
|
|
gesture.row.classList.remove("drag-allowed");
|
|
return;
|
|
}
|
|
|
|
gesture.targetRowId = targetRow.dataset.rowId || null;
|
|
gesture.position = dragDropPosition(event, targetRow);
|
|
gesture.row.classList.toggle("drag-allowed", gesture.targetRowId !== null);
|
|
targetRow.classList.toggle("drag-before", gesture.position === "before");
|
|
targetRow.classList.toggle("drag-after", gesture.position === "after");
|
|
}
|
|
|
|
function onPlaylistPointerUp(event) {
|
|
const gesture = playlistDragGesture;
|
|
if (!gesture || gesture.handle !== event.currentTarget ||
|
|
gesture.pointerId !== event.pointerId) return;
|
|
const targetHeader = gesture.started ? playlistRowHeaderAtPoint(event) : null;
|
|
const targetRow = targetHeader ? targetHeader.closest(".playlist-data-row") : null;
|
|
const targetRowId = targetRow && targetRow !== gesture.row
|
|
? targetRow.dataset.rowId || null : null;
|
|
const shouldReorder = gesture.started && !playlistStructureMutationLocked &&
|
|
targetRowId && targetRowId !== gesture.rowId;
|
|
const sourceRowId = gesture.rowId;
|
|
const position = targetRow ? dragDropPosition(event, targetRow) : null;
|
|
const wasStarted = gesture.started;
|
|
clearPlaylistDragGesture();
|
|
if (!wasStarted) return;
|
|
|
|
deferredPlaylistPlainSelectionRowId = null;
|
|
event.preventDefault();
|
|
if (shouldReorder) {
|
|
send("reorder-playlist-rows", {
|
|
sourceRowId: sourceRowId,
|
|
targetRowId: targetRowId,
|
|
position: position
|
|
});
|
|
}
|
|
}
|
|
|
|
function onPlaylistPointerCancel(event) {
|
|
const gesture = playlistDragGesture;
|
|
if (!gesture || gesture.handle !== event.currentTarget ||
|
|
gesture.pointerId !== event.pointerId) return;
|
|
deferredPlaylistPlainSelectionRowId = null;
|
|
clearPlaylistDragGesture(event.type !== "lostpointercapture");
|
|
}
|
|
|
|
function createPlaylistElement(row) {
|
|
const element = document.createElement("div");
|
|
element.className = "playlist-row playlist-data-row";
|
|
element.dataset.rowId = row.rowId;
|
|
element.setAttribute("role", "option");
|
|
element.tabIndex = -1;
|
|
element.draggable = false;
|
|
|
|
const rowHeader = document.createElement("div");
|
|
rowHeader.className = "playlist-row-header";
|
|
rowHeader.setAttribute("role", "presentation");
|
|
rowHeader.draggable = false;
|
|
rowHeader.addEventListener("pointerdown", function (event) {
|
|
// FarPoint reports a row-header press separately from normal data-cell
|
|
// selection. It changes the active row used by F8, but it does not
|
|
// rewrite MainForm's Ctrl/Shift deletion set.
|
|
event.stopPropagation();
|
|
element.focus({ preventScroll: true });
|
|
send("activate-playlist-row", { rowId: element.dataset.rowId });
|
|
beginPlaylistDragGesture(rowHeader, element, event);
|
|
});
|
|
rowHeader.addEventListener("pointermove", onPlaylistPointerMove);
|
|
rowHeader.addEventListener("pointerup", onPlaylistPointerUp);
|
|
rowHeader.addEventListener("pointercancel", onPlaylistPointerCancel);
|
|
rowHeader.addEventListener("lostpointercapture", onPlaylistPointerCancel);
|
|
element.appendChild(rowHeader);
|
|
|
|
const enabledCell = document.createElement("div");
|
|
enabledCell.className = "enabled-cell";
|
|
const enabled = document.createElement("input");
|
|
enabled.type = "checkbox";
|
|
enabled.tabIndex = -1;
|
|
enabled.draggable = false;
|
|
enabled.addEventListener("click", function (event) {
|
|
if (playlistStructureMutationLocked) event.preventDefault();
|
|
});
|
|
enabled.addEventListener("change", function () {
|
|
if (playlistStructureMutationLocked) return;
|
|
send("set-playlist-enabled", {
|
|
rowId: element.dataset.rowId,
|
|
enabled: enabled.checked
|
|
});
|
|
});
|
|
enabledCell.appendChild(enabled);
|
|
element.appendChild(enabledCell);
|
|
|
|
for (let index = 0; index < 5; index += 1) {
|
|
element.appendChild(document.createElement("div"));
|
|
}
|
|
|
|
element.addEventListener("pointerdown", function (event) {
|
|
if (event.button !== 0) return;
|
|
if (enabledCell.contains(event.target)) {
|
|
element.focus({ preventScroll: true });
|
|
send("activate-playlist-row", { rowId: element.dataset.rowId });
|
|
return;
|
|
}
|
|
element.focus({ preventScroll: true });
|
|
const plainSelection = !event.ctrlKey && !event.shiftKey;
|
|
const deferSelectedRowPlainClickForDrag = plainSelection &&
|
|
element.getAttribute("aria-selected") === "true" &&
|
|
playlistRows.querySelectorAll('.playlist-data-row[aria-selected="true"]').length > 1;
|
|
if (deferSelectedRowPlainClickForDrag) {
|
|
deferredPlaylistPlainSelectionRowId = element.dataset.rowId;
|
|
return;
|
|
}
|
|
|
|
deferredPlaylistPlainSelectionRowId = null;
|
|
send("select-playlist-row", {
|
|
rowId: element.dataset.rowId,
|
|
control: event.ctrlKey,
|
|
shift: event.shiftKey
|
|
});
|
|
});
|
|
element.addEventListener("pointerup", function (event) {
|
|
if (enabledCell.contains(event.target)) {
|
|
return;
|
|
}
|
|
if (event.button !== 0 ||
|
|
deferredPlaylistPlainSelectionRowId !== element.dataset.rowId) return;
|
|
deferredPlaylistPlainSelectionRowId = null;
|
|
send("select-playlist-row", {
|
|
rowId: element.dataset.rowId,
|
|
control: false,
|
|
shift: false
|
|
});
|
|
});
|
|
element.addEventListener("pointercancel", function () {
|
|
if (deferredPlaylistPlainSelectionRowId === element.dataset.rowId) {
|
|
deferredPlaylistPlainSelectionRowId = null;
|
|
}
|
|
});
|
|
element.addEventListener("dragstart", function (event) {
|
|
// Only the 30 px row header owns the pointer-capture reorder gesture.
|
|
// Suppress Chromium's native text/checkbox/row drag path completely.
|
|
event.preventDefault();
|
|
});
|
|
return element;
|
|
}
|
|
|
|
function renderPlaylist(state) {
|
|
playlistMutationLocked = isOperatorMutationLocked(state);
|
|
playlistStructureMutationLocked = isPlaylistStructureMutationLocked(state);
|
|
playlistProgramEnableMutationLocked =
|
|
isPlaylistProgramEnableMutationLocked(state);
|
|
playlistDeleteSelectedLocked = !canDeleteSelectedPlaylistRows(state);
|
|
if (playlistStructureMutationLocked) {
|
|
deferredPlaylistPlainSelectionRowId = null;
|
|
clearPlaylistDragGesture();
|
|
}
|
|
const existingRows = new Map();
|
|
let currentPlayoutRow = null;
|
|
let currentPlayoutFocusKey = "";
|
|
Array.from(playlistRows.children).forEach(function (element) {
|
|
existingRows.set(element.dataset.rowId, element);
|
|
});
|
|
|
|
state.playlist.forEach(function (row, position) {
|
|
const presentation = playlistPlayoutPresentation(state, row, position);
|
|
let element = existingRows.get(row.rowId);
|
|
if (!element) {
|
|
element = createPlaylistElement(row);
|
|
}
|
|
|
|
const elementAtPosition = playlistRows.children.item(position);
|
|
if (elementAtPosition !== element) {
|
|
playlistRows.insertBefore(element, elementAtPosition);
|
|
}
|
|
|
|
const enabled = element.querySelector("input");
|
|
const rowHeader = element.querySelector(".playlist-row-header");
|
|
rowHeader.textContent = String(position + 1);
|
|
rowHeader.title = String(position + 1);
|
|
rowHeader.setAttribute("aria-label", String(position + 1) + "번 행 이동 핸들");
|
|
rowHeader.dataset.dragEnabled = playlistStructureMutationLocked ? "false" : "true";
|
|
enabled.checked = row.isEnabled;
|
|
enabled.disabled = false;
|
|
enabled.setAttribute("aria-disabled", playlistStructureMutationLocked ? "true" : "false");
|
|
const isCandidate = row.isActive === true;
|
|
const isDragSource = playlistDragGesture?.started === true &&
|
|
playlistDragGesture.rowId === row.rowId;
|
|
const dragTargetPosition = playlistDragGesture?.started === true &&
|
|
playlistDragGesture.targetRowId === row.rowId
|
|
? playlistDragGesture.position
|
|
: null;
|
|
element.className = "playlist-row playlist-data-row" +
|
|
(presentation.isOnAir ? " on-air" : "") +
|
|
(presentation.isCurrent ? " playout-current" : "") +
|
|
(presentation.isSelected || isCandidate ? " selected" : "") +
|
|
(row.isSceneResolved === false ? " scene-unresolved" : "") +
|
|
(isDragSource ? " dragging" : "") +
|
|
(dragTargetPosition === "before" ? " drag-before" : "") +
|
|
(dragTargetPosition === "after" ? " drag-after" : "");
|
|
element.dataset.active = isCandidate ? "true" : "false";
|
|
element.setAttribute(
|
|
"aria-selected",
|
|
presentation.isSelected ? "true" : "false");
|
|
element.setAttribute(
|
|
"aria-current",
|
|
presentation.isCurrent ? "true" : "false");
|
|
const accessibleStates = [];
|
|
if (presentation.isOnAir) accessibleStates.push("현재 PGM");
|
|
if (isCandidate) accessibleStates.push("송출 후보");
|
|
if (presentation.isSelected) accessibleStates.push("삭제 선택됨");
|
|
if (row.isSceneResolved === false) accessibleStates.push("장면 확인 필요");
|
|
element.title = row.isSceneResolved === false
|
|
? "기존 데이터는 정상 로드되었습니다. 이 행은 송출할 때 장면 이름을 다시 확인합니다."
|
|
: "";
|
|
element.setAttribute(
|
|
"aria-label",
|
|
accessibleStates.concat([
|
|
row.marketText,
|
|
row.stockName,
|
|
row.graphicType,
|
|
row.subtype,
|
|
presentation.pageText
|
|
]).filter(Boolean).join(", "));
|
|
element.draggable = false;
|
|
element.setAttribute("aria-disabled", playlistMutationLocked ? "true" : "false");
|
|
[row.marketText, row.stockName, row.graphicType, row.subtype, presentation.pageText]
|
|
.forEach(function (text, cellIndex) {
|
|
const cell = element.children.item(cellIndex + 2);
|
|
cell.textContent = text;
|
|
cell.title = text;
|
|
});
|
|
if (presentation.isCurrent) {
|
|
currentPlayoutRow = element;
|
|
currentPlayoutFocusKey = presentation.focusKey;
|
|
}
|
|
});
|
|
|
|
const retainedRowIds = new Set(state.playlist.map(function (row) { return row.rowId; }));
|
|
Array.from(playlistRows.children).forEach(function (element) {
|
|
if (!retainedRowIds.has(element.dataset.rowId)) element.remove();
|
|
});
|
|
if (playlistDragGesture && !retainedRowIds.has(playlistDragGesture.rowId)) {
|
|
deferredPlaylistPlainSelectionRowId = null;
|
|
clearPlaylistDragGesture();
|
|
}
|
|
presentCurrentPlaylistRow(currentPlayoutRow, currentPlayoutFocusKey);
|
|
|
|
const interactionDisabled = playlistStructureMutationLocked;
|
|
const enabledCount = state.playlist.filter(function (row) { return row.isEnabled; }).length;
|
|
const nextHeadingCount = `총 ${state.playlist.length}개 · 송출 ${enabledCount}개`;
|
|
if (playlistHeadingCount.textContent !== nextHeadingCount) {
|
|
playlistHeadingCount.textContent = nextHeadingCount;
|
|
}
|
|
playlistEnableAll.checked = state.playlist.length === 0 || enabledCount === state.playlist.length;
|
|
playlistEnableAll.indeterminate = enabledCount > 0 && enabledCount < state.playlist.length;
|
|
playlistEnableAll.disabled = playlistProgramEnableMutationLocked;
|
|
playlistMoveUp.disabled = interactionDisabled;
|
|
playlistMoveDown.disabled = interactionDisabled;
|
|
playlistDeleteSelected.disabled = playlistDeleteSelectedLocked;
|
|
playlistDeleteAll.disabled = interactionDisabled;
|
|
}
|
|
|
|
function tabOrderFromDom() {
|
|
return Array.from(marketTabs.querySelectorAll("button[data-tab-id]"))
|
|
.map(function (button) { return button.dataset.tabId; });
|
|
}
|
|
|
|
function clearPendingTabHoverSwap() {
|
|
if (pendingTabHoverSwap && pendingTabHoverSwap.timeoutId !== null) {
|
|
window.clearTimeout(pendingTabHoverSwap.timeoutId);
|
|
}
|
|
pendingTabHoverSwap = null;
|
|
}
|
|
|
|
function acknowledgePendingTabHoverSwap(order) {
|
|
if (!pendingTabHoverSwap) return;
|
|
const orderKey = order.join("|");
|
|
if (orderKey === pendingTabHoverSwap.expectedOrderKey ||
|
|
orderKey !== pendingTabHoverSwap.initialOrderKey) {
|
|
clearPendingTabHoverSwap();
|
|
}
|
|
}
|
|
|
|
function hasCrossedLegacyTabSwapThreshold(
|
|
source,
|
|
target,
|
|
clientX,
|
|
sourceIndex,
|
|
targetIndex
|
|
) {
|
|
if (!source || !target || !Number.isFinite(clientX) ||
|
|
sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return false;
|
|
|
|
const sourceRect = source.getBoundingClientRect();
|
|
const targetRect = target.getBoundingClientRect();
|
|
if (sourceRect.width < targetRect.width) {
|
|
// MainForm compensates for the narrow source width so merely entering a
|
|
// wider target cannot immediately swap it. Its comparisons are strict.
|
|
return sourceIndex < targetIndex
|
|
? clientX > targetRect.left + targetRect.width - sourceRect.width
|
|
: clientX < targetRect.left + sourceRect.width;
|
|
}
|
|
|
|
// An equal-width or wider source swaps as soon as it enters another tab.
|
|
return true;
|
|
}
|
|
|
|
function onMarketTabDragOver(event) {
|
|
const target = event.target.closest("button[data-tab-id]");
|
|
if (!target || !draggedTabId) return;
|
|
event.preventDefault();
|
|
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
|
const targetId = target.dataset.tabId;
|
|
if (targetId === draggedTabId) {
|
|
tabDragLastTargetId = null;
|
|
return;
|
|
}
|
|
if (tabDragLastTargetId === targetId || pendingTabHoverSwap) return;
|
|
|
|
const buttons = Array.from(
|
|
marketTabs.querySelectorAll("button[data-tab-id]"));
|
|
const source = buttons.find(function (button) {
|
|
return button.dataset.tabId === draggedTabId;
|
|
});
|
|
const sourceIndex = buttons.indexOf(source);
|
|
const targetIndex = buttons.indexOf(target);
|
|
if (!hasCrossedLegacyTabSwapThreshold(
|
|
source,
|
|
target,
|
|
event.clientX,
|
|
sourceIndex,
|
|
targetIndex)) return;
|
|
|
|
// Record the crossing only when the busy/pending gates actually accepted
|
|
// the opaque native intent. Repeated dragover events then cannot toggle it.
|
|
if (requestTabHoverSwap(targetId)) tabDragLastTargetId = targetId;
|
|
}
|
|
|
|
function requestTabHoverSwap(targetId) {
|
|
if (uiBusy || !draggedTabId || !targetId || draggedTabId === targetId ||
|
|
pendingTabHoverSwap) return false;
|
|
|
|
const order = tabOrderFromDom();
|
|
const sourceIndex = order.indexOf(draggedTabId);
|
|
const targetIndex = order.indexOf(targetId);
|
|
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return false;
|
|
|
|
const expectedOrder = order.slice();
|
|
expectedOrder[sourceIndex] = targetId;
|
|
expectedOrder[targetIndex] = draggedTabId;
|
|
const pending = {
|
|
initialOrderKey: order.join("|"),
|
|
expectedOrderKey: expectedOrder.join("|"),
|
|
timeoutId: null
|
|
};
|
|
pending.timeoutId = window.setTimeout(function () {
|
|
if (pendingTabHoverSwap === pending) {
|
|
pendingTabHoverSwap = null;
|
|
tabDragLastTargetId = null;
|
|
}
|
|
}, 500);
|
|
pendingTabHoverSwap = pending;
|
|
send("hover-swap-tabs", {
|
|
source: draggedTabId,
|
|
target: targetId,
|
|
expectedSourceIndex: sourceIndex,
|
|
expectedTargetIndex: targetIndex
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function renderTabs(state) {
|
|
if (!Array.isArray(state.tabs)) return;
|
|
const existingTabs = new Map();
|
|
Array.from(marketTabs.children).forEach(function (button) {
|
|
existingTabs.set(button.dataset.tabId, button);
|
|
});
|
|
state.tabs.forEach(function (tab, position) {
|
|
const button = existingTabs.get(tab.id);
|
|
if (!button) return;
|
|
const buttonAtPosition = marketTabs.children.item(position);
|
|
if (buttonAtPosition !== button) marketTabs.insertBefore(button, buttonAtPosition);
|
|
const label = button.querySelector(".workspace-nav-label");
|
|
if (label) label.textContent = tab.label;
|
|
else button.textContent = tab.label;
|
|
button.setAttribute("aria-label", tab.label);
|
|
button.title = tab.label;
|
|
button.classList.toggle("active", tab.isActive === true);
|
|
button.disabled = state.isBusy === true || state.fixedSectionBatch != null;
|
|
button.draggable = state.isBusy !== true && state.fixedSectionBatch == null;
|
|
});
|
|
const activeTab = state.tabs.find(function (tab) { return tab.isActive === true; });
|
|
// Keep the clicked menu highlighted while native tab loading is in flight.
|
|
// The busy snapshot still carries the previous active tab; only reconcile
|
|
// once the authoritative operation has completed.
|
|
if (activeTab && state.isBusy !== true &&
|
|
window.LegacyWorkspaceNavigation?.syncMarket) {
|
|
window.LegacyWorkspaceNavigation.syncMarket(activeTab.id, {
|
|
authoritativeNativeRender: true
|
|
});
|
|
}
|
|
acknowledgePendingTabHoverSwap(
|
|
state.tabs.map(function (tab) { return tab.id; }));
|
|
}
|
|
|
|
function renderFixedCatalog(state) {
|
|
const catalog = state.fixedCatalog;
|
|
const batch = state.fixedSectionBatch;
|
|
if (!catalog || !Array.isArray(catalog.sections)) {
|
|
return;
|
|
}
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
catalog.sections.forEach(function (section) {
|
|
const details = document.createElement("details");
|
|
details.className = "catalog-section";
|
|
details.open = catalogExpandAll.checked;
|
|
const summary = document.createElement("summary");
|
|
summary.dataset.sectionIndex = String(section.index);
|
|
summary.dataset.cardCount = String(section.actions.length);
|
|
summary.textContent = section.name;
|
|
summary.title = batch
|
|
? "현재 수동 입력 단계를 완료하거나 취소하세요."
|
|
: "하위 항목을 더블클릭해 편성표에 추가합니다.";
|
|
summary.setAttribute("aria-disabled", batch ? "true" : "false");
|
|
const children = document.createElement("div");
|
|
children.className = "catalog-actions";
|
|
markVisualCardList(children, "fixed-actions");
|
|
section.actions.forEach(function (action) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "tree-item" + (action.isAvailable ? "" : " unavailable");
|
|
button.dataset.actionId = action.id;
|
|
setVisualCardContent(button, {
|
|
title: action.label,
|
|
meta: action.isAvailable
|
|
? (action.detail === section.name ? "" : action.detail)
|
|
: action.prerequisite,
|
|
kind: "fixed"
|
|
});
|
|
button.title = action.isAvailable ? action.detail : action.prerequisite;
|
|
button.disabled = state.isBusy === true || batch != null;
|
|
children.appendChild(button);
|
|
});
|
|
details.append(summary, children);
|
|
fragment.appendChild(details);
|
|
});
|
|
categoryTree.replaceChildren(fragment);
|
|
}
|
|
|
|
function markIndustryRowSelected(row) {
|
|
categoryTree.querySelectorAll("button[data-industry-index]").forEach(function (candidate) {
|
|
const selected = candidate === row;
|
|
candidate.classList.toggle("selected", selected);
|
|
candidate.setAttribute("aria-selected", selected ? "true" : "false");
|
|
});
|
|
const list = row.closest(".industry-list");
|
|
if (list) list.setAttribute("aria-activedescendant", row.id);
|
|
}
|
|
|
|
function selectIndustryRow(row) {
|
|
markIndustryRowSelected(row);
|
|
const list = row.closest(".industry-list");
|
|
if (list) list.focus({ preventScroll: true });
|
|
send("select-industry", { index: Number(row.dataset.industryIndex) });
|
|
}
|
|
|
|
function activateIndustryRow(row) {
|
|
markIndustryRowSelected(row);
|
|
const list = row.closest(".industry-list");
|
|
if (list) list.focus({ preventScroll: true });
|
|
send("double-click-industry", { index: Number(row.dataset.industryIndex) });
|
|
}
|
|
|
|
function renderIndustry(state) {
|
|
const industry = state.industry;
|
|
if (!industry || !Array.isArray(industry.industries) || !Array.isArray(industry.actions)) {
|
|
return;
|
|
}
|
|
|
|
const structureKey = JSON.stringify({
|
|
market: industry.market,
|
|
rows: industry.industries.map(function (row) {
|
|
return [row.index, row.name];
|
|
}),
|
|
actions: industry.actions.map(function (action) {
|
|
return [action.actionId, action.section, action.label];
|
|
})
|
|
});
|
|
const existingWorkspace = categoryTree.querySelector(".industry-workspace");
|
|
if (existingWorkspace && structureKey === industryRenderStructureKey) {
|
|
const firstInput = existingWorkspace.querySelector('input[data-industry-slot="first"]');
|
|
const secondInput = existingWorkspace.querySelector('input[data-industry-slot="second"]');
|
|
if (firstInput && document.activeElement !== firstInput) {
|
|
firstInput.value = industry.firstName || "";
|
|
}
|
|
if (secondInput && document.activeElement !== secondInput) {
|
|
secondInput.value = industry.secondName || "";
|
|
}
|
|
const list = existingWorkspace.querySelector(".industry-list");
|
|
let selectedRow = null;
|
|
existingWorkspace.querySelectorAll("button[data-industry-index]")
|
|
.forEach(function (row) {
|
|
const selected = Number(row.dataset.industryIndex) === industry.selectedIndex;
|
|
row.classList.toggle("selected", selected);
|
|
row.setAttribute("aria-selected", selected ? "true" : "false");
|
|
if (selected) selectedRow = row;
|
|
});
|
|
if (list && selectedRow) {
|
|
list.setAttribute("aria-activedescendant", selectedRow.id);
|
|
} else if (list) {
|
|
list.removeAttribute("aria-activedescendant");
|
|
}
|
|
return;
|
|
}
|
|
|
|
const previousList = categoryTree.querySelector(".industry-list");
|
|
const previousScrollTop = previousList ? previousList.scrollTop : 0;
|
|
const focusedIndustry = document.activeElement &&
|
|
document.activeElement.closest("button[data-industry-index]");
|
|
const focusedIndustryIndex = focusedIndustry
|
|
? focusedIndustry.dataset.industryIndex : null;
|
|
|
|
const workspace = document.createElement("div");
|
|
workspace.className = "industry-workspace";
|
|
const selector = document.createElement("section");
|
|
selector.className = "industry-selector";
|
|
const slots = document.createElement("div");
|
|
slots.className = "industry-slots";
|
|
const first = document.createElement("input");
|
|
first.id = "industry-comparison-first";
|
|
first.dataset.industrySlot = "first";
|
|
first.maxLength = 128;
|
|
first.autocomplete = "off";
|
|
first.value = industry.firstName || "";
|
|
first.setAttribute("aria-label", "첫 번째 업종");
|
|
const swap = document.createElement("button");
|
|
swap.type = "button";
|
|
swap.dataset.industrySwap = "true";
|
|
swap.textContent = "◀▶";
|
|
const second = document.createElement("input");
|
|
second.id = "industry-comparison-second";
|
|
second.dataset.industrySlot = "second";
|
|
second.maxLength = 128;
|
|
second.autocomplete = "off";
|
|
second.value = industry.secondName || "";
|
|
second.setAttribute("aria-label", "두 번째 업종");
|
|
slots.append(first, swap, second);
|
|
const list = document.createElement("div");
|
|
list.className = "industry-list";
|
|
list.setAttribute("role", "listbox");
|
|
list.tabIndex = 0;
|
|
industry.industries.forEach(function (row) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.id = "industry-row-" + row.index;
|
|
button.dataset.industryIndex = String(row.index);
|
|
button.tabIndex = -1;
|
|
button.setAttribute("role", "option");
|
|
button.textContent = row.name;
|
|
button.className = industry.selectedIndex === row.index ? "selected" : "";
|
|
button.setAttribute("aria-selected", industry.selectedIndex === row.index ? "true" : "false");
|
|
if (industry.selectedIndex === row.index) {
|
|
list.setAttribute("aria-activedescendant", button.id);
|
|
}
|
|
list.appendChild(button);
|
|
});
|
|
const comparisonTitle = document.createElement("strong");
|
|
comparisonTitle.textContent = "업종비교";
|
|
selector.append(list, comparisonTitle, slots);
|
|
|
|
const actionTree = document.createElement("section");
|
|
actionTree.className = "industry-action-tree";
|
|
const group = document.createElement("details");
|
|
group.open = catalogExpandAll.checked;
|
|
const summary = document.createElement("summary");
|
|
summary.textContent = industry.market === "kospi" ? "코스피" : "코스닥";
|
|
summary.dataset.industrySectionIndex = "0";
|
|
summary.dataset.cardCount = String(industry.actions.length);
|
|
const actionCards = document.createElement("div");
|
|
actionCards.className = "catalog-actions";
|
|
markVisualCardList(actionCards, "industry-actions");
|
|
group.append(summary, actionCards);
|
|
industry.actions.forEach(function (action) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "tree-item";
|
|
button.dataset.industryActionId = action.actionId;
|
|
setVisualCardContent(button, {
|
|
title: action.label,
|
|
kind: "industry"
|
|
});
|
|
actionCards.appendChild(button);
|
|
});
|
|
actionTree.appendChild(group);
|
|
workspace.append(selector, actionTree);
|
|
categoryTree.replaceChildren(workspace);
|
|
list.scrollTop = previousScrollTop;
|
|
if (focusedIndustryIndex !== null) {
|
|
const replacement = Array.from(
|
|
list.querySelectorAll("button[data-industry-index]"))
|
|
.find(function (row) {
|
|
return row.dataset.industryIndex === focusedIndustryIndex;
|
|
});
|
|
if (replacement) replacement.focus({ preventScroll: true });
|
|
}
|
|
industryRenderStructureKey = structureKey;
|
|
}
|
|
|
|
function updateOverseasWorkspace(workspace, overseas, isBusy) {
|
|
[
|
|
["fixed", overseas.selectedFixedIndexId],
|
|
["industry", overseas.industry.selectedId],
|
|
["stock", overseas.stock.selectedId]
|
|
].forEach(function (selection) {
|
|
const kind = selection[0];
|
|
const selectedId = selection[1];
|
|
const list = workspace.querySelector('[data-overseas-list="' + kind + '"]');
|
|
if (!list) return;
|
|
list.setAttribute("aria-disabled", isBusy ? "true" : "false");
|
|
let selected = null;
|
|
list.querySelectorAll("button").forEach(function (button) {
|
|
const id = kind === "fixed"
|
|
? button.dataset.overseasTargetId : button.dataset.overseasSelectionId;
|
|
const isSelected = id === selectedId;
|
|
button.classList.toggle("selected", isSelected);
|
|
button.setAttribute("aria-selected", isSelected ? "true" : "false");
|
|
button.disabled = isBusy;
|
|
if (isSelected) selected = button;
|
|
});
|
|
if (selected) list.setAttribute("aria-activedescendant", selected.id);
|
|
else list.removeAttribute("aria-activedescendant");
|
|
});
|
|
|
|
["industry", "stock"].forEach(function (kind) {
|
|
const input = workspace.querySelector("#overseas-" + kind + "-search");
|
|
syncSearchDraft(input, overseas[kind].query);
|
|
if (input) input.readOnly = isBusy;
|
|
const search = workspace.querySelector('button[data-overseas-search="' + kind + '"]');
|
|
if (search) search.disabled = isBusy;
|
|
});
|
|
|
|
const actionStates = new Map(overseas.actions.map(function (action) {
|
|
return [action.actionId, action];
|
|
}));
|
|
workspace.querySelectorAll("button[data-overseas-action-id]").forEach(function (button) {
|
|
const action = actionStates.get(button.dataset.overseasActionId);
|
|
if (!action) return;
|
|
button.disabled = isBusy || !action.isAvailable;
|
|
button.title = action.isAvailable ? "Add to playlist" : action.reason || "Select an item first.";
|
|
});
|
|
}
|
|
|
|
function markOverseasRowSelected(row) {
|
|
const list = row.closest("[data-overseas-list]");
|
|
if (!list) return;
|
|
list.querySelectorAll("button").forEach(function (candidate) {
|
|
const selected = candidate === row;
|
|
candidate.classList.toggle("selected", selected);
|
|
candidate.setAttribute("aria-selected", selected ? "true" : "false");
|
|
});
|
|
list.setAttribute("aria-activedescendant", row.id);
|
|
}
|
|
|
|
function selectOverseasRow(row) {
|
|
markOverseasRowSelected(row);
|
|
const list = row.closest("[data-overseas-list]");
|
|
if (list) list.focus({ preventScroll: true });
|
|
if (row.dataset.overseasTargetId) {
|
|
send("select-overseas-fixed-index", { targetId: row.dataset.overseasTargetId });
|
|
return;
|
|
}
|
|
send(row.dataset.overseasResultKind === "stock"
|
|
? "select-overseas-stock" : "select-overseas-industry", {
|
|
selectionId: row.dataset.overseasSelectionId
|
|
});
|
|
}
|
|
|
|
function moveOverseasListSelection(list, key) {
|
|
if (!list || list.getAttribute("aria-disabled") === "true") return false;
|
|
const rows = Array.from(list.querySelectorAll("button"));
|
|
if (rows.length === 0) return false;
|
|
const selectedIndex = rows.findIndex(function (row) {
|
|
return row.getAttribute("aria-selected") === "true";
|
|
});
|
|
let nextIndex;
|
|
if (key === "Home") nextIndex = 0;
|
|
else if (key === "End") nextIndex = rows.length - 1;
|
|
else if (selectedIndex < 0) nextIndex = 0;
|
|
else if (key === "ArrowUp") nextIndex = Math.max(0, selectedIndex - 1);
|
|
else nextIndex = Math.min(rows.length - 1, selectedIndex + 1);
|
|
if (nextIndex === selectedIndex) return false;
|
|
const nextRow = rows[nextIndex];
|
|
selectOverseasRow(nextRow);
|
|
nextRow.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
return true;
|
|
}
|
|
|
|
function renderOverseas(state) {
|
|
const overseas = state.overseas;
|
|
if (!overseas || !Array.isArray(overseas.fixedIndexTargets) ||
|
|
!overseas.industry || !overseas.stock || !Array.isArray(overseas.actions)) return;
|
|
|
|
const structureKey = JSON.stringify({
|
|
fixed: overseas.fixedIndexTargets.map(function (row) {
|
|
return [row.targetId, row.displayName];
|
|
}),
|
|
industry: (overseas.industry.results || []).map(function (row) {
|
|
return [row.selectionId, row.displayName];
|
|
}),
|
|
industryTruncated: overseas.industry.isTruncated === true,
|
|
stock: (overseas.stock.results || []).map(function (row) {
|
|
return [row.selectionId, row.displayName];
|
|
}),
|
|
stockTruncated: overseas.stock.isTruncated === true,
|
|
actions: overseas.actions.map(function (action) {
|
|
return [action.actionId, action.source, action.periodDays];
|
|
})
|
|
});
|
|
const existingWorkspace = categoryTree.querySelector(".overseas-workspace");
|
|
if (existingWorkspace && structureKey === overseasRenderStructureKey) {
|
|
updateOverseasWorkspace(existingWorkspace, overseas, state.isBusy === true);
|
|
return;
|
|
}
|
|
|
|
function actionButton(action, label) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.dataset.overseasActionId = action.actionId;
|
|
button.textContent = label;
|
|
button.disabled = state.isBusy === true || !action.isAvailable;
|
|
button.title = action.isAvailable ? "Add to playlist" : action.reason || "Select an item first.";
|
|
return button;
|
|
}
|
|
|
|
function periodButtons(source) {
|
|
const buttons = document.createElement("div");
|
|
buttons.className = "overseas-period-actions";
|
|
overseas.actions.filter(function (action) {
|
|
return action.source === source && action.periodDays;
|
|
}).forEach(function (action) {
|
|
buttons.appendChild(actionButton(action, action.periodDays + "일"));
|
|
});
|
|
return buttons;
|
|
}
|
|
|
|
function resultList(kind, model) {
|
|
const list = document.createElement("div");
|
|
list.className = "overseas-results";
|
|
list.dataset.overseasList = kind;
|
|
list.setAttribute("role", "listbox");
|
|
list.tabIndex = 0;
|
|
list.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false");
|
|
(model.results || []).forEach(function (row, index) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.id = "overseas-" + kind + "-row-" + index;
|
|
button.dataset.overseasResultKind = kind;
|
|
button.dataset.overseasSelectionId = row.selectionId;
|
|
button.tabIndex = -1;
|
|
button.textContent = row.displayName;
|
|
const selected = model.selectedId === row.selectionId;
|
|
button.className = selected ? "selected" : "";
|
|
button.setAttribute("aria-selected", selected ? "true" : "false");
|
|
button.disabled = state.isBusy === true;
|
|
if (selected) list.setAttribute("aria-activedescendant", button.id);
|
|
list.appendChild(button);
|
|
});
|
|
return list;
|
|
}
|
|
|
|
function searchGroup(kind, title, searchLabel, model, includePeriods) {
|
|
const group = document.createElement("section");
|
|
group.className = "overseas-group overseas-search-group";
|
|
const heading = document.createElement("h3");
|
|
heading.textContent = title;
|
|
if (model.isTruncated === true) {
|
|
const truncation = document.createElement("span");
|
|
truncation.className = "overseas-truncation";
|
|
const isolatedCount = Number.isSafeInteger(model.isolatedInvalidRowCount)
|
|
? model.isolatedInvalidRowCount : 0;
|
|
truncation.textContent = isolatedCount > 0
|
|
? "검색 결과 일부가 안전 상한 또는 불완전 데이터 때문에 제외되었습니다. " +
|
|
"(표시명 없음 " + isolatedCount + "건)"
|
|
: "검색 결과가 안전 상한을 초과해 일부만 표시됩니다.";
|
|
heading.appendChild(truncation);
|
|
}
|
|
const searchLine = document.createElement("div");
|
|
searchLine.className = "overseas-search-line";
|
|
const input = document.createElement("input");
|
|
input.id = "overseas-" + kind + "-search";
|
|
input.value = model.query || "";
|
|
input.maxLength = 64;
|
|
input.setAttribute("aria-label", searchLabel);
|
|
input.readOnly = state.isBusy === true;
|
|
const search = document.createElement("button");
|
|
search.type = "button";
|
|
search.dataset.overseasSearch = kind;
|
|
search.textContent = searchLabel;
|
|
search.disabled = state.isBusy === true;
|
|
searchLine.append(input, search);
|
|
const resultArea = document.createElement("div");
|
|
resultArea.className = "overseas-result-area";
|
|
resultArea.appendChild(resultList(kind, model));
|
|
const actionColumn = periodButtons(kind);
|
|
const current = overseas.actions.find(function (action) {
|
|
return action.source === kind && !action.periodDays;
|
|
});
|
|
if (current) actionColumn.prepend(actionButton(current, "1열판"));
|
|
if (includePeriods || current) resultArea.appendChild(actionColumn);
|
|
group.append(heading, searchLine, resultArea);
|
|
return group;
|
|
}
|
|
|
|
const workspace = document.createElement("div");
|
|
workspace.className = "overseas-workspace";
|
|
const fixedGroup = document.createElement("section");
|
|
fixedGroup.className = "overseas-group overseas-fixed-group";
|
|
const fixedHeading = document.createElement("h3");
|
|
fixedHeading.textContent = "해외지수";
|
|
const fixedBody = document.createElement("div");
|
|
fixedBody.className = "overseas-fixed-body";
|
|
const fixedList = document.createElement("div");
|
|
fixedList.className = "overseas-fixed-list";
|
|
fixedList.dataset.overseasList = "fixed";
|
|
fixedList.setAttribute("role", "listbox");
|
|
fixedList.tabIndex = 0;
|
|
fixedList.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false");
|
|
overseas.fixedIndexTargets.forEach(function (target, index) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.id = "overseas-fixed-row-" + index;
|
|
button.dataset.overseasTargetId = target.targetId;
|
|
button.tabIndex = -1;
|
|
button.textContent = target.displayName;
|
|
const selected = overseas.selectedFixedIndexId === target.targetId;
|
|
button.className = selected ? "selected" : "";
|
|
button.setAttribute("aria-selected", selected ? "true" : "false");
|
|
button.disabled = state.isBusy === true;
|
|
if (selected) fixedList.setAttribute("aria-activedescendant", button.id);
|
|
fixedList.appendChild(button);
|
|
});
|
|
fixedBody.append(fixedList, periodButtons("fixedIndex"));
|
|
fixedGroup.append(fixedHeading, fixedBody);
|
|
workspace.append(
|
|
fixedGroup,
|
|
searchGroup("industry", "해외 업종지수", "업종 검색", overseas.industry, false),
|
|
searchGroup("stock", "해외 종목", "종목 검색", overseas.stock, true));
|
|
categoryTree.replaceChildren(workspace);
|
|
overseasRenderStructureKey = structureKey;
|
|
}
|
|
|
|
function updateComparisonWorkspace(workspace, comparison) {
|
|
["domestic", "world"].forEach(function (kind) {
|
|
const input = workspace.querySelector("#comparison-" + kind + "-search");
|
|
syncSearchDraft(input, comparison[kind + "Search"].query);
|
|
});
|
|
}
|
|
|
|
function comparisonUnavailableText(reason) {
|
|
switch (reason) {
|
|
case "pair-incomplete-or-invalid":
|
|
return "비교할 종목 두 개를 선택하세요.";
|
|
case "deferred-unsafe-comparison-row":
|
|
return "현재 데이터로는 안전하게 비교할 수 없습니다.";
|
|
case "comparison-graphs-require-two-krx-stocks":
|
|
return "국내 종목 두 개를 선택해야 합니다.";
|
|
case "two-futures-targets-are-unsupported":
|
|
return "선물 두 개는 서로 비교할 수 없습니다.";
|
|
case "futures-requires-a-market-target-counterpart":
|
|
return "선물은 시장 지수와 비교해야 합니다.";
|
|
case "expected-mode-supports-only-krx-stocks-and-four-domestic-indices":
|
|
return "예상가는 국내 종목과 지원 지수에서만 사용할 수 있습니다.";
|
|
case "mixed-market-and-world-is-unsupported":
|
|
return "시장 지수와 해외 종목은 함께 비교할 수 없습니다.";
|
|
default:
|
|
if (!reason) return "";
|
|
return /^[a-z0-9-]+$/.test(reason)
|
|
? "현재 선택으로는 사용할 수 없습니다."
|
|
: reason;
|
|
}
|
|
}
|
|
|
|
function renderComparison(state) {
|
|
const comparison = state.comparison;
|
|
if (!comparison || !comparison.domesticSearch || !comparison.worldSearch) {
|
|
clearComparisonTargetPendingSelection(false);
|
|
return;
|
|
}
|
|
|
|
const structureKey = JSON.stringify({
|
|
domestic: {
|
|
results: comparison.domesticSearch.results,
|
|
selectedResultId: comparison.domesticSearch.selectedResultId,
|
|
isTruncated: comparison.domesticSearch.isTruncated,
|
|
isolatedInvalidRowCount: comparison.domesticSearch.isolatedInvalidRowCount,
|
|
deferredUnsafeRowCount: comparison.domesticSearch.deferredUnsafeRowCount
|
|
},
|
|
world: {
|
|
results: comparison.worldSearch.results,
|
|
selectedResultId: comparison.worldSearch.selectedResultId,
|
|
isTruncated: comparison.worldSearch.isTruncated,
|
|
isolatedInvalidRowCount: comparison.worldSearch.isolatedInvalidRowCount,
|
|
deferredUnsafeRowCount: comparison.worldSearch.deferredUnsafeRowCount
|
|
},
|
|
fixedTargets: comparison.fixedTargets,
|
|
selectedFixedTargetId: comparison.selectedFixedTargetId,
|
|
firstTarget: comparison.firstTarget,
|
|
secondTarget: comparison.secondTarget,
|
|
savedPairs: comparison.savedPairs,
|
|
actions: comparison.actions,
|
|
canImportLegacyPairs: comparison.canImportLegacyPairs,
|
|
lastImport: comparison.lastImport,
|
|
statusMessage: comparison.statusMessage,
|
|
statusKind: comparison.statusKind
|
|
});
|
|
if (comparisonTargetPendingSelection) {
|
|
if (comparisonTargetPendingSelection.sent && state.isBusy !== true) {
|
|
const selectedId = comparisonTargetPendingSelection.kind === "fixed"
|
|
? comparison.selectedFixedTargetId
|
|
: comparison[comparisonTargetPendingSelection.kind + "Search"].selectedResultId;
|
|
clearComparisonTargetPendingSelection(
|
|
selectedId !== comparisonTargetPendingSelection.key);
|
|
} else if (!comparisonTargetPendingSelection.sent &&
|
|
comparisonRenderStructureKey &&
|
|
structureKey !== comparisonRenderStructureKey) {
|
|
clearComparisonTargetPendingSelection(false);
|
|
} else {
|
|
applyPendingRowSelection(comparisonTargetPendingSelection, false);
|
|
}
|
|
}
|
|
const existingWorkspace = categoryTree.querySelector(".comparison-workspace");
|
|
if (existingWorkspace && structureKey === comparisonRenderStructureKey) {
|
|
updateComparisonWorkspace(existingWorkspace, comparison);
|
|
return;
|
|
}
|
|
|
|
const workspace = document.createElement("div");
|
|
workspace.className = "comparison-workspace";
|
|
|
|
const searches = document.createElement("section");
|
|
searches.className = "comparison-searches";
|
|
[
|
|
["domestic", "국내·NXT 종목", comparison.domesticSearch],
|
|
["world", "미국·대만 종목", comparison.worldSearch]
|
|
].forEach(function (definition) {
|
|
const kind = definition[0];
|
|
const panel = document.createElement("div");
|
|
panel.className = "comparison-search-panel";
|
|
const line = document.createElement("div");
|
|
line.className = "comparison-search-line";
|
|
const input = document.createElement("input");
|
|
input.id = "comparison-" + kind + "-search";
|
|
input.maxLength = 64;
|
|
input.value = definition[2].query || "";
|
|
input.placeholder = definition[1];
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.dataset.comparisonSearch = kind;
|
|
button.textContent = "검색";
|
|
line.append(input, button);
|
|
const results = document.createElement("div");
|
|
results.className = "comparison-results";
|
|
results.setAttribute("aria-label", definition[1] + " 검색 결과");
|
|
let selectedResult = null;
|
|
definition[2].results.forEach(function (row) {
|
|
const result = document.createElement("button");
|
|
result.type = "button";
|
|
result.id = "comparison-" + kind + "-result-" + row.selectionId;
|
|
result.dataset.comparisonChoice = kind;
|
|
result.dataset.comparisonSelectionId = row.selectionId;
|
|
const isSelected = definition[2].selectedResultId === row.selectionId;
|
|
result.className = isSelected
|
|
? "selected" : "";
|
|
result.setAttribute(
|
|
"aria-selected",
|
|
isSelected ? "true" : "false");
|
|
result.disabled = row.canSelect === false;
|
|
if (row.unavailableReason) {
|
|
result.title = comparisonUnavailableText(row.unavailableReason);
|
|
}
|
|
result.textContent = row.displayName + " · " + row.metadata;
|
|
if (isSelected) selectedResult = result;
|
|
results.appendChild(result);
|
|
});
|
|
configureRovingList(
|
|
results,
|
|
Array.from(results.querySelectorAll("button[data-comparison-selection-id]")),
|
|
selectedResult);
|
|
panel.append(line, results);
|
|
if (definition[2].isTruncated === true ||
|
|
Number(definition[2].deferredUnsafeRowCount) > 0) {
|
|
const isolated = Number(definition[2].isolatedInvalidRowCount) || 0;
|
|
const deferred = Number(definition[2].deferredUnsafeRowCount) || 0;
|
|
const messages = [];
|
|
if (isolated > 0) {
|
|
messages.push("식별할 수 없는 기존 데이터 " + String(isolated) +
|
|
"행은 제외하고 나머지 결과를 표시합니다.");
|
|
} else if (definition[2].isTruncated === true) {
|
|
messages.push("안전한 표시 상한을 초과해 일부 결과만 표시합니다.");
|
|
}
|
|
if (deferred > 0) {
|
|
messages.push("대상 구분이 모호한 " + String(deferred) +
|
|
"행은 목록에 표시하되 선택과 송출을 차단합니다.");
|
|
}
|
|
const warning = document.createElement("div");
|
|
warning.className = "comparison-search-warning";
|
|
warning.setAttribute("role", "status");
|
|
warning.textContent = messages.join(" ");
|
|
panel.appendChild(warning);
|
|
}
|
|
searches.appendChild(panel);
|
|
});
|
|
|
|
const fixed = document.createElement("section");
|
|
fixed.className = "comparison-fixed";
|
|
const fixedTitle = document.createElement("strong");
|
|
fixedTitle.textContent = "지수·환율 고정 대상";
|
|
const fixedList = document.createElement("div");
|
|
fixedList.className = "comparison-fixed-list";
|
|
fixedList.setAttribute("aria-label", "지수·환율 고정 대상");
|
|
let selectedFixedTarget = null;
|
|
comparison.fixedTargets.forEach(function (row) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.id = "comparison-fixed-result-" + row.targetId;
|
|
button.dataset.comparisonTargetId = row.targetId;
|
|
const isSelected = comparison.selectedFixedTargetId === row.targetId;
|
|
button.className = isSelected
|
|
? "selected" : "";
|
|
button.setAttribute(
|
|
"aria-selected",
|
|
isSelected ? "true" : "false");
|
|
button.textContent = row.displayName;
|
|
if (isSelected) selectedFixedTarget = button;
|
|
fixedList.appendChild(button);
|
|
});
|
|
configureRovingList(
|
|
fixedList,
|
|
Array.from(fixedList.querySelectorAll("button[data-comparison-target-id]")),
|
|
selectedFixedTarget);
|
|
fixed.append(fixedTitle, fixedList);
|
|
|
|
const pairPanel = document.createElement("section");
|
|
pairPanel.className = "comparison-pair-panel";
|
|
const draft = document.createElement("div");
|
|
draft.className = "comparison-draft";
|
|
const first = document.createElement("input");
|
|
first.readOnly = true;
|
|
first.value = comparison.firstTarget ? comparison.firstTarget.displayName : "";
|
|
first.setAttribute("aria-label", "첫 번째 비교 대상");
|
|
const second = document.createElement("input");
|
|
second.readOnly = true;
|
|
second.value = comparison.secondTarget ? comparison.secondTarget.displayName : "";
|
|
second.setAttribute("aria-label", "두 번째 비교 대상");
|
|
const swap = document.createElement("button");
|
|
swap.type = "button";
|
|
swap.dataset.comparisonSwap = "true";
|
|
swap.textContent = "◀▶";
|
|
const add = document.createElement("button");
|
|
add.type = "button";
|
|
add.dataset.comparisonAdd = "true";
|
|
add.textContent = "추가";
|
|
add.disabled = !comparison.firstTarget || !comparison.secondTarget;
|
|
draft.append(first, swap, second, add);
|
|
|
|
const saved = document.createElement("div");
|
|
saved.className = "comparison-saved";
|
|
saved.setAttribute("aria-label", "저장된 종목 비교");
|
|
let selectedSavedPair = null;
|
|
comparison.savedPairs.forEach(function (row) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.id = "comparison-saved-result-" + row.rowId;
|
|
button.dataset.comparisonPairId = row.rowId;
|
|
button.textContent = row.rowNumber + ". " + row.displayLabel;
|
|
button.className = row.isSelected ? "selected" : "";
|
|
button.setAttribute("aria-selected", row.isSelected ? "true" : "false");
|
|
if (row.isSelected) selectedSavedPair = button;
|
|
saved.appendChild(button);
|
|
});
|
|
configureRovingList(
|
|
saved,
|
|
Array.from(saved.querySelectorAll("button[data-comparison-pair-id]")),
|
|
selectedSavedPair);
|
|
const savedTools = document.createElement("div");
|
|
savedTools.className = "comparison-saved-tools";
|
|
const importLegacy = document.createElement("button");
|
|
importLegacy.type = "button";
|
|
importLegacy.dataset.comparisonImport = "true";
|
|
importLegacy.textContent = "원본 비교쌍 가져오기";
|
|
importLegacy.title =
|
|
"현재 저장 순서는 유지하고 원본 프로그램에만 있는 비교쌍을 뒤에 추가합니다.";
|
|
importLegacy.disabled = comparison.canImportLegacyPairs !== true ||
|
|
comparison.isBusy === true;
|
|
savedTools.appendChild(importLegacy);
|
|
[["up", "UP"], ["down", "DOWN"]].forEach(function (item) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.dataset.comparisonMove = item[0];
|
|
button.textContent = item[1];
|
|
savedTools.appendChild(button);
|
|
});
|
|
const deleteSelected = document.createElement("button");
|
|
deleteSelected.type = "button";
|
|
deleteSelected.dataset.comparisonDelete = "selected";
|
|
deleteSelected.textContent = "DEL";
|
|
const deleteAll = document.createElement("button");
|
|
deleteAll.type = "button";
|
|
deleteAll.dataset.comparisonDelete = "all";
|
|
deleteAll.textContent = "DEL ALL";
|
|
savedTools.append(deleteSelected, deleteAll);
|
|
const importStatus = document.createElement("div");
|
|
importStatus.className = "comparison-import-status";
|
|
importStatus.setAttribute("role", "status");
|
|
importStatus.textContent = comparison.statusMessage || "";
|
|
if (comparison.lastImport) {
|
|
importStatus.title =
|
|
"원본 " + String(comparison.lastImport.sourceRowCount) + "개 · 추가 " +
|
|
String(comparison.lastImport.addedPairCount) + "개 · 중복 " +
|
|
String(comparison.lastImport.skippedDuplicateCount) + "개 · 현재 " +
|
|
String(comparison.lastImport.totalPairCount) + "개";
|
|
}
|
|
pairPanel.append(draft, saved, savedTools, importStatus);
|
|
|
|
const actions = document.createElement("section");
|
|
actions.className = "comparison-actions";
|
|
const comparisonSections = new Map();
|
|
comparison.actions.forEach(function (action) {
|
|
let group = comparisonSections.get(action.section);
|
|
let actionCards;
|
|
if (!group) {
|
|
group = document.createElement("details");
|
|
group.open = catalogExpandAll.checked;
|
|
const summary = document.createElement("summary");
|
|
summary.textContent = action.section;
|
|
summary.dataset.comparisonSectionIndex = String(comparisonSections.size);
|
|
summary.dataset.cardCount = String(comparison.actions.filter(function (candidate) {
|
|
return candidate.section === action.section;
|
|
}).length);
|
|
summary.title = "더블클릭하면 직계 자식 전체를 원본 순서로 추가합니다.";
|
|
actionCards = document.createElement("div");
|
|
actionCards.className = "catalog-actions";
|
|
markVisualCardList(actionCards, "comparison-actions");
|
|
group.append(summary, actionCards);
|
|
comparisonSections.set(action.section, group);
|
|
actions.appendChild(group);
|
|
} else {
|
|
actionCards = group.querySelector(":scope > .catalog-actions");
|
|
}
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "tree-item" + (action.isAvailable ? "" : " unavailable");
|
|
button.dataset.comparisonActionId = action.actionId;
|
|
setVisualCardContent(button, {
|
|
title: action.label,
|
|
kind: "comparison"
|
|
});
|
|
button.disabled = !action.isAvailable;
|
|
button.title = action.isAvailable
|
|
? "더블클릭하여 플레이리스트에 추가"
|
|
: comparisonUnavailableText(action.unavailableReason);
|
|
actionCards.appendChild(button);
|
|
});
|
|
|
|
workspace.append(searches, fixed, pairPanel, actions);
|
|
categoryTree.replaceChildren(workspace);
|
|
comparisonRenderStructureKey = structureKey;
|
|
updateComparisonWorkspace(workspace, comparison);
|
|
}
|
|
|
|
function themeActionIsClickable(action) {
|
|
return action.isAvailable === true ||
|
|
(action.actionId === "five-row-expected" &&
|
|
action.unavailableReason === "expected-theme-is-krx-only");
|
|
}
|
|
|
|
function updateThemeWorkspace(workspace, theme) {
|
|
const input = workspace.querySelector("#theme-search");
|
|
syncSearchDraft(input, theme.query);
|
|
|
|
const searchTruncation = workspace.querySelector(".theme-search-truncation");
|
|
if (searchTruncation) {
|
|
searchTruncation.hidden = theme.searchIsTruncated !== true;
|
|
searchTruncation.textContent = theme.searchIsTruncated === true
|
|
? "검색 결과가 최대 5,000건으로 제한되었습니다. 검색어를 좁혀 주세요."
|
|
: "";
|
|
}
|
|
|
|
const results = workspace.querySelector(".theme-results");
|
|
let selectedResult = null;
|
|
results.querySelectorAll("button[data-theme-result-id]").forEach(function (button) {
|
|
const selected = button.dataset.themeResultId === theme.selectedResultId;
|
|
button.classList.toggle("selected", selected);
|
|
button.setAttribute("aria-selected", selected ? "true" : "false");
|
|
if (selected) selectedResult = button;
|
|
});
|
|
configureRovingList(
|
|
results,
|
|
Array.from(results.querySelectorAll("button[data-theme-result-id]")),
|
|
selectedResult);
|
|
if (selectedResult) results.setAttribute("aria-activedescendant", selectedResult.id);
|
|
else results.removeAttribute("aria-activedescendant");
|
|
|
|
workspace.querySelectorAll("input[data-theme-sort]").forEach(function (radio) {
|
|
radio.checked = radio.value === theme.sortId;
|
|
});
|
|
|
|
const preview = workspace.querySelector(".theme-preview-list");
|
|
const previewFragment = document.createDocumentFragment();
|
|
theme.previewItems.forEach(function (row) {
|
|
const item = document.createElement("li");
|
|
item.textContent = row.itemName;
|
|
previewFragment.appendChild(item);
|
|
});
|
|
preview.replaceChildren(previewFragment);
|
|
|
|
const actionStates = new Map(theme.actions.map(function (action) {
|
|
return [action.actionId, action];
|
|
}));
|
|
workspace.querySelectorAll("button[data-theme-action-id]").forEach(function (button) {
|
|
const action = actionStates.get(button.dataset.themeActionId);
|
|
if (!action) return;
|
|
const warningHandledByNative = action.actionId === "five-row-expected" &&
|
|
action.unavailableReason === "expected-theme-is-krx-only";
|
|
button.disabled = !themeActionIsClickable(action);
|
|
button.title = warningHandledByNative
|
|
? "NXT는 데이터가 없습니다."
|
|
: action.unavailableReason || "클릭하여 플레이리스트에 추가";
|
|
});
|
|
|
|
const editTheme = workspace.querySelector("#uc4-theme-edit");
|
|
const deleteTheme = workspace.querySelector("#uc4-theme-delete");
|
|
editTheme.disabled = !theme.selectedTheme;
|
|
deleteTheme.disabled = !theme.selectedTheme;
|
|
}
|
|
|
|
function renderTheme(state) {
|
|
const theme = state.theme;
|
|
if (!theme || !Array.isArray(theme.searchResults) || !Array.isArray(theme.actions)) {
|
|
clearThemeResultPendingSelection(false);
|
|
return;
|
|
}
|
|
|
|
const structureKey = JSON.stringify({
|
|
query: theme.query || "",
|
|
results: theme.searchResults.map(function (row) {
|
|
return [row.resultId, row.displayTitle];
|
|
}),
|
|
sorts: theme.sorts.map(function (row) {
|
|
return [row.id, row.label];
|
|
}),
|
|
actions: theme.actions.map(function (action) {
|
|
return [action.actionId, action.label];
|
|
})
|
|
});
|
|
const existingWorkspace = categoryTree.querySelector(".theme-workspace");
|
|
if (themeResultPendingSelection) {
|
|
if (themeResultPendingSelection.sent && state.isBusy !== true) {
|
|
clearThemeResultPendingSelection(
|
|
theme.selectedResultId !== themeResultPendingSelection.key);
|
|
} else if (themeRenderStructureKey && structureKey !== themeRenderStructureKey) {
|
|
clearThemeResultPendingSelection(false);
|
|
}
|
|
}
|
|
if (existingWorkspace && structureKey === themeRenderStructureKey) {
|
|
updateThemeWorkspace(existingWorkspace, theme);
|
|
if (themeResultPendingSelection) {
|
|
applyPendingRowSelection(themeResultPendingSelection, false);
|
|
applyPendingControlLocks(themeResultPendingSelection.lockedControls);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const workspace = document.createElement("div");
|
|
workspace.className = "theme-workspace";
|
|
const search = document.createElement("div");
|
|
search.className = "theme-search-line";
|
|
const input = document.createElement("input");
|
|
input.id = "theme-search";
|
|
input.value = theme.query || "";
|
|
input.maxLength = 64;
|
|
input.placeholder = "테마명";
|
|
input.setAttribute("aria-label", "테마명");
|
|
const searchButton = document.createElement("button");
|
|
searchButton.type = "button";
|
|
searchButton.dataset.themeSearch = "true";
|
|
searchButton.textContent = "테마검색";
|
|
const addTheme = document.createElement("button");
|
|
addTheme.type = "button";
|
|
addTheme.id = "uc4-theme-add";
|
|
addTheme.dataset.uc4ThemeCatalog = "create";
|
|
addTheme.textContent = "테마추가";
|
|
const editTheme = document.createElement("button");
|
|
editTheme.type = "button";
|
|
editTheme.id = "uc4-theme-edit";
|
|
editTheme.dataset.uc4ThemeCatalog = "edit";
|
|
editTheme.textContent = "테마편집";
|
|
editTheme.disabled = !theme.selectedTheme;
|
|
const deleteTheme = document.createElement("button");
|
|
deleteTheme.type = "button";
|
|
deleteTheme.id = "uc4-theme-delete";
|
|
deleteTheme.dataset.uc4ThemeCatalog = "delete";
|
|
deleteTheme.textContent = "테마삭제";
|
|
deleteTheme.disabled = !theme.selectedTheme;
|
|
search.append(input, searchButton, addTheme, editTheme, deleteTheme);
|
|
|
|
const resultsPanel = document.createElement("section");
|
|
resultsPanel.className = "theme-results-panel";
|
|
const resultsTitle = document.createElement("strong");
|
|
resultsTitle.className = "theme-column-title";
|
|
resultsTitle.textContent = "테마명";
|
|
const searchTruncation = document.createElement("span");
|
|
searchTruncation.className = "theme-search-truncation";
|
|
searchTruncation.hidden = theme.searchIsTruncated !== true;
|
|
searchTruncation.textContent = theme.searchIsTruncated === true
|
|
? "검색 결과가 최대 5,000건으로 제한되었습니다. 검색어를 좁혀 주세요."
|
|
: "";
|
|
resultsTitle.appendChild(searchTruncation);
|
|
const results = document.createElement("div");
|
|
results.className = "theme-results";
|
|
results.setAttribute("role", "listbox");
|
|
results.setAttribute("aria-label", "테마명");
|
|
theme.searchResults.forEach(function (row, index) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.id = "theme-result-row-" + index;
|
|
button.dataset.themeResultId = row.resultId;
|
|
button.textContent = row.displayTitle;
|
|
button.className = theme.selectedResultId === row.resultId ? "selected" : "";
|
|
button.setAttribute("aria-selected",
|
|
theme.selectedResultId === row.resultId ? "true" : "false");
|
|
results.appendChild(button);
|
|
});
|
|
resultsPanel.append(resultsTitle, results);
|
|
|
|
const sortControls = document.createElement("fieldset");
|
|
sortControls.className = "theme-sort-controls";
|
|
const sortLegend = document.createElement("legend");
|
|
sortLegend.textContent = "정렬";
|
|
sortControls.appendChild(sortLegend);
|
|
theme.sorts.forEach(function (row) {
|
|
const label = document.createElement("label");
|
|
const radio = document.createElement("input");
|
|
radio.type = "radio";
|
|
radio.name = "theme-sort";
|
|
radio.value = row.id;
|
|
radio.dataset.themeSort = "true";
|
|
radio.checked = row.id === theme.sortId;
|
|
label.append(radio, document.createTextNode(row.label));
|
|
sortControls.appendChild(label);
|
|
});
|
|
|
|
const previewPanel = document.createElement("section");
|
|
previewPanel.className = "theme-preview-panel";
|
|
const previewTitle = document.createElement("strong");
|
|
previewTitle.className = "theme-column-title";
|
|
previewTitle.textContent = "종목명";
|
|
const preview = document.createElement("ol");
|
|
preview.className = "theme-preview-list";
|
|
theme.previewItems.forEach(function (row) {
|
|
const item = document.createElement("li");
|
|
item.textContent = row.itemName;
|
|
preview.appendChild(item);
|
|
});
|
|
previewPanel.append(previewTitle, preview);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "theme-actions";
|
|
markVisualCardList(actions, "theme-actions");
|
|
const originalActionOrder = new Map([
|
|
["six-row-current", 0],
|
|
["twelve-row-current", 1],
|
|
["five-row-current", 2],
|
|
["five-row-expected", 3]
|
|
]);
|
|
const originalActionLabels = new Map([
|
|
["six-row-current", "6종목 현재가"],
|
|
["twelve-row-current", "12종목 현재가"],
|
|
["five-row-current", "현재가"],
|
|
["five-row-expected", "예상체결가"]
|
|
]);
|
|
theme.actions.slice().sort(function (left, right) {
|
|
return (originalActionOrder.get(left.actionId) ?? Number.MAX_SAFE_INTEGER) -
|
|
(originalActionOrder.get(right.actionId) ?? Number.MAX_SAFE_INTEGER);
|
|
}).forEach(function (action) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.dataset.themeActionId = action.actionId;
|
|
setVisualCardContent(button, {
|
|
title: originalActionLabels.get(action.actionId) || action.label,
|
|
kind: "theme"
|
|
});
|
|
actions.appendChild(button);
|
|
});
|
|
// UC4 is a vertical native panel: search, theme rows, sort radios,
|
|
// action buttons, then the selected theme's item preview.
|
|
workspace.append(search, resultsPanel, sortControls, actions, previewPanel);
|
|
categoryTree.replaceChildren(workspace);
|
|
themeRenderStructureKey = structureKey;
|
|
updateThemeWorkspace(workspace, theme);
|
|
}
|
|
|
|
function updateExpertWorkspace(workspace, expert) {
|
|
const input = workspace.querySelector("#expert-search");
|
|
syncSearchDraft(input, expert.search.query);
|
|
}
|
|
|
|
function expertRecommendationText(row) {
|
|
const buyAmount = row.buyAmount == null ? "" : String(row.buyAmount);
|
|
return row.stockName + " · " + buyAmount;
|
|
}
|
|
|
|
function renderExpert(state) {
|
|
const expert = state.expert;
|
|
if (!expert || !expert.search || !expert.preview) return;
|
|
const structureKey = JSON.stringify({
|
|
results: expert.search.results,
|
|
isTruncated: expert.search.isTruncated,
|
|
selectedExpert: expert.selectedExpert,
|
|
preview: expert.preview,
|
|
actions: expert.actions
|
|
});
|
|
const existingWorkspace = categoryTree.querySelector(".expert-workspace");
|
|
if (existingWorkspace && structureKey === expertRenderStructureKey) {
|
|
updateExpertWorkspace(existingWorkspace, expert);
|
|
return;
|
|
}
|
|
const workspace = document.createElement("div");
|
|
workspace.className = "expert-workspace";
|
|
const search = document.createElement("div");
|
|
search.className = "expert-search-line";
|
|
const input = document.createElement("input");
|
|
input.id = "expert-search";
|
|
input.maxLength = 64;
|
|
input.value = expert.search.query || "";
|
|
input.placeholder = "전문가명";
|
|
const searchButton = document.createElement("button");
|
|
searchButton.type = "button";
|
|
searchButton.dataset.expertSearch = "true";
|
|
searchButton.textContent = "조회";
|
|
const addExpert = document.createElement("button");
|
|
addExpert.type = "button";
|
|
addExpert.id = "uc6-expert-add";
|
|
addExpert.dataset.uc6ExpertCatalog = "create";
|
|
addExpert.textContent = "전문가 추가";
|
|
const deleteExpert = document.createElement("button");
|
|
deleteExpert.type = "button";
|
|
deleteExpert.id = "uc6-expert-delete";
|
|
deleteExpert.dataset.uc6ExpertCatalog = "delete";
|
|
deleteExpert.textContent = "전문가 삭제";
|
|
deleteExpert.disabled = !expert.selectedExpert;
|
|
search.append(input, searchButton, addExpert, deleteExpert);
|
|
|
|
const body = document.createElement("div");
|
|
body.className = "expert-body";
|
|
const results = document.createElement("div");
|
|
results.className = "expert-results";
|
|
results.setAttribute("aria-label", "전문가 검색 결과");
|
|
let selectedExpertResult = null;
|
|
expert.search.results.forEach(function (row) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.id = "expert-result-" + row.selectionId;
|
|
button.dataset.expertSelectionId = row.selectionId;
|
|
button.textContent = row.expertName;
|
|
const isSelected = !!expert.selectedExpert &&
|
|
expert.selectedExpert.selectionId === row.selectionId;
|
|
button.className = isSelected ? "selected" : "";
|
|
button.setAttribute("aria-selected", isSelected ? "true" : "false");
|
|
if (isSelected) selectedExpertResult = button;
|
|
results.appendChild(button);
|
|
});
|
|
configureRovingList(
|
|
results,
|
|
Array.from(results.querySelectorAll("button[data-expert-selection-id]")),
|
|
selectedExpertResult);
|
|
const preview = document.createElement("div");
|
|
preview.id = "expert-preview";
|
|
preview.className = "expert-preview";
|
|
preview.tabIndex = 0;
|
|
const heading = document.createElement("strong");
|
|
heading.textContent = expert.selectedExpert
|
|
? expert.selectedExpert.expertName + " 추천 종목"
|
|
: "전문가를 선택하세요.";
|
|
const list = document.createElement("ol");
|
|
expert.preview.items.forEach(function (row) {
|
|
const item = document.createElement("li");
|
|
item.textContent = expertRecommendationText(row);
|
|
if (row.buyAmount == null) {
|
|
item.setAttribute("aria-label", row.stockName + " · 매수가 미입력");
|
|
}
|
|
list.appendChild(item);
|
|
});
|
|
preview.append(heading, list);
|
|
body.append(results, preview);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "expert-actions";
|
|
markVisualCardList(actions, "expert-actions");
|
|
expert.actions.forEach(function (action) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.dataset.expertActionId = action.actionId;
|
|
setVisualCardContent(button, {
|
|
title: action.label,
|
|
kind: "expert"
|
|
});
|
|
button.disabled = !action.isAvailable;
|
|
actions.appendChild(button);
|
|
});
|
|
const editRecommendations = document.createElement("button");
|
|
editRecommendations.type = "button";
|
|
editRecommendations.id = "uc6-expert-edit";
|
|
editRecommendations.dataset.uc6ExpertCatalog = "edit";
|
|
editRecommendations.textContent = "추천행 편집·저장·삭제";
|
|
editRecommendations.disabled = !expert.selectedExpert;
|
|
actions.appendChild(editRecommendations);
|
|
workspace.append(search, body, actions);
|
|
categoryTree.replaceChildren(workspace);
|
|
expertRenderStructureKey = structureKey;
|
|
updateExpertWorkspace(workspace, expert);
|
|
}
|
|
|
|
function updateTradingHaltWorkspace(workspace, halt) {
|
|
const input = workspace.querySelector("#halt-search");
|
|
syncSearchDraft(input, halt.query);
|
|
}
|
|
|
|
function renderTradingHalt(state) {
|
|
const halt = state.tradingHalt;
|
|
if (!halt || !Array.isArray(halt.results)) return;
|
|
const structureKey = JSON.stringify({
|
|
results: halt.results,
|
|
nameSort: halt.nameSort,
|
|
actions: halt.actions
|
|
});
|
|
const existingWorkspace = categoryTree.querySelector(".halt-workspace");
|
|
if (existingWorkspace && structureKey === tradingHaltRenderStructureKey) {
|
|
updateTradingHaltWorkspace(existingWorkspace, halt);
|
|
return;
|
|
}
|
|
const workspace = document.createElement("div");
|
|
workspace.className = "halt-workspace";
|
|
const search = document.createElement("div");
|
|
search.className = "halt-search-line";
|
|
const input = document.createElement("input");
|
|
input.id = "halt-search";
|
|
input.maxLength = 64;
|
|
input.value = halt.query || "";
|
|
input.placeholder = "거래정지 종목";
|
|
const searchButton = document.createElement("button");
|
|
searchButton.type = "button";
|
|
searchButton.dataset.haltSearch = "true";
|
|
searchButton.textContent = "검색";
|
|
const sortButton = document.createElement("button");
|
|
sortButton.type = "button";
|
|
sortButton.dataset.haltSort = "true";
|
|
sortButton.textContent = halt.nameSort === "ascending" ? "종목명 ↑" : "종목명 ↓";
|
|
search.append(input, searchButton, sortButton);
|
|
const results = document.createElement("div");
|
|
results.className = "halt-results";
|
|
results.setAttribute("aria-label", "거래정지 종목 검색 결과");
|
|
let selectedHaltResult = null;
|
|
halt.results.forEach(function (row) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.id = "halt-result-" + row.selectionId;
|
|
button.dataset.haltSelectionId = row.selectionId;
|
|
button.textContent = (row.market === "kospi" ? "코스피 · " : "코스닥 · ") + row.displayName;
|
|
button.className = row.isSelected ? "selected" : "";
|
|
button.setAttribute("aria-selected", row.isSelected ? "true" : "false");
|
|
if (row.isSelected) selectedHaltResult = button;
|
|
results.appendChild(button);
|
|
});
|
|
configureRovingList(
|
|
results,
|
|
Array.from(results.querySelectorAll("button[data-halt-selection-id]")),
|
|
selectedHaltResult);
|
|
const actions = document.createElement("div");
|
|
actions.className = "halt-actions";
|
|
markVisualCardList(actions, "halt-actions");
|
|
halt.actions.forEach(function (action) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.dataset.haltActionId = action.actionId;
|
|
setVisualCardContent(button, {
|
|
title: action.label,
|
|
kind: "halt"
|
|
});
|
|
button.disabled = !action.isAvailable;
|
|
actions.appendChild(button);
|
|
});
|
|
workspace.append(search, results, actions);
|
|
categoryTree.replaceChildren(workspace);
|
|
tradingHaltRenderStructureKey = structureKey;
|
|
updateTradingHaltWorkspace(workspace, halt);
|
|
}
|
|
|
|
const manualFinancialReferenceImages = Object.freeze({
|
|
"revenue-composition": "./Previews/pie.bmp",
|
|
"growth-metrics": "./Previews/Grow.bmp",
|
|
sales: "./Previews/sell.bmp",
|
|
"operating-profit": "./Previews/profit.bmp"
|
|
});
|
|
|
|
function manualFinancialPreviewColumns(screen) {
|
|
if (screen === "revenue-composition") {
|
|
return ["종목명", "기준일", "구성 1", "구성 2", "구성 3", "구성 4", "구성 5"];
|
|
}
|
|
if (screen === "growth-metrics") {
|
|
return ["종목명", "매출 성장", "영업익 성장", "순자산 성장", "순이익 성장", "기간"];
|
|
}
|
|
return ["종목명", "분기 1", "분기 2", "분기 3", "분기 4", "분기 5", "분기 6"];
|
|
}
|
|
|
|
function manualFinancialPreviewValues(row, count) {
|
|
const projected = Array.isArray(row.previewValues)
|
|
? row.previewValues.slice(0, count)
|
|
: [];
|
|
while (projected.length < count) projected.push("");
|
|
projected[0] = projected[0] == null || String(projected[0]).trim() === ""
|
|
? (row.stockName || "")
|
|
: projected[0];
|
|
return projected.map(function (value) {
|
|
return value == null ? "" : String(value);
|
|
});
|
|
}
|
|
|
|
function manualFinancialReferenceFigure(screen, label) {
|
|
const source = manualFinancialReferenceImages[screen];
|
|
if (!source) return null;
|
|
const figure = manualElement("figure", "manual-financial-reference");
|
|
const image = document.createElement("img");
|
|
image.src = source;
|
|
image.width = 480;
|
|
image.height = 270;
|
|
image.loading = "eager";
|
|
image.decoding = "async";
|
|
image.alt = label + " 기존 송출 화면 참고 이미지";
|
|
const caption = manualElement(
|
|
"figcaption",
|
|
"",
|
|
"기존 송출 화면 참고 · 입력 위치와 구성 확인용");
|
|
figure.append(image, caption);
|
|
return figure;
|
|
}
|
|
|
|
function blankManualRecord(screen) {
|
|
if (screen === "revenue-composition") {
|
|
return {
|
|
screen: screen,
|
|
stockName: "",
|
|
baseDate: "",
|
|
slices: Array.from({ length: 5 }, function () {
|
|
return { label: "", percentageText: "" };
|
|
})
|
|
};
|
|
}
|
|
if (screen === "growth-metrics") {
|
|
return {
|
|
screen: screen,
|
|
stockName: "",
|
|
salesGrowth: [null, null, null, null],
|
|
operatingProfitGrowth: [null, null, null, null],
|
|
netAssetGrowth: [null, null, null, null],
|
|
netIncomeGrowth: [null, null, null, null],
|
|
periods: ["", "", "", ""]
|
|
};
|
|
}
|
|
return {
|
|
screen: screen,
|
|
stockName: "",
|
|
quarters: Array.from({ length: 6 }, function () {
|
|
return { quarter: "", value: 0 };
|
|
})
|
|
};
|
|
}
|
|
|
|
function splitLegacyRawValue(value, count) {
|
|
const tokens = String(value === null || value === undefined ? "" : value).split("_");
|
|
while (tokens.length < count) tokens.push("");
|
|
if (tokens.length > count) {
|
|
tokens[count - 1] = tokens.slice(count - 1).join("_");
|
|
tokens.length = count;
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
function manualRecordFromRaw(raw) {
|
|
if (!raw) return null;
|
|
const record = blankManualRecord(raw.screen);
|
|
record.stockName = raw.stockName || "";
|
|
record.rawMode = true;
|
|
const values = Array.isArray(raw.storageValues) ? raw.storageValues : [];
|
|
record.rawOriginalStorageValues = values.map(function (value) {
|
|
return String(value === null || value === undefined ? "" : value);
|
|
});
|
|
record.rawDirtyStorageIndices = [];
|
|
if (record.screen === "revenue-composition") {
|
|
record.baseDate = values[0] || "";
|
|
record.slices = record.slices.map(function (_, index) {
|
|
const tokens = splitLegacyRawValue(values[index + 1] || "", 2);
|
|
return { label: tokens[0], percentageText: tokens[1] };
|
|
});
|
|
} else if (record.screen === "growth-metrics") {
|
|
["salesGrowth", "operatingProfitGrowth", "netAssetGrowth", "netIncomeGrowth"]
|
|
.forEach(function (seriesName, index) {
|
|
record[seriesName] = splitLegacyRawValue(values[index] || "", 4);
|
|
});
|
|
record.periods = splitLegacyRawValue(values[4] || "", 4);
|
|
} else {
|
|
record.quarters = record.quarters.map(function (_, index) {
|
|
const tokens = splitLegacyRawValue(values[index] || "", 2);
|
|
return { quarter: tokens[0], value: tokens[1] };
|
|
});
|
|
}
|
|
return record;
|
|
}
|
|
|
|
function preserveManualRawStorageValue(record, index, rebuiltValue) {
|
|
const dirty = Array.isArray(record.rawDirtyStorageIndices) &&
|
|
record.rawDirtyStorageIndices.includes(index);
|
|
const originals = Array.isArray(record.rawOriginalStorageValues)
|
|
? record.rawOriginalStorageValues : [];
|
|
return !dirty && index < originals.length
|
|
? originals[index]
|
|
: rebuiltValue;
|
|
}
|
|
|
|
function cloneManualRecord(record) {
|
|
return record ? JSON.parse(JSON.stringify(record)) : null;
|
|
}
|
|
|
|
function shouldResetManualStockDraft(stockIsSelected, stockNameReadOnly, draft) {
|
|
return stockIsSelected !== true && stockNameReadOnly !== true &&
|
|
!!draft && draft.rawMode !== true;
|
|
}
|
|
|
|
function manualElement(tagName, className, text) {
|
|
const element = document.createElement(tagName);
|
|
if (className) element.className = className;
|
|
if (text !== undefined) element.textContent = text;
|
|
return element;
|
|
}
|
|
|
|
function manualInput(value, dataName, dataValue, type) {
|
|
const input = document.createElement("input");
|
|
input.type = type || "text";
|
|
input.value = value === null || value === undefined ? "" : String(value);
|
|
if (dataName) input.dataset[dataName] = String(dataValue);
|
|
return input;
|
|
}
|
|
|
|
function appendManualLabel(parent, labelText, input) {
|
|
const label = manualElement("label", "manual-financial-field");
|
|
label.append(manualElement("span", "", labelText), input);
|
|
parent.appendChild(label);
|
|
}
|
|
|
|
function renderManualEditorFields(editor, record) {
|
|
const fields = manualElement("div", "manual-financial-fields");
|
|
if (record.screen === "revenue-composition") {
|
|
appendManualLabel(
|
|
fields,
|
|
"기준일",
|
|
manualInput(record.baseDate, "manualBaseDate", "true"));
|
|
const slices = manualElement("div", "manual-financial-grid manual-revenue-grid");
|
|
slices.append(
|
|
manualElement("strong", "", "구성"),
|
|
manualElement("strong", "", "비율"));
|
|
record.slices.forEach(function (slice, index) {
|
|
slices.append(
|
|
manualInput(slice ? slice.label : "", "manualRevenueLabel", index),
|
|
manualInput(
|
|
slice ? slice.percentageText : "",
|
|
"manualRevenuePercentage",
|
|
index));
|
|
});
|
|
fields.appendChild(slices);
|
|
} else if (record.screen === "growth-metrics") {
|
|
const grid = manualElement("div", "manual-financial-grid manual-growth-grid");
|
|
grid.appendChild(manualElement("strong", "", "구분"));
|
|
record.periods.forEach(function (period, index) {
|
|
grid.appendChild(manualInput(period, "manualGrowthPeriod", index));
|
|
});
|
|
[
|
|
["매출 성장", "salesGrowth"],
|
|
["영업이익 성장", "operatingProfitGrowth"],
|
|
["순자산 성장", "netAssetGrowth"],
|
|
["순이익 성장", "netIncomeGrowth"]
|
|
].forEach(function (series) {
|
|
grid.appendChild(manualElement("span", "", series[0]));
|
|
record[series[1]].forEach(function (value, index) {
|
|
const input = manualInput(
|
|
value,
|
|
"manualGrowthValue",
|
|
index,
|
|
record.rawMode ? "text" : "number");
|
|
input.dataset.manualGrowthSeries = series[1];
|
|
input.step = "any";
|
|
grid.appendChild(input);
|
|
});
|
|
});
|
|
fields.appendChild(grid);
|
|
} else {
|
|
const grid = manualElement("div", "manual-financial-grid manual-quarter-grid");
|
|
grid.append(
|
|
manualElement("strong", "", "분기"),
|
|
manualElement("strong", "", "값"));
|
|
record.quarters.forEach(function (quarter, index) {
|
|
grid.append(
|
|
manualInput(quarter.quarter, "manualQuarterLabel", index),
|
|
manualInput(
|
|
quarter.value,
|
|
"manualQuarterValue",
|
|
index,
|
|
record.rawMode ? "text" : "number"));
|
|
});
|
|
fields.appendChild(grid);
|
|
}
|
|
editor.appendChild(fields);
|
|
}
|
|
|
|
function bindManualRawStorageInputs(editor, record) {
|
|
if (!record || record.rawMode !== true) return;
|
|
const bind = function (selector, indexFactory) {
|
|
editor.querySelectorAll(selector).forEach(function (input) {
|
|
input.dataset.manualRawStorageIndex = String(indexFactory(input));
|
|
});
|
|
};
|
|
bind("[data-manual-base-date]", function () { return 0; });
|
|
bind("[data-manual-revenue-label], [data-manual-revenue-percentage]",
|
|
function (input) {
|
|
return Number(input.dataset.manualRevenueLabel !== undefined
|
|
? input.dataset.manualRevenueLabel
|
|
: input.dataset.manualRevenuePercentage) + 1;
|
|
});
|
|
const growthSeriesIndices = {
|
|
salesGrowth: 0,
|
|
operatingProfitGrowth: 1,
|
|
netAssetGrowth: 2,
|
|
netIncomeGrowth: 3
|
|
};
|
|
bind("[data-manual-growth-value]", function (input) {
|
|
return growthSeriesIndices[input.dataset.manualGrowthSeries];
|
|
});
|
|
bind("[data-manual-growth-period]", function () { return 4; });
|
|
bind("[data-manual-quarter-label], [data-manual-quarter-value]",
|
|
function (input) {
|
|
return Number(input.dataset.manualQuarterLabel !== undefined
|
|
? input.dataset.manualQuarterLabel
|
|
: input.dataset.manualQuarterValue);
|
|
});
|
|
}
|
|
|
|
function readManualRecord() {
|
|
if (!manualDraft || !manualWorkspace) return null;
|
|
const stockName = manualWorkspace.querySelector("#manual-financial-stock-name");
|
|
const record = blankManualRecord(manualDraft.screen);
|
|
record.stockName = stockName ? stockName.value.trim() : "";
|
|
if (record.screen === "revenue-composition") {
|
|
const baseDate = manualWorkspace.querySelector("[data-manual-base-date]");
|
|
record.baseDate = baseDate ? baseDate.value : "";
|
|
record.slices = record.slices.map(function (_, index) {
|
|
const label = manualWorkspace.querySelector(
|
|
"[data-manual-revenue-label=\"" + index + "\"]");
|
|
const percentage = manualWorkspace.querySelector(
|
|
"[data-manual-revenue-percentage=\"" + index + "\"]");
|
|
const labelValue = label ? label.value.trim() : "";
|
|
const percentageValue = percentage ? percentage.value.trim() : "";
|
|
return labelValue === "" && percentageValue === "" ? null : {
|
|
label: labelValue,
|
|
percentageText: percentageValue
|
|
};
|
|
});
|
|
} else if (record.screen === "growth-metrics") {
|
|
record.periods = record.periods.map(function (_, index) {
|
|
const input = manualWorkspace.querySelector(
|
|
"[data-manual-growth-period=\"" + index + "\"]");
|
|
return input ? input.value : "";
|
|
});
|
|
["salesGrowth", "operatingProfitGrowth", "netAssetGrowth", "netIncomeGrowth"]
|
|
.forEach(function (seriesName) {
|
|
record[seriesName] = record[seriesName].map(function (_, index) {
|
|
const input = manualWorkspace.querySelector(
|
|
"[data-manual-growth-series=\"" + seriesName + "\"]" +
|
|
"[data-manual-growth-value=\"" + index + "\"]");
|
|
if (!input || input.value.trim() === "") return null;
|
|
const value = Number(input.value);
|
|
return Number.isFinite(value) ? value : null;
|
|
});
|
|
});
|
|
} else {
|
|
record.quarters = record.quarters.map(function (_, index) {
|
|
const quarter = manualWorkspace.querySelector(
|
|
"[data-manual-quarter-label=\"" + index + "\"]");
|
|
const value = manualWorkspace.querySelector(
|
|
"[data-manual-quarter-value=\"" + index + "\"]");
|
|
const parsed = value && /^-?\d+$/.test(value.value.trim())
|
|
? Number(value.value) : null;
|
|
return {
|
|
quarter: quarter ? quarter.value.trim() : "",
|
|
value: parsed
|
|
};
|
|
});
|
|
}
|
|
return record;
|
|
}
|
|
|
|
function readManualRawRecord() {
|
|
if (!manualDraft || !manualWorkspace || manualDraft.rawMode !== true) return null;
|
|
const stockName = manualWorkspace.querySelector("#manual-financial-stock-name");
|
|
const record = {
|
|
screen: manualDraft.screen,
|
|
stockName: stockName ? stockName.value : "",
|
|
storageValues: []
|
|
};
|
|
if (record.screen === "revenue-composition") {
|
|
const baseDate = manualWorkspace.querySelector("[data-manual-base-date]");
|
|
record.storageValues.push(preserveManualRawStorageValue(
|
|
manualDraft,
|
|
0,
|
|
baseDate ? baseDate.value : ""));
|
|
for (let index = 0; index < 5; index += 1) {
|
|
const label = manualWorkspace.querySelector(
|
|
"[data-manual-revenue-label=\"" + index + "\"]");
|
|
const percentage = manualWorkspace.querySelector(
|
|
"[data-manual-revenue-percentage=\"" + index + "\"]");
|
|
record.storageValues.push(preserveManualRawStorageValue(
|
|
manualDraft,
|
|
index + 1,
|
|
(label ? label.value : "") + "_" +
|
|
(percentage ? percentage.value : "")));
|
|
}
|
|
} else if (record.screen === "growth-metrics") {
|
|
["salesGrowth", "operatingProfitGrowth", "netAssetGrowth", "netIncomeGrowth"]
|
|
.forEach(function (seriesName) {
|
|
const values = [];
|
|
for (let index = 0; index < 4; index += 1) {
|
|
const input = manualWorkspace.querySelector(
|
|
"[data-manual-growth-series=\"" + seriesName + "\"]" +
|
|
"[data-manual-growth-value=\"" + index + "\"]");
|
|
values.push(input ? input.value : "");
|
|
}
|
|
record.storageValues.push(preserveManualRawStorageValue(
|
|
manualDraft,
|
|
record.storageValues.length,
|
|
values.join("_")));
|
|
});
|
|
const periods = [];
|
|
for (let index = 0; index < 4; index += 1) {
|
|
const input = manualWorkspace.querySelector(
|
|
"[data-manual-growth-period=\"" + index + "\"]");
|
|
periods.push(input ? input.value : "");
|
|
}
|
|
record.storageValues.push(preserveManualRawStorageValue(
|
|
manualDraft,
|
|
4,
|
|
periods.join("_")));
|
|
} else {
|
|
for (let index = 0; index < 6; index += 1) {
|
|
const quarter = manualWorkspace.querySelector(
|
|
"[data-manual-quarter-label=\"" + index + "\"]");
|
|
const value = manualWorkspace.querySelector(
|
|
"[data-manual-quarter-value=\"" + index + "\"]");
|
|
record.storageValues.push(preserveManualRawStorageValue(
|
|
manualDraft,
|
|
index,
|
|
(quarter ? quarter.value : "") + "_" + (value ? value.value : "")));
|
|
}
|
|
}
|
|
return record;
|
|
}
|
|
|
|
function rememberManualDraft() {
|
|
if (manualDraft && manualDraft.rawMode === true) {
|
|
const raw = readManualRawRecord();
|
|
if (raw) manualDraft = manualRecordFromRaw(raw);
|
|
return;
|
|
}
|
|
const record = readManualRecord();
|
|
if (record) manualDraft = record;
|
|
}
|
|
|
|
function focusManualFinancialStock(stock) {
|
|
if (!stock || stock.disabled === true || !manualWorkspace.contains(stock)) return false;
|
|
if (document.activeElement !== stock) stock.focus({ preventScroll: true });
|
|
return true;
|
|
}
|
|
|
|
function selectManualFinancialStock(stock) {
|
|
if (!focusManualFinancialStock(stock)) return false;
|
|
rememberManualDraft();
|
|
const previousDraft = manualDraft ? cloneManualRecord(manualDraft) : null;
|
|
const previousDraftKey = manualDraftKey;
|
|
const previousDraftResetPending = manualDraftResetPending;
|
|
const stockName = manualWorkspace.querySelector("#manual-financial-stock-name");
|
|
const stockIsSelected = stock.getAttribute("aria-selected") === "true";
|
|
if (stockName && shouldResetManualStockDraft(
|
|
stockIsSelected,
|
|
stockName.readOnly,
|
|
manualDraft)) {
|
|
// The original GraphE forms cleared the other editor cells whenever the
|
|
// operator deliberately chose another stock. Do this only for that
|
|
// selection transition; ordinary native rerenders keep the live draft.
|
|
manualDraft = blankManualRecord(manualDraft.screen);
|
|
manualDraftKey = manualDraft.screen + "|new";
|
|
manualDraftResetPending = true;
|
|
}
|
|
if (send("select-manual-financial-stock", {
|
|
resultId: stock.dataset.manualStockResultId
|
|
})) return true;
|
|
|
|
manualDraft = previousDraft;
|
|
manualDraftKey = previousDraftKey;
|
|
manualDraftResetPending = previousDraftResetPending;
|
|
return false;
|
|
}
|
|
|
|
function captureManualDraftForChangedModel() {
|
|
if (!manualModal.hidden && !manualDraftResetPending) rememberManualDraft();
|
|
manualDraftResetPending = false;
|
|
}
|
|
|
|
function renderManualFinancial(state) {
|
|
const manual = state.manualFinancial;
|
|
manualButtons.forEach(function (button) {
|
|
button.disabled = state.isBusy === true;
|
|
});
|
|
if (!manual) {
|
|
clearManualResultPendingSelection(false);
|
|
setTransientControlsBusy(manualDialog, false);
|
|
manualModal.hidden = true;
|
|
manualWorkspace.replaceChildren();
|
|
forgetSearchDraft(
|
|
"manual-financial-list-query",
|
|
"manual-financial-find-query");
|
|
manualFinancialSearchFocus = null;
|
|
manualDraft = null;
|
|
manualDraftKey = "";
|
|
manualDraftResetPending = false;
|
|
manualFinancialRenderStateKey = "";
|
|
return;
|
|
}
|
|
|
|
const capturedSearchFocus = captureSearchDraftFocus(manualWorkspace);
|
|
if (capturedSearchFocus) manualFinancialSearchFocus = capturedSearchFocus;
|
|
const manualSearchScope = "manual-financial:" + manual.screen;
|
|
const renderStateKey = JSON.stringify(manual);
|
|
if (manualResultPendingSelection) {
|
|
if (manualResultPendingSelection.sent && state.isBusy !== true) {
|
|
const accepted = manual.selectedRowId === manualResultPendingSelection.key;
|
|
clearManualResultPendingSelection(!accepted);
|
|
} else if (!manualResultPendingSelection.sent &&
|
|
manualFinancialRenderStateKey &&
|
|
renderStateKey !== manualFinancialRenderStateKey) {
|
|
clearManualResultPendingSelection(false);
|
|
} else {
|
|
applyPendingRowSelection(manualResultPendingSelection, false);
|
|
applyPendingControlLocks(manualResultPendingSelection.lockedControls);
|
|
}
|
|
}
|
|
if (!manualModal.hidden && renderStateKey === manualFinancialRenderStateKey) {
|
|
setTransientControlsBusy(manualDialog, state.isBusy === true);
|
|
if (state.isBusy !== true &&
|
|
restoreSearchDraftFocus(manualWorkspace, manualFinancialSearchFocus)) {
|
|
manualFinancialSearchFocus = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
captureManualDraftForChangedModel();
|
|
// Clear the previous transient snapshot before applying a changed model.
|
|
// Otherwise a busy -> new-model transition can restore the old screen's
|
|
// semantic disabled states over the controls rendered below.
|
|
setTransientControlsBusy(manualDialog, false);
|
|
if (manualModal.hidden) rememberModalOpener(manualDialog);
|
|
manualModal.hidden = false;
|
|
manualTitle.textContent = manual.label + " · GraphE 수동 입력";
|
|
const selectedEditorRecord = manual.selectedRawRecord
|
|
? manualRecordFromRaw(manual.selectedRawRecord)
|
|
: manual.selectedRecord;
|
|
const detailKey = manual.screen + "|" + (manual.selectedRowId || "new") + "|" +
|
|
JSON.stringify(manual.selectedRawRecord || manual.selectedRecord || null);
|
|
if (selectedEditorRecord && detailKey !== manualDraftKey) {
|
|
manualDraft = cloneManualRecord(selectedEditorRecord);
|
|
manualDraftKey = detailKey;
|
|
} else if (!manualDraft || manualDraft.screen !== manual.screen) {
|
|
manualDraft = blankManualRecord(manual.screen);
|
|
manualDraftKey = manual.screen + "|new";
|
|
}
|
|
if (!manual.selectedRecord && manual.verifiedStock) {
|
|
manualDraft.stockName = manual.verifiedStock.name;
|
|
}
|
|
|
|
const toolbar = manualElement("div", "manual-financial-toolbar");
|
|
const listQuery = manualInput(manual.query || "", "manualListQuery", "true");
|
|
listQuery.id = "manual-financial-list-query";
|
|
syncSearchDraft(listQuery, manual.query, manualSearchScope);
|
|
const listSearch = manualElement("button", "", "목록 검색");
|
|
listSearch.type = "button";
|
|
listSearch.dataset.manualListSearch = "true";
|
|
const findQuery = manualInput(manual.findQuery || "", "manualFindQuery", "true");
|
|
findQuery.id = "manual-financial-find-query";
|
|
findQuery.placeholder = "종목명 찾기";
|
|
syncSearchDraft(findQuery, manual.findQuery, manualSearchScope);
|
|
const findUp = manualElement("button", "", "▲ 위로 찾기");
|
|
findUp.type = "button";
|
|
findUp.dataset.manualFindDirection = "up";
|
|
findUp.dataset.manualGeneration = String(manual.generation);
|
|
const findDown = manualElement("button", "", "▼ 아래로 찾기");
|
|
findDown.type = "button";
|
|
findDown.dataset.manualFindDirection = "down";
|
|
findDown.dataset.manualGeneration = String(manual.generation);
|
|
const create = manualElement("button", "", "새 입력");
|
|
create.type = "button";
|
|
create.dataset.manualCreate = "true";
|
|
toolbar.append(listQuery, listSearch, create, findQuery, findUp, findDown);
|
|
|
|
const body = manualElement("div", "manual-financial-body");
|
|
const list = manualElement("div", "manual-financial-list");
|
|
list.setAttribute("aria-label", manual.label + " 저장 목록");
|
|
const previewColumns = manualFinancialPreviewColumns(manual.screen);
|
|
list.classList.add("manual-financial-preview-columns-" + String(previewColumns.length));
|
|
const nameHeader = manualElement(
|
|
"button",
|
|
"manual-financial-list-header manual-financial-list-row");
|
|
nameHeader.type = "button";
|
|
nameHeader.dataset.manualNameSort = "true";
|
|
nameHeader.dataset.manualGeneration = String(manual.generation);
|
|
nameHeader.title = "종목명 정렬 방향 전환";
|
|
previewColumns.forEach(function (label, index) {
|
|
nameHeader.appendChild(manualElement(
|
|
"span",
|
|
"manual-financial-preview-cell",
|
|
index === 0
|
|
? label + " " + (manual.nameSortDirection === "descending" ? "▼" : "▲")
|
|
: label));
|
|
});
|
|
list.appendChild(nameHeader);
|
|
let selectedManualRow = null;
|
|
manual.rows.forEach(function (row) {
|
|
const button = manualElement("button", "manual-financial-list-row");
|
|
button.type = "button";
|
|
button.id = "manual-financial-result-" + row.resultId;
|
|
button.dataset.manualResultId = row.resultId;
|
|
const isSelected = manual.selectedRowId === row.resultId;
|
|
button.classList.toggle("selected", isSelected);
|
|
button.setAttribute("aria-selected", isSelected ? "true" : "false");
|
|
const previewValues = manualFinancialPreviewValues(row, previewColumns.length);
|
|
previewValues.forEach(function (value) {
|
|
const cell = manualElement("span", "manual-financial-preview-cell", value);
|
|
if (value) cell.title = value;
|
|
button.appendChild(cell);
|
|
});
|
|
button.setAttribute("aria-label", previewColumns.map(function (label, index) {
|
|
return label + " " + (previewValues[index] || "없음");
|
|
}).join(", "));
|
|
if (row.isDataReadable === false) {
|
|
button.classList.add("manual-financial-row-unreadable");
|
|
button.title = "종목 이름을 읽을 수 없어 이 행을 사용할 수 없습니다.";
|
|
button.setAttribute(
|
|
"aria-label",
|
|
button.getAttribute("aria-label") + " · 읽기 불가");
|
|
} else if (row.isPlayoutReady === false) {
|
|
button.classList.add("manual-financial-row-legacy");
|
|
button.title = "기존 GraphE 원문 형식입니다. 선택해 원문 그대로 확인하고 편집할 수 있습니다.";
|
|
button.setAttribute(
|
|
"aria-label",
|
|
button.getAttribute("aria-label") + " · 기존 원문 형식");
|
|
}
|
|
if (isSelected) selectedManualRow = button;
|
|
list.appendChild(button);
|
|
});
|
|
configureRovingList(
|
|
list,
|
|
Array.from(list.querySelectorAll("button[data-manual-result-id]")),
|
|
selectedManualRow);
|
|
|
|
const editor = manualElement("section", "manual-financial-editor");
|
|
const stockLine = manualElement("div", "manual-financial-stock-line");
|
|
const stockInput = manualInput(manualDraft.stockName || "");
|
|
stockInput.id = "manual-financial-stock-name";
|
|
stockInput.readOnly = !!manual.selectedRecord || !!manual.selectedRawRecord;
|
|
const stockSearch = manualElement("button", "", "종목 확인");
|
|
stockSearch.type = "button";
|
|
stockSearch.dataset.manualStockSearch = "true";
|
|
stockLine.append(stockInput, stockSearch);
|
|
editor.appendChild(stockLine);
|
|
|
|
const candidates = manualElement("div", "manual-financial-stock-results");
|
|
let selectedManualStock = null;
|
|
manual.stockCandidates.forEach(function (row) {
|
|
const isSelected = manual.selectedStockResultId === row.resultId;
|
|
const button = manualElement(
|
|
"button",
|
|
isSelected ? "selected" : "",
|
|
row.name + " · " + row.market);
|
|
button.type = "button";
|
|
button.id = "manual-financial-stock-result-" + row.resultId;
|
|
button.dataset.manualStockResultId = row.resultId;
|
|
button.setAttribute(
|
|
"aria-selected",
|
|
isSelected ? "true" : "false");
|
|
if (isSelected) selectedManualStock = button;
|
|
candidates.appendChild(button);
|
|
});
|
|
// A single click on the legacy GraphE candidate ListBox only moved its
|
|
// focus/highlight. Keep arrow/page navigation equally non-destructive;
|
|
// double-click or Enter remains the explicit stock-selection command.
|
|
configureRovingList(
|
|
candidates,
|
|
Array.from(candidates.querySelectorAll("button[data-manual-stock-result-id]")),
|
|
selectedManualStock,
|
|
{ activation: "focus", selectionFollowsFocus: false });
|
|
editor.appendChild(candidates);
|
|
const verification = manualElement(
|
|
"div",
|
|
"manual-financial-verification",
|
|
manual.verifiedStock
|
|
? "확인됨: " + manual.verifiedStock.name + " · " + manual.verifiedStock.market
|
|
: "정확한 종목 마스터를 확인하세요.");
|
|
editor.appendChild(verification);
|
|
const referenceFigure = manualFinancialReferenceFigure(manual.screen, manual.label);
|
|
if (referenceFigure) editor.appendChild(referenceFigure);
|
|
renderManualEditorFields(editor, manualDraft);
|
|
bindManualRawStorageInputs(editor, manualDraft);
|
|
if (manual.selectedRawRecord && manual.isPlayoutReady !== true) {
|
|
editor.appendChild(manualElement(
|
|
"div",
|
|
"manual-financial-warning",
|
|
"기존 GraphE 원문 값으로 불러왔습니다. 편집·삭제할 수 있고, 송출 전에는 장면 데이터 검증을 다시 수행합니다."));
|
|
}
|
|
|
|
const actions = manualElement("div", "manual-financial-actions");
|
|
const save = manualElement("button", "manual-primary", "저장");
|
|
save.type = "button";
|
|
save.dataset.manualSave = "true";
|
|
save.disabled = !manual.canSave;
|
|
const remove = manualElement("button", "", "선택 삭제");
|
|
remove.type = "button";
|
|
remove.dataset.manualDelete = "true";
|
|
remove.disabled = !manual.canDelete;
|
|
const removeAll = manualElement("button", "", "전체 삭제");
|
|
removeAll.type = "button";
|
|
removeAll.dataset.manualDeleteAll = "true";
|
|
removeAll.disabled = manual.writesQuarantined === true;
|
|
const add = manualElement("button", "manual-primary", "편성표에 추가");
|
|
add.type = "button";
|
|
add.dataset.manualActionId = manual.action.actionId;
|
|
add.disabled = !manual.canMaterializePlaylist;
|
|
actions.append(save, remove, removeAll, add);
|
|
editor.appendChild(actions);
|
|
if (manual.lastMessage) {
|
|
editor.appendChild(manualElement(
|
|
"div",
|
|
manual.writesQuarantined || manual.listRejectedItemCount > 0 ||
|
|
manual.listIsTruncated === true
|
|
? "manual-financial-warning"
|
|
: "manual-financial-message",
|
|
manual.lastMessage));
|
|
}
|
|
|
|
body.append(list, editor);
|
|
manualWorkspace.replaceChildren(toolbar, body);
|
|
manualClose.disabled = false;
|
|
manualExit.disabled = manualClose.disabled;
|
|
setTransientControlsBusy(manualDialog, state.isBusy === true);
|
|
manualFinancialRenderStateKey = renderStateKey;
|
|
if (state.isBusy !== true &&
|
|
restoreSearchDraftFocus(manualWorkspace, manualFinancialSearchFocus)) {
|
|
manualFinancialSearchFocus = null;
|
|
}
|
|
}
|
|
|
|
function isCanonicalNamedPlaylistTitle(value) {
|
|
return value.length > 0 && value.length <= 128 && value === value.trim() &&
|
|
!/[\u0000-\u001f\u007f-\u009f\ud800-\udfff\ufffd]/.test(value);
|
|
}
|
|
|
|
function renderManualLists(state) {
|
|
const manual = state.manualLists;
|
|
const fixedBatch = state.fixedSectionBatch;
|
|
manualListButtons.forEach(function (button) {
|
|
button.disabled = state.isBusy === true || fixedBatch != null;
|
|
});
|
|
if (!manual || manual.isOpen !== true) {
|
|
clearManualViResultPendingSelection(false);
|
|
setTransientControlsBusy(manualListDialog, false);
|
|
manualListModal.hidden = true;
|
|
manualListWorkspace.replaceChildren();
|
|
forgetSearchDraft("manual-vi-search");
|
|
manualListSearchFocus = null;
|
|
manualListRenderStateKey = "";
|
|
return;
|
|
}
|
|
|
|
if (manual.screen === "vi") {
|
|
const capturedSearchFocus = captureSearchDraftFocus(manualListWorkspace);
|
|
if (capturedSearchFocus) manualListSearchFocus = capturedSearchFocus;
|
|
} else {
|
|
clearManualViResultPendingSelection(false);
|
|
forgetSearchDraft("manual-vi-search");
|
|
manualListSearchFocus = null;
|
|
}
|
|
const renderStateKey = JSON.stringify({
|
|
manual: manual,
|
|
fixedBatch: fixedBatch
|
|
});
|
|
if (manualViResultPendingSelection) {
|
|
if (manualViResultPendingSelection.sent && state.isBusy !== true) {
|
|
const accepted = manual.selectedSearchResultId ===
|
|
manualViResultPendingSelection.key;
|
|
clearManualViResultPendingSelection(!accepted);
|
|
} else if (!manualViResultPendingSelection.sent && manualListRenderStateKey &&
|
|
renderStateKey !== manualListRenderStateKey) {
|
|
clearManualViResultPendingSelection(false);
|
|
} else {
|
|
applyPendingRowSelection(manualViResultPendingSelection, false);
|
|
}
|
|
}
|
|
if (!manualListModal.hidden && renderStateKey === manualListRenderStateKey) {
|
|
setTransientControlsBusy(manualListDialog, state.isBusy === true);
|
|
if (state.isBusy !== true && manual.screen === "vi" &&
|
|
restoreSearchDraftFocus(manualListWorkspace, manualListSearchFocus)) {
|
|
manualListSearchFocus = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
setTransientControlsBusy(manualListDialog, false);
|
|
if (manualListModal.hidden) rememberModalOpener(manualListDialog);
|
|
manualListModal.hidden = false;
|
|
const busy = state.isBusy === true;
|
|
if (manual.screen !== "vi") {
|
|
clearManualViResultPendingSelection(false);
|
|
}
|
|
const audienceLabels = {
|
|
individual: "개인",
|
|
foreign: "외국인",
|
|
institution: "기관"
|
|
};
|
|
const baseTitle = manual.screen === "vi"
|
|
? "VI 발동 수동 목록"
|
|
: (audienceLabels[manual.audience] || "수동") + " 순매도 상위";
|
|
manualListTitle.textContent = fixedBatch
|
|
? baseTitle + " · " + fixedBatch.sectionName + " " +
|
|
String(fixedBatch.manualStepNumber) + "/" + String(fixedBatch.manualStepCount)
|
|
: baseTitle;
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
if (fixedBatch) {
|
|
const progress = manualElement(
|
|
"div",
|
|
"manual-list-status",
|
|
"섹션 전체 " + String(fixedBatch.totalActionCount) + "개는 아직 편성표에 추가되지 않았습니다. " +
|
|
"현재 항목을 확인하면 C#이 다음 수동 항목으로 진행하며, 닫으면 배치를 취소합니다.");
|
|
progress.setAttribute("role", "status");
|
|
fragment.appendChild(progress);
|
|
}
|
|
if (manual.writesQuarantined || manual.isAvailable !== true) {
|
|
const warning = manualElement(
|
|
"div",
|
|
"manual-list-status error",
|
|
manual.statusMessage || manual.availabilityMessage ||
|
|
"수동 목록 저장소를 사용할 수 없습니다.");
|
|
warning.setAttribute("role", "alert");
|
|
fragment.appendChild(warning);
|
|
}
|
|
|
|
if (manual.screen === "net-sell") {
|
|
const tabs = manualElement("div", "manual-list-tabs");
|
|
Object.keys(audienceLabels).forEach(function (audience) {
|
|
const button = manualElement(
|
|
"button",
|
|
audience === manual.audience ? "active" : "",
|
|
audienceLabels[audience]);
|
|
button.type = "button";
|
|
button.disabled = fixedBatch != null;
|
|
button.addEventListener("click", function () {
|
|
send("open-manual-list", { screen: audience });
|
|
});
|
|
tabs.appendChild(button);
|
|
});
|
|
fragment.appendChild(tabs);
|
|
|
|
const wrap = manualElement("div", "manual-list-table-wrap");
|
|
const table = manualElement("table", "manual-list-table");
|
|
const head = document.createElement("thead");
|
|
const headRow = document.createElement("tr");
|
|
["#", "좌측 종목", "좌측 금액", "우측 종목", "우측 금액"].forEach(function (label) {
|
|
headRow.appendChild(manualElement("th", "", label));
|
|
});
|
|
head.appendChild(headRow);
|
|
table.appendChild(head);
|
|
const body = document.createElement("tbody");
|
|
const fields = ["leftName", "leftAmount", "rightName", "rightAmount"];
|
|
manual.netRows.forEach(function (row, index) {
|
|
const tr = document.createElement("tr");
|
|
tr.appendChild(manualElement("th", "", String(index + 1)));
|
|
fields.forEach(function (field) {
|
|
const td = document.createElement("td");
|
|
const input = document.createElement("input");
|
|
input.type = "text";
|
|
input.maxLength = 256;
|
|
input.value = row[field] || "";
|
|
input.disabled = manual.isAvailable !== true || manual.writesQuarantined;
|
|
input.addEventListener("change", function () {
|
|
setTransientControlsBusy(manualListDialog, true);
|
|
const sent = send("set-manual-net-sell-cell", {
|
|
rowId: row.rowId,
|
|
field: field,
|
|
value: input.value
|
|
});
|
|
if (!sent) setTransientControlsBusy(manualListDialog, false);
|
|
});
|
|
td.appendChild(input);
|
|
tr.appendChild(td);
|
|
});
|
|
body.appendChild(tr);
|
|
});
|
|
table.appendChild(body);
|
|
wrap.appendChild(table);
|
|
fragment.appendChild(wrap);
|
|
} else {
|
|
const search = manualElement("div", "manual-list-search");
|
|
const input = document.createElement("input");
|
|
input.type = "search";
|
|
input.id = "manual-vi-search";
|
|
input.maxLength = 100;
|
|
input.placeholder = "종목명 검색";
|
|
syncSearchDraft(input, manual.searchQuery, "manual-list:vi");
|
|
const searchButton = manualElement("button", "", "검색");
|
|
searchButton.type = "button";
|
|
searchButton.disabled = manual.isAvailable !== true;
|
|
function submitSearch() {
|
|
send("search-manual-vi", { query: input.value });
|
|
}
|
|
searchButton.addEventListener("click", submitSearch);
|
|
input.addEventListener("keydown", function (event) {
|
|
if (event.key === "Enter" && !isImeComposing(event)) {
|
|
event.preventDefault();
|
|
submitSearch();
|
|
}
|
|
});
|
|
search.append(input, searchButton);
|
|
fragment.appendChild(search);
|
|
|
|
const results = manualElement("div", "manual-list-search-results");
|
|
results.setAttribute("aria-label", "VI 종목 검색 결과");
|
|
let selectedViResult = null;
|
|
manual.searchResults.forEach(function (row) {
|
|
const isSelected = manual.selectedSearchResultId === row.resultId;
|
|
const button = manualElement(
|
|
"button",
|
|
isSelected ? "selected" : "",
|
|
row.displayName + " · " + row.marketText);
|
|
button.type = "button";
|
|
button.id = "manual-vi-result-" + row.resultId;
|
|
button.dataset.manualViResultId = row.resultId;
|
|
button.disabled = false;
|
|
button.setAttribute("aria-selected", isSelected ? "true" : "false");
|
|
if (isSelected) selectedViResult = button;
|
|
button.addEventListener("click", function () {
|
|
clearDelayedClick(manualViResultClickTimer);
|
|
const pending = beginManualViResultPendingSelection(button, row.resultId);
|
|
manualViResultClickTimer = deferLegacySingleClick(function () {
|
|
manualViResultClickTimer = null;
|
|
if (!pending || manualViResultPendingSelection !== pending) return;
|
|
if (send("select-manual-vi-result", { resultId: row.resultId })) {
|
|
pending.sent = true;
|
|
} else {
|
|
clearManualViResultPendingSelection(true);
|
|
}
|
|
});
|
|
});
|
|
button.addEventListener("dblclick", function (event) {
|
|
event.preventDefault();
|
|
clearDelayedClick(manualViResultClickTimer);
|
|
manualViResultClickTimer = null;
|
|
const pending = manualViResultPendingSelection &&
|
|
manualViResultPendingSelection.key === row.resultId
|
|
? manualViResultPendingSelection
|
|
: beginManualViResultPendingSelection(button, row.resultId);
|
|
if (send("add-manual-vi-result", { resultId: row.resultId })) {
|
|
if (pending) pending.sent = true;
|
|
} else {
|
|
clearManualViResultPendingSelection(true);
|
|
}
|
|
});
|
|
results.appendChild(button);
|
|
});
|
|
configureRovingList(
|
|
results,
|
|
Array.from(results.querySelectorAll("button[data-manual-vi-result-id]")),
|
|
selectedViResult);
|
|
fragment.appendChild(results);
|
|
|
|
if (manual.searchIsTruncated === true) {
|
|
const truncationWarning = manualElement(
|
|
"div",
|
|
"manual-list-status warning",
|
|
"검색 결과 일부만 표시 중입니다. 검색어를 더 구체적으로 입력해 주세요.");
|
|
truncationWarning.setAttribute("role", "status");
|
|
fragment.appendChild(truncationWarning);
|
|
}
|
|
|
|
const summary = manualElement(
|
|
"div",
|
|
"manual-list-status",
|
|
String(manual.viItems.length) + "/100종목 · " +
|
|
String(manual.viPageCount) + "페이지");
|
|
fragment.appendChild(summary);
|
|
const wrap = manualElement("div", "manual-list-table-wrap");
|
|
const table = manualElement("table", "manual-list-table");
|
|
const body = document.createElement("tbody");
|
|
manual.viItems.forEach(function (row, index) {
|
|
const tr = document.createElement("tr");
|
|
tr.append(
|
|
manualElement("td", "", String(row.index)),
|
|
manualElement("td", "", row.name));
|
|
const controls = manualElement("td", "manual-list-row-actions");
|
|
[["↑", "up"], ["↓", "down"]].forEach(function (move) {
|
|
const button = manualElement("button", "", move[0]);
|
|
button.type = "button";
|
|
button.disabled = move[1] === "up"
|
|
? index === 0
|
|
: index === manual.viItems.length - 1;
|
|
button.addEventListener("click", function () {
|
|
send("move-manual-vi-item", { itemId: row.itemId, direction: move[1] });
|
|
});
|
|
controls.appendChild(button);
|
|
});
|
|
const remove = manualElement("button", "", "삭제");
|
|
remove.type = "button";
|
|
remove.disabled = false;
|
|
remove.addEventListener("click", function () {
|
|
send("delete-manual-vi-item", { itemId: row.itemId });
|
|
});
|
|
controls.appendChild(remove);
|
|
tr.appendChild(controls);
|
|
body.appendChild(tr);
|
|
});
|
|
table.appendChild(body);
|
|
wrap.appendChild(table);
|
|
fragment.appendChild(wrap);
|
|
}
|
|
|
|
if (manual.statusMessage) {
|
|
fragment.appendChild(manualElement(
|
|
"div",
|
|
"manual-list-status" +
|
|
(manual.statusKind === "error" || manual.writesQuarantined ? " error" : ""),
|
|
manual.statusMessage));
|
|
}
|
|
const actions = manualElement("div", "manual-list-actions-row");
|
|
const importButton = manualElement("button", "", "원본 1회 가져오기");
|
|
importButton.type = "button";
|
|
importButton.disabled = manual.isAvailable !== true || manual.writesQuarantined;
|
|
importButton.addEventListener("click", function () {
|
|
if (window.confirm("원본 파일을 현재 빈 저장소로 한 번만 가져옵니다. 기존 파일은 덮어쓰지 않습니다.")) {
|
|
send("import-manual-lists", {});
|
|
}
|
|
});
|
|
const refresh = manualElement("button", "", "재조회");
|
|
refresh.type = "button";
|
|
refresh.disabled = manual.isAvailable !== true;
|
|
refresh.addEventListener("click", function () { send("refresh-manual-list", {}); });
|
|
if (manual.screen === "vi") {
|
|
const clear = manualElement("button", "", "전체 삭제");
|
|
clear.type = "button";
|
|
clear.addEventListener("click", function () {
|
|
send("delete-all-manual-vi-items", {});
|
|
});
|
|
actions.appendChild(clear);
|
|
}
|
|
const confirm = manualElement("button", "primary", "확인");
|
|
confirm.type = "button";
|
|
if (fixedBatch) {
|
|
confirm.textContent = fixedBatch.manualStepNumber < fixedBatch.manualStepCount
|
|
? "확인 후 다음"
|
|
: "확인 후 섹션 추가";
|
|
}
|
|
confirm.disabled = manual.canSave !== true;
|
|
confirm.addEventListener("click", function () {
|
|
send("confirm-manual-list", {});
|
|
});
|
|
actions.append(importButton, refresh, confirm);
|
|
fragment.appendChild(actions);
|
|
manualListWorkspace.replaceChildren(fragment);
|
|
manualListClose.disabled = false;
|
|
manualListCancel.disabled = manualListClose.disabled;
|
|
setTransientControlsBusy(manualListDialog, busy);
|
|
manualListRenderStateKey = renderStateKey;
|
|
if (!busy && manual.screen === "vi" &&
|
|
restoreSearchDraftFocus(manualListWorkspace, manualListSearchFocus)) {
|
|
manualListSearchFocus = null;
|
|
}
|
|
}
|
|
|
|
function openNamedPlaylist(mode) {
|
|
if (operatorMutationLocked ||
|
|
(mode === "load" && playlistStructureMutationLocked)) return false;
|
|
namedPlaylistSelection.cancel();
|
|
namedPlaylistMode = mode;
|
|
namedPlaylistTitle.textContent = mode === "save"
|
|
? "DB 재생목록 저장" : "DB 재생목록 불러오기";
|
|
namedPlaylistConfirmButton.textContent = mode === "save" ? "현재 목록 저장" : "불러오기";
|
|
namedPlaylistNewTitle.value = "";
|
|
if (namedPlaylistModal.hidden) rememberModalOpener(namedPlaylistDialog);
|
|
namedPlaylistModal.hidden = false;
|
|
if (namedPlaylistState) {
|
|
renderNamedPlaylist({ namedPlaylist: namedPlaylistState, isBusy: uiBusy });
|
|
}
|
|
syncModalFocus();
|
|
beginNamedPlaylistBusyCycle("refresh-named-playlists", {});
|
|
return true;
|
|
}
|
|
|
|
function closeNamedPlaylist() {
|
|
if (isNamedPlaylistModalLocked() || namedPlaylistSelection.isPending()) return false;
|
|
namedPlaylistMode = null;
|
|
namedPlaylistModal.hidden = true;
|
|
syncModalFocus();
|
|
return true;
|
|
}
|
|
|
|
function isNamedPlaylistModalLocked() {
|
|
return uiBusy || pendingNamedPlaylistCommand !== null ||
|
|
namedPlaylistConfirmation !== null ||
|
|
!!(namedPlaylistState && namedPlaylistState.isWriteInProgress === true);
|
|
}
|
|
|
|
function isNamedPlaylistActionLocked() {
|
|
return isNamedPlaylistModalLocked() || operatorMutationLocked ||
|
|
namedPlaylistSelection.isPending();
|
|
}
|
|
|
|
function isNamedPlaylistDefinitionLocked() {
|
|
return operatorMutationLocked || pendingNamedPlaylistCommand !== null ||
|
|
namedPlaylistConfirmation !== null ||
|
|
!!(namedPlaylistState && namedPlaylistState.isWriteInProgress === true) ||
|
|
(uiBusy && !namedPlaylistSelection.isPending());
|
|
}
|
|
|
|
function markNamedPlaylistVisualSelection(definitionId) {
|
|
namedPlaylistDefinitions
|
|
.querySelectorAll("button[data-named-playlist-definition-id]")
|
|
.forEach(function (button) {
|
|
const selected = button.dataset.namedPlaylistDefinitionId === definitionId;
|
|
button.setAttribute("aria-selected", selected ? "true" : "false");
|
|
button.classList.toggle("selected", selected);
|
|
});
|
|
}
|
|
|
|
function lockNamedPlaylistSelectionActions() {
|
|
namedPlaylistClose.disabled = true;
|
|
namedPlaylistCancelButton.disabled = true;
|
|
namedPlaylistNewTitle.disabled = true;
|
|
namedPlaylistCreateButton.disabled = true;
|
|
namedPlaylistDeleteButton.disabled = true;
|
|
namedPlaylistConfirmButton.disabled = true;
|
|
}
|
|
|
|
function beginNamedPlaylistSelection(definitionId) {
|
|
if (namedPlaylistSelection.isPending()) {
|
|
const pending = namedPlaylistSelection.begin(
|
|
definitionId,
|
|
namedPlaylistState && namedPlaylistState.selectedDefinitionId);
|
|
if (pending.accepted) markNamedPlaylistVisualSelection(definitionId);
|
|
return pending.accepted;
|
|
}
|
|
if (isNamedPlaylistModalLocked() || operatorMutationLocked) return false;
|
|
|
|
const requestId = "named-ui-" + String(nextNamedPlaylistRequestId++);
|
|
const started = namedPlaylistSelection.begin(
|
|
definitionId,
|
|
namedPlaylistState && namedPlaylistState.selectedDefinitionId,
|
|
requestId,
|
|
lastCommandSequence + 1);
|
|
if (!started.accepted) return false;
|
|
markNamedPlaylistVisualSelection(definitionId);
|
|
if (!started.shouldSend) return true;
|
|
|
|
lockNamedPlaylistSelectionActions();
|
|
if (!send("select-named-playlist", {
|
|
definitionId: definitionId,
|
|
requestId: started.requestId
|
|
})) {
|
|
namedPlaylistSelection.cancel();
|
|
restoreNamedPlaylistAfterConfirmation();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function runNamedPlaylistDoubleClick(definitionId, mode) {
|
|
if (mode === "save") {
|
|
return openNamedPlaylistConfirmation({
|
|
kind: "save",
|
|
definitionId: definitionId,
|
|
message: "저장하겠습니까?",
|
|
showSavedAlert: false,
|
|
closeOnNo: true
|
|
});
|
|
}
|
|
return beginNamedPlaylistCommand("load-named-playlist-by-id", definitionId, false);
|
|
}
|
|
|
|
function lockNamedPlaylistControls() {
|
|
namedPlaylistModal.querySelectorAll("button, input").forEach(function (control) {
|
|
control.disabled = true;
|
|
});
|
|
}
|
|
|
|
function beginNamedPlaylistBusyCycle(type, payload) {
|
|
if (isNamedPlaylistActionLocked()) return false;
|
|
const requestId = "named-ui-" + String(nextNamedPlaylistRequestId++);
|
|
pendingNamedPlaylistCommand = {
|
|
command: type,
|
|
targetId: requestId,
|
|
minimumSequence: lastCommandSequence + 1,
|
|
closeOnSuccess: false,
|
|
showSavedAlert: false
|
|
};
|
|
const correlatedPayload = Object.assign({}, payload || {}, { requestId: requestId });
|
|
if (!send(type, correlatedPayload)) {
|
|
pendingNamedPlaylistCommand = null;
|
|
return false;
|
|
}
|
|
lockNamedPlaylistControls();
|
|
return true;
|
|
}
|
|
|
|
function beginNamedPlaylistCommand(command, definitionId, showSavedAlert) {
|
|
if (isNamedPlaylistActionLocked() || !definitionId) return false;
|
|
const payload = { definitionId: definitionId };
|
|
if (command === "save-current-named-playlist-to") {
|
|
payload.showSavedAlert = showSavedAlert === true;
|
|
}
|
|
pendingNamedPlaylistCommand = {
|
|
command: command,
|
|
targetId: definitionId,
|
|
definitionId: definitionId,
|
|
minimumSequence: lastCommandSequence + 1,
|
|
closeOnSuccess: true
|
|
};
|
|
if (!send(command, payload)) {
|
|
pendingNamedPlaylistCommand = null;
|
|
return false;
|
|
}
|
|
lockNamedPlaylistControls();
|
|
return true;
|
|
}
|
|
|
|
function isMatchingNamedPlaylistReceipt(busy, pending, commandResult) {
|
|
const exact = !!pending && !!commandResult &&
|
|
Number.isSafeInteger(commandResult.sequence) &&
|
|
commandResult.sequence >= pending.minimumSequence &&
|
|
commandResult.command === pending.command &&
|
|
commandResult.targetId === pending.targetId;
|
|
return exact && (commandResult.succeeded === false ||
|
|
(busy !== true && commandResult.succeeded === true));
|
|
}
|
|
|
|
function openNamedPlaylistConfirmation(options) {
|
|
if (!options || isNamedPlaylistActionLocked()) return false;
|
|
namedPlaylistConfirmation = options;
|
|
namedPlaylistConfirmationMessage.textContent = options.message;
|
|
if (namedPlaylistConfirmationModal.hidden) {
|
|
rememberModalOpener(namedPlaylistConfirmationDialog);
|
|
}
|
|
namedPlaylistConfirmationModal.hidden = false;
|
|
lockNamedPlaylistControls();
|
|
// The confirmation can open immediately after a correlated DB refresh or
|
|
// selection receipt. Re-establish its own controls here so a transient
|
|
// busy render cannot leave the affirmative action stale-disabled.
|
|
namedPlaylistConfirmationYes.disabled = false;
|
|
namedPlaylistConfirmationNo.disabled = false;
|
|
syncModalFocus();
|
|
return true;
|
|
}
|
|
|
|
function restoreNamedPlaylistAfterConfirmation() {
|
|
if (!namedPlaylistState || namedPlaylistModal.hidden) return;
|
|
renderNamedPlaylist({
|
|
namedPlaylist: namedPlaylistState,
|
|
isBusy: uiBusy,
|
|
commandResult: null,
|
|
statusKind: null
|
|
});
|
|
}
|
|
|
|
function resolveNamedPlaylistConfirmation(confirmed) {
|
|
const confirmation = namedPlaylistConfirmation;
|
|
if (!confirmation) return false;
|
|
namedPlaylistConfirmation = null;
|
|
namedPlaylistConfirmationModal.hidden = true;
|
|
syncModalFocus();
|
|
|
|
if (!confirmed) {
|
|
if (confirmation.closeOnNo === true) closeNamedPlaylist();
|
|
else restoreNamedPlaylistAfterConfirmation();
|
|
return true;
|
|
}
|
|
|
|
const started = confirmation.kind === "delete"
|
|
? beginNamedPlaylistBusyCycle("delete-selected-named-playlist", {})
|
|
: beginNamedPlaylistCommand(
|
|
"save-current-named-playlist-to",
|
|
confirmation.definitionId,
|
|
confirmation.showSavedAlert === true);
|
|
if (!started) restoreNamedPlaylistAfterConfirmation();
|
|
return started;
|
|
}
|
|
|
|
function renderNamedPlaylist(state) {
|
|
const named = state.namedPlaylist;
|
|
namedPlaylistState = named || null;
|
|
const busy = state.isBusy === true;
|
|
const commandResult = state.commandResult;
|
|
const selectionResult = namedPlaylistSelection.reconcile(
|
|
commandResult,
|
|
named && named.selectedDefinitionId);
|
|
const deferredSelectionAction = selectionResult.deferredAction;
|
|
if (commandResult && Number.isSafeInteger(commandResult.sequence)) {
|
|
lastCommandSequence = Math.max(lastCommandSequence, commandResult.sequence);
|
|
}
|
|
if (isMatchingNamedPlaylistReceipt(
|
|
busy,
|
|
pendingNamedPlaylistCommand,
|
|
commandResult
|
|
)) {
|
|
const completed = pendingNamedPlaylistCommand;
|
|
const succeeded = commandResult.succeeded === true;
|
|
pendingNamedPlaylistCommand = null;
|
|
if (succeeded && completed.closeOnSuccess) {
|
|
closeNamedPlaylist();
|
|
}
|
|
}
|
|
dbLoadButton.disabled = playlistStructureMutationLocked || busy || !named;
|
|
dbSaveButton.disabled = operatorMutationLocked || busy || !named ||
|
|
named.canMutate !== true;
|
|
if (!named || namedPlaylistModal.hidden) return;
|
|
|
|
const modalLocked = isNamedPlaylistActionLocked();
|
|
const definitionLocked = isNamedPlaylistDefinitionLocked();
|
|
const existingDefinitions = new Map();
|
|
namedPlaylistDefinitions
|
|
.querySelectorAll("button[data-named-playlist-definition-id]")
|
|
.forEach(function (button) {
|
|
existingDefinitions.set(button.dataset.namedPlaylistDefinitionId, button);
|
|
});
|
|
const fragment = document.createDocumentFragment();
|
|
let selectedDefinition = null;
|
|
named.definitions.forEach(function (definition) {
|
|
const button = existingDefinitions.get(definition.definitionId) ||
|
|
document.createElement("button");
|
|
button.type = "button";
|
|
button.dataset.namedPlaylistDefinitionId = definition.definitionId;
|
|
button.setAttribute("role", "option");
|
|
button.setAttribute("aria-selected", definition.isSelected ? "true" : "false");
|
|
button.className = definition.isSelected ? "selected" : "";
|
|
button.disabled = definitionLocked;
|
|
button.textContent = definition.title;
|
|
if (definition.isSelected) selectedDefinition = button;
|
|
fragment.appendChild(button);
|
|
});
|
|
namedPlaylistDefinitions.replaceChildren(fragment);
|
|
configureRovingList(
|
|
namedPlaylistDefinitions,
|
|
Array.from(namedPlaylistDefinitions.querySelectorAll(
|
|
"button[data-named-playlist-definition-id]")),
|
|
selectedDefinition);
|
|
|
|
const selectedDescription = named.selectedTitle
|
|
? "선택: " + named.selectedTitle + " · " + String(named.rowCount) + "행"
|
|
: "DB 재생목록을 선택하세요.";
|
|
const quarantine = named.isWriteQuarantined === true
|
|
? " 결과 불명확 상태로 이번 실행의 추가 쓰기가 차단되었습니다." : "";
|
|
const truncated = named.isListTruncated === true
|
|
? " 목록 일부만 표시 중입니다." : "";
|
|
namedPlaylistStatus.textContent = (named.statusMessage || selectedDescription) +
|
|
quarantine + truncated;
|
|
namedPlaylistStatus.classList.toggle("error", named.isWriteQuarantined === true ||
|
|
named.statusKind === "error");
|
|
|
|
const closeLocked = isNamedPlaylistModalLocked() ||
|
|
namedPlaylistSelection.isPending();
|
|
namedPlaylistClose.disabled = closeLocked;
|
|
namedPlaylistCancelButton.disabled = closeLocked;
|
|
namedPlaylistNewTitle.disabled = modalLocked;
|
|
namedPlaylistCreateButton.disabled = modalLocked || named.canCreate !== true ||
|
|
!isCanonicalNamedPlaylistTitle(namedPlaylistNewTitle.value);
|
|
namedPlaylistDeleteButton.disabled = modalLocked || named.canDelete !== true;
|
|
namedPlaylistConfirmButton.disabled = modalLocked ||
|
|
(namedPlaylistMode === "save" ? named.canSave !== true : named.canLoad !== true);
|
|
// A PROGRAM auto-refresh can begin between pointer-down and pointer-up.
|
|
// Keep the already-open confirmation clickable through unrelated busy
|
|
// state; the native ordered gate serializes the request and performs the
|
|
// authoritative mutation-safety check before any database write.
|
|
namedPlaylistConfirmationYes.disabled = pendingNamedPlaylistCommand !== null ||
|
|
!!(namedPlaylistState && namedPlaylistState.isWriteInProgress === true);
|
|
if (deferredSelectionAction) {
|
|
runNamedPlaylistDoubleClick(
|
|
deferredSelectionAction.definitionId,
|
|
deferredSelectionAction.mode);
|
|
}
|
|
}
|
|
|
|
function clearOperatorCatalogDragVisuals() {
|
|
operatorCatalogDraftRows.querySelectorAll(".dragging, .drag-before, .drag-after")
|
|
.forEach(function (row) {
|
|
row.classList.remove("dragging", "drag-before", "drag-after");
|
|
});
|
|
}
|
|
|
|
function onOperatorCatalogPointerDown(event) {
|
|
if (event.button !== 0) return;
|
|
const rowId = event.currentTarget.dataset.operatorCatalogRowId;
|
|
operatorCatalogDragBlockedRowId = event.target.closest("button, input")
|
|
? rowId
|
|
: null;
|
|
}
|
|
|
|
function onOperatorCatalogPointerEnd(event) {
|
|
if (operatorCatalogDragBlockedRowId ===
|
|
event.currentTarget.dataset.operatorCatalogRowId) {
|
|
operatorCatalogDragBlockedRowId = null;
|
|
}
|
|
}
|
|
|
|
function onOperatorCatalogDragStart(event) {
|
|
const row = event.currentTarget;
|
|
const sourceRowId = row.dataset.operatorCatalogRowId;
|
|
if (operatorCatalogMutationLocked || !sourceRowId || !event.dataTransfer ||
|
|
operatorCatalogDragBlockedRowId === sourceRowId ||
|
|
event.target.closest("button, input")) {
|
|
operatorCatalogDragBlockedRowId = null;
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
operatorCatalogDragBlockedRowId = null;
|
|
draggedOperatorCatalogRowId = sourceRowId;
|
|
event.dataTransfer.effectAllowed = "move";
|
|
event.dataTransfer.setData(operatorCatalogDragMime, sourceRowId);
|
|
row.classList.add("dragging");
|
|
}
|
|
|
|
function onOperatorCatalogDragOver(event) {
|
|
const target = event.currentTarget;
|
|
if (operatorCatalogMutationLocked || !draggedOperatorCatalogRowId ||
|
|
target.dataset.operatorCatalogRowId === draggedOperatorCatalogRowId) return;
|
|
|
|
event.preventDefault();
|
|
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
|
const position = dragDropPosition(event, target);
|
|
target.classList.toggle("drag-before", position === "before");
|
|
target.classList.toggle("drag-after", position === "after");
|
|
}
|
|
|
|
function onOperatorCatalogDragLeave(event) {
|
|
event.currentTarget.classList.remove("drag-before", "drag-after");
|
|
}
|
|
|
|
function onOperatorCatalogDrop(event) {
|
|
const target = event.currentTarget;
|
|
const sourceRowId = draggedOperatorCatalogRowId;
|
|
const targetRowId = target.dataset.operatorCatalogRowId;
|
|
if (operatorCatalogMutationLocked || !sourceRowId || !targetRowId ||
|
|
sourceRowId === targetRowId || !event.dataTransfer ||
|
|
event.dataTransfer.getData(operatorCatalogDragMime) !== sourceRowId) {
|
|
draggedOperatorCatalogRowId = null;
|
|
clearOperatorCatalogDragVisuals();
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
const position = dragDropPosition(event, target);
|
|
draggedOperatorCatalogRowId = null;
|
|
clearOperatorCatalogDragVisuals();
|
|
send("reorder-operator-catalog-draft-row", {
|
|
sourceRowId: sourceRowId,
|
|
targetRowId: targetRowId,
|
|
position: position
|
|
});
|
|
}
|
|
|
|
function onOperatorCatalogDragEnd() {
|
|
draggedOperatorCatalogRowId = null;
|
|
operatorCatalogDragBlockedRowId = null;
|
|
clearOperatorCatalogDragVisuals();
|
|
}
|
|
|
|
function reconcileOperatorCatalogStockResults(rows) {
|
|
const existing = new Map();
|
|
Array.from(operatorCatalogStockResults.children).forEach(function (button) {
|
|
existing.set(button.dataset.operatorCatalogStockResultId, button);
|
|
});
|
|
|
|
(rows || []).forEach(function (row, position) {
|
|
let button = existing.get(row.resultId);
|
|
if (!button) {
|
|
button = document.createElement("button");
|
|
button.type = "button";
|
|
button.dataset.operatorCatalogStockResultId = row.resultId;
|
|
button.title = "더블클릭하면 구성 종목에 추가합니다.";
|
|
button.setAttribute("role", "option");
|
|
const name = document.createElement("span");
|
|
name.className = "operator-catalog-stock-result-name";
|
|
const market = document.createElement("small");
|
|
market.className = "operator-catalog-stock-result-market";
|
|
button.append(name, market);
|
|
}
|
|
|
|
const buttonAtPosition = operatorCatalogStockResults.children.item(position);
|
|
if (buttonAtPosition !== button) {
|
|
operatorCatalogStockResults.insertBefore(button, buttonAtPosition);
|
|
}
|
|
existing.delete(row.resultId);
|
|
|
|
button.className = row.isSelected ? "selected" : "";
|
|
button.disabled = row.isCompatible !== true;
|
|
button.setAttribute("aria-selected", row.isSelected ? "true" : "false");
|
|
button.querySelector(".operator-catalog-stock-result-name").textContent = row.displayName;
|
|
button.querySelector(".operator-catalog-stock-result-market").textContent = row.market;
|
|
});
|
|
|
|
existing.forEach(function (button) { button.remove(); });
|
|
const stockRows = Array.from(operatorCatalogStockResults.querySelectorAll(
|
|
"button[data-operator-catalog-stock-result-id]"));
|
|
configureRovingList(
|
|
operatorCatalogStockResults,
|
|
stockRows,
|
|
stockRows.find(function (button) {
|
|
return button.getAttribute("aria-selected") === "true";
|
|
}) || null);
|
|
}
|
|
|
|
function renderOperatorCatalog(state) {
|
|
const catalog = state.operatorCatalog;
|
|
const busy = state.isBusy === true;
|
|
operatorCatalogMutationLocked = isOperatorMutationLocked(state);
|
|
operatorCatalogState = catalog || null;
|
|
if (!catalog || catalog.isOpen !== true) {
|
|
setTransientControlsBusy(operatorCatalogDialog, false);
|
|
draggedOperatorCatalogRowId = null;
|
|
operatorCatalogDragBlockedRowId = null;
|
|
clearOperatorCatalogDragVisuals();
|
|
operatorCatalogModal.hidden = true;
|
|
operatorCatalogDraftKey = "";
|
|
operatorCatalogNameDraft = "";
|
|
forgetSearchDraft(
|
|
"operator-catalog-query",
|
|
"operator-catalog-stock-query");
|
|
operatorCatalogSearchFocus = null;
|
|
operatorCatalogNameStatus.textContent = "";
|
|
operatorCatalogNameStatus.className = "";
|
|
operatorCatalogRenderStateKey = "";
|
|
return;
|
|
}
|
|
|
|
const capturedSearchFocus = captureSearchDraftFocus(operatorCatalogDialog);
|
|
if (capturedSearchFocus) operatorCatalogSearchFocus = capturedSearchFocus;
|
|
if (operatorCatalogMutationLocked) {
|
|
draggedOperatorCatalogRowId = null;
|
|
operatorCatalogDragBlockedRowId = null;
|
|
clearOperatorCatalogDragVisuals();
|
|
}
|
|
|
|
const renderStateKey = JSON.stringify(catalog);
|
|
if (!operatorCatalogModal.hidden && renderStateKey === operatorCatalogRenderStateKey) {
|
|
setTransientControlsBusy(operatorCatalogDialog, busy);
|
|
operatorCatalogDraftRows.querySelectorAll("[data-operator-catalog-row-id]")
|
|
.forEach(function (row) {
|
|
row.draggable = !operatorCatalogMutationLocked;
|
|
row.setAttribute(
|
|
"aria-disabled",
|
|
operatorCatalogMutationLocked ? "true" : "false");
|
|
});
|
|
if (!busy &&
|
|
restoreSearchDraftFocus(operatorCatalogDialog, operatorCatalogSearchFocus)) {
|
|
operatorCatalogSearchFocus = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
setTransientControlsBusy(operatorCatalogDialog, false);
|
|
if (operatorCatalogModal.hidden) rememberModalOpener(operatorCatalogDialog);
|
|
operatorCatalogModal.hidden = false;
|
|
const isTheme = catalog.entity === "theme";
|
|
const editing = catalog.mode === "create" || catalog.mode === "edit";
|
|
operatorCatalogTitle.textContent = isTheme
|
|
? "테마추가"
|
|
: catalog.mode === "create" ? "전문가 추가" : "전문가 추천 편집";
|
|
operatorCatalogThemeTab.classList.toggle("selected", isTheme);
|
|
operatorCatalogExpertTab.classList.toggle("selected", !isTheme);
|
|
operatorCatalogThemeTab.disabled = editing;
|
|
operatorCatalogExpertTab.disabled = editing;
|
|
operatorCatalogToolbar.hidden = editing;
|
|
operatorCatalogListPanel.hidden = editing;
|
|
operatorCatalogBody.classList.toggle("editor-only", editing);
|
|
operatorCatalogClose.disabled = false;
|
|
operatorCatalogSearch.disabled = false;
|
|
operatorCatalogSession.disabled = !isTheme;
|
|
operatorCatalogSession.hidden = !isTheme;
|
|
operatorCatalogNewMarket.disabled = !isTheme;
|
|
operatorCatalogNewMarket.hidden = !isTheme;
|
|
operatorCatalogNew.disabled = catalog.canBeginCreate !== true;
|
|
operatorCatalogNew.textContent = isTheme ? "새 ThemeA" : "새 EList";
|
|
syncSearchDraft(
|
|
operatorCatalogQuery,
|
|
catalog.query,
|
|
"operator-catalog:" + catalog.entity);
|
|
operatorCatalogSession.value = catalog.nxtSession === "afterMarket"
|
|
? "afterMarket" : "preMarket";
|
|
operatorCatalogNewMarket.value = catalog.newThemeMarket === "nxt" ? "nxt" : "krx";
|
|
|
|
const resultFragment = document.createDocumentFragment();
|
|
(catalog.results || []).forEach(function (row) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.dataset.operatorCatalogResultId = row.resultId;
|
|
button.className = row.isSelected ? "selected" : "";
|
|
button.disabled = false;
|
|
button.setAttribute("role", "option");
|
|
button.setAttribute("aria-selected", row.isSelected ? "true" : "false");
|
|
const code = document.createElement("strong");
|
|
code.textContent = row.codeLabel;
|
|
const name = document.createElement("span");
|
|
name.textContent = row.displayName;
|
|
const scope = document.createElement("small");
|
|
scope.textContent = row.scope;
|
|
button.append(code, name, scope);
|
|
resultFragment.appendChild(button);
|
|
});
|
|
operatorCatalogResults.replaceChildren(resultFragment);
|
|
const catalogRows = Array.from(operatorCatalogResults.querySelectorAll(
|
|
"button[data-operator-catalog-result-id]"));
|
|
configureRovingList(
|
|
operatorCatalogResults,
|
|
catalogRows,
|
|
catalogRows.find(function (button) {
|
|
return button.getAttribute("aria-selected") === "true";
|
|
}) || null);
|
|
|
|
operatorCatalogEditor.hidden = !editing;
|
|
const canChangeCreateThemeMarket = isTheme && catalog.mode === "create";
|
|
operatorCatalogEditorMarketField.hidden = !canChangeCreateThemeMarket;
|
|
operatorCatalogEditorMarket.disabled = !canChangeCreateThemeMarket;
|
|
operatorCatalogEditorMarket.value = catalog.newThemeMarket === "nxt" ? "nxt" : "krx";
|
|
const draftKey = catalog.entity + "|" + catalog.mode + "|" +
|
|
(catalog.selectedResultId || "new") + "|" + (catalog.codeLabel || "");
|
|
if (draftKey !== operatorCatalogDraftKey) {
|
|
operatorCatalogDraftKey = draftKey;
|
|
operatorCatalogNameDraft = catalog.editorName || "";
|
|
}
|
|
if (document.activeElement !== operatorCatalogName) {
|
|
operatorCatalogName.value = operatorCatalogNameDraft;
|
|
}
|
|
operatorCatalogCode.textContent = catalog.codeLabel
|
|
? "코드 " + catalog.codeLabel : "";
|
|
operatorCatalogNameLabel.textContent = isTheme ? "테마명" : "전문가명";
|
|
operatorCatalogName.placeholder = isTheme ? "ThemeA 이름" : "전문가 이름";
|
|
operatorCatalogName.disabled = !editing ||
|
|
(!isTheme && catalog.mode === "edit");
|
|
const currentThemeName = operatorCatalogNameDraft.trim();
|
|
const checkedCurrentThemeName = isTheme && currentThemeName.length > 0 &&
|
|
catalog.checkedThemeName === currentThemeName;
|
|
const themeNameAvailability = checkedCurrentThemeName
|
|
? catalog.themeNameAvailability
|
|
: "notChecked";
|
|
operatorCatalogNameCheck.hidden = !isTheme;
|
|
operatorCatalogNameCheck.disabled = !isTheme || !editing ||
|
|
catalog.canCheckThemeName !== true || currentThemeName.length === 0;
|
|
operatorCatalogNameStatus.hidden = !isTheme;
|
|
operatorCatalogNameStatus.className = themeNameAvailability === "available" ||
|
|
themeNameAvailability === "duplicate" || themeNameAvailability === "indeterminate"
|
|
? themeNameAvailability
|
|
: "";
|
|
operatorCatalogNameStatus.textContent = themeNameAvailability === "available"
|
|
? "사용 가능"
|
|
: themeNameAvailability === "duplicate"
|
|
? "이미 사용 중"
|
|
: themeNameAvailability === "indeterminate"
|
|
? "확인 불가"
|
|
: "";
|
|
syncSearchDraft(
|
|
operatorCatalogStockQuery,
|
|
catalog.stockQuery,
|
|
"operator-catalog-stock:" + draftKey);
|
|
operatorCatalogStockQuery.disabled = !editing;
|
|
operatorCatalogStockSearch.disabled = !editing;
|
|
const showCatalogRows = isTheme || catalog.mode === "edit";
|
|
operatorCatalogStockSearchLine.hidden = !showCatalogRows;
|
|
operatorCatalogStockResults.hidden = !showCatalogRows;
|
|
operatorCatalogAddLine.hidden = !showCatalogRows;
|
|
operatorCatalogDraftHeader.hidden = !showCatalogRows;
|
|
operatorCatalogDraftRows.hidden = !showCatalogRows;
|
|
// ThemeA and UC6 both add the selected stock immediately. UC6 then lets
|
|
// the operator fill the buy amount in column 3 of the newly added row.
|
|
operatorCatalogAmountLabel.hidden = true;
|
|
operatorCatalogAmount.hidden = true;
|
|
operatorCatalogAmount.disabled = true;
|
|
operatorCatalogAddStock.disabled = catalog.canAddStock !== true;
|
|
|
|
reconcileOperatorCatalogStockResults(catalog.stockResults);
|
|
|
|
const focusedDraftRow = document.activeElement &&
|
|
document.activeElement.closest("[data-operator-catalog-row-id]");
|
|
const restoreDraftFocus = focusedDraftRow &&
|
|
operatorCatalogDraftRows.contains(focusedDraftRow);
|
|
const draftFragment = document.createDocumentFragment();
|
|
(catalog.draftRows || []).forEach(function (row, index) {
|
|
const line = document.createElement("div");
|
|
const isActive = row.rowId === catalog.activeDraftRowId;
|
|
line.className = "operator-catalog-draft-row" + (isActive ? " selected" : "");
|
|
line.dataset.operatorCatalogRowId = row.rowId;
|
|
line.tabIndex = isActive || (!catalog.activeDraftRowId && index === 0) ? 0 : -1;
|
|
line.setAttribute("role", "row");
|
|
line.setAttribute("aria-selected", isActive ? "true" : "false");
|
|
line.draggable = !operatorCatalogMutationLocked;
|
|
line.setAttribute(
|
|
"aria-disabled",
|
|
operatorCatalogMutationLocked ? "true" : "false");
|
|
line.addEventListener("pointerdown", onOperatorCatalogPointerDown);
|
|
line.addEventListener("pointerup", onOperatorCatalogPointerEnd);
|
|
line.addEventListener("pointercancel", onOperatorCatalogPointerEnd);
|
|
line.addEventListener("dragstart", onOperatorCatalogDragStart);
|
|
line.addEventListener("dragover", onOperatorCatalogDragOver);
|
|
line.addEventListener("dragleave", onOperatorCatalogDragLeave);
|
|
line.addEventListener("drop", onOperatorCatalogDrop);
|
|
line.addEventListener("dragend", onOperatorCatalogDragEnd);
|
|
let selectionSent = false;
|
|
function selectDraftRow() {
|
|
if (!selectionSent && row.rowId !== catalog.activeDraftRowId) {
|
|
selectionSent = true;
|
|
send("select-operator-catalog-draft-row", { rowId: row.rowId });
|
|
}
|
|
}
|
|
line.addEventListener("focus", selectDraftRow);
|
|
line.addEventListener("click", function (event) {
|
|
if (!event.target.closest("button, input")) {
|
|
line.focus({ preventScroll: true });
|
|
selectDraftRow();
|
|
}
|
|
});
|
|
const order = document.createElement("span");
|
|
order.textContent = String(row.position + 1);
|
|
const name = document.createElement("strong");
|
|
name.textContent = row.displayName;
|
|
const detail = document.createElement(isTheme ? "small" : "input");
|
|
if (isTheme) {
|
|
detail.textContent = row.market;
|
|
} else {
|
|
detail.type = "number";
|
|
detail.min = "1";
|
|
detail.step = "1";
|
|
detail.inputMode = "numeric";
|
|
detail.value = row.buyAmount == null ? "" : String(row.buyAmount);
|
|
detail.dataset.operatorCatalogBuyAmountRowId = row.rowId;
|
|
detail.setAttribute("aria-label", row.displayName + " 매수가");
|
|
detail.disabled = false;
|
|
}
|
|
const up = document.createElement("button");
|
|
up.type = "button";
|
|
up.textContent = "Up";
|
|
up.dataset.operatorCatalogMoveRowId = row.rowId;
|
|
up.dataset.operatorCatalogMoveDirection = "up";
|
|
up.disabled = index === 0;
|
|
const down = document.createElement("button");
|
|
down.type = "button";
|
|
down.textContent = "Down";
|
|
down.dataset.operatorCatalogMoveRowId = row.rowId;
|
|
down.dataset.operatorCatalogMoveDirection = "down";
|
|
down.disabled = index + 1 === catalog.draftRows.length;
|
|
const remove = document.createElement("button");
|
|
remove.type = "button";
|
|
remove.textContent = "Delete";
|
|
remove.dataset.operatorCatalogRemoveRowId = row.rowId;
|
|
remove.disabled = false;
|
|
line.append(order, name, detail, up, down, remove);
|
|
draftFragment.appendChild(line);
|
|
});
|
|
operatorCatalogDraftRows.replaceChildren(draftFragment);
|
|
if (restoreDraftFocus && catalog.activeDraftRowId) {
|
|
const activeRow = Array.from(
|
|
operatorCatalogDraftRows.querySelectorAll("[data-operator-catalog-row-id]"))
|
|
.find(function (row) {
|
|
return row.dataset.operatorCatalogRowId === catalog.activeDraftRowId;
|
|
});
|
|
if (activeRow) activeRow.focus({ preventScroll: true });
|
|
}
|
|
|
|
operatorCatalogSave.disabled = catalog.canSave !== true;
|
|
operatorCatalogDeleteAll.hidden = !isTheme || !editing;
|
|
operatorCatalogDeleteAll.disabled = !isTheme || !editing ||
|
|
(catalog.draftRows || []).length === 0;
|
|
operatorCatalogCancel.disabled = !editing;
|
|
const messages = [];
|
|
if (catalog.statusMessage) messages.push(catalog.statusMessage);
|
|
if (catalog.resultsAreTruncated) messages.push("목록 일부만 표시 중입니다.");
|
|
if (catalog.stockResultsAreTruncated) messages.push("종목 검색 결과 일부만 표시 중입니다.");
|
|
if (catalog.writesQuarantined) {
|
|
messages.push("결과 불명확 상태로 현재 프로세스의 추가 DB 쓰기가 차단되었습니다.");
|
|
}
|
|
operatorCatalogStatus.textContent = messages.join(" ");
|
|
operatorCatalogStatus.classList.toggle(
|
|
"error",
|
|
catalog.writesQuarantined === true || catalog.statusKind === "error");
|
|
setTransientControlsBusy(operatorCatalogDialog, busy);
|
|
operatorCatalogRenderStateKey = renderStateKey;
|
|
if (!busy &&
|
|
restoreSearchDraftFocus(operatorCatalogDialog, operatorCatalogSearchFocus)) {
|
|
operatorCatalogSearchFocus = null;
|
|
}
|
|
}
|
|
|
|
function renderDialog(state) {
|
|
if (state.dialog && state.dialog.message) {
|
|
legacyDialogIsConfirmation = state.dialog.isConfirmation === true;
|
|
dialogCaption.textContent = state.dialog.caption || "MmoneyCoder";
|
|
dialogMessage.textContent = state.dialog.message;
|
|
dialogOk.textContent = legacyDialogIsConfirmation ? "예" : "확인";
|
|
// A one-button result alert is safe to acknowledge while an unrelated
|
|
// refresh is finishing. Disabling it between pointer-down and pointer-up
|
|
// swallowed real operator clicks; native dispatch now queues one dismissal.
|
|
dialogOk.disabled = legacyDialogIsConfirmation && state.isBusy === true;
|
|
dialogNo.hidden = !legacyDialogIsConfirmation;
|
|
dialogNo.disabled = state.isBusy === true;
|
|
if (dialog.hidden) rememberModalOpener(legacyAlertDialog);
|
|
dialog.hidden = false;
|
|
} else {
|
|
legacyDialogIsConfirmation = false;
|
|
dialogOk.disabled = false;
|
|
dialogNo.hidden = true;
|
|
dialogNo.disabled = false;
|
|
dialog.hidden = true;
|
|
}
|
|
}
|
|
|
|
function updateCutDragSize(state) {
|
|
const metrics = state && state.interactionMetrics;
|
|
if (!metrics || metrics.coordinateSpace !== "host-dip" ||
|
|
!Number.isInteger(metrics.cutDragWidthDips) ||
|
|
!Number.isInteger(metrics.cutDragHeightDips) ||
|
|
metrics.cutDragWidthDips <= 0 || metrics.cutDragHeightDips <= 0 ||
|
|
typeof metrics.hostRasterizationScale !== "number") return;
|
|
const nextDragSize = window.LegacyCutDrag.toCssDragSize({
|
|
width: metrics.cutDragWidthDips,
|
|
height: metrics.cutDragHeightDips
|
|
}, metrics.hostRasterizationScale, window.devicePixelRatio);
|
|
cutDragSize = nextDragSize;
|
|
if (cutDragGesture && !cutDragGesture.started) {
|
|
// The cut-pointer-down reply is also a native state boundary. If a
|
|
// Windows preference change raced the previous rendered state, update
|
|
// the still-pending gesture before its next pointermove decision.
|
|
cutDragGesture.rectangle = window.LegacyCutDrag.createDragRectangle(
|
|
cutDragGesture.down,
|
|
nextDragSize);
|
|
}
|
|
if (playlistDragGesture && !playlistDragGesture.started) {
|
|
playlistDragGesture.rectangle = window.LegacyCutDrag.createDragRectangle(
|
|
playlistDragGesture.down,
|
|
nextDragSize);
|
|
}
|
|
}
|
|
|
|
function operatorSettingsText(value, fallback) {
|
|
if (typeof value === "string" && value.trim()) return value.trim();
|
|
if (value && typeof value === "object") {
|
|
const candidate = value.displayName || value.label || value.status || value.message;
|
|
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function operatorDiagnosticTone(value, label) {
|
|
if (value && typeof value === "object") {
|
|
if (value.isConnected === true || value.isHealthy === true) return "good";
|
|
if (value.isConnected === false || value.isHealthy === false) return "bad";
|
|
}
|
|
const normalized = String(label || "").toLowerCase();
|
|
if (/연결됨|정상|사용 가능|ready|connected|healthy/.test(normalized)) return "good";
|
|
if (/확인 중|재연결|대기|dry run|감지됨|unknown|waiting/.test(normalized)) {
|
|
return "warning";
|
|
}
|
|
if (/끊김|미연결|오류|실패|불명확|disconnected|fault|error|failed/.test(normalized)) {
|
|
return "bad";
|
|
}
|
|
return "neutral";
|
|
}
|
|
|
|
function setOperatorDiagnostic(element, value, fallback) {
|
|
const label = operatorSettingsText(value, fallback);
|
|
if (element.textContent !== label) element.textContent = label;
|
|
const tone = operatorDiagnosticTone(value, label);
|
|
if (tone === "neutral") element.removeAttribute("data-state");
|
|
else element.dataset.state = tone;
|
|
}
|
|
|
|
function operatorPlayoutModeLabel(playout) {
|
|
if (!playout) return "확인 중";
|
|
if (playout.mode === "live") return "LIVE";
|
|
if (playout.mode === "dryRun") return "DRY RUN";
|
|
if (playout.mode === "disabled") return "송출 비활성";
|
|
return operatorSettingsText(playout.mode, "확인 중");
|
|
}
|
|
|
|
function operatorPlayoutConnectionLabel(playout) {
|
|
if (!playout) return "확인 중";
|
|
if (playout.outcomeUnknown === true) return "결과 불명확";
|
|
if (playout.mode === "disabled") return "송출 비활성";
|
|
if (playout.mode === "dryRun") return "DRY RUN 준비됨";
|
|
if (playout.isConnected === true) return "Tornado2 연결됨";
|
|
if (playout.isProcessRunning === true) return "Tornado2 감지됨";
|
|
return "미연결";
|
|
}
|
|
|
|
function renderOperatorFolder(kind, folder, isBusy) {
|
|
const controls = operatorFolderControls[kind];
|
|
const current = folder && typeof folder === "object" ? folder : {};
|
|
const defaultNames = {
|
|
design: "앱 기본값",
|
|
resource: "앱 기본값",
|
|
background: "자동"
|
|
};
|
|
const displayName = current.isCustom === true
|
|
? operatorSettingsText(current.displayName, defaultNames[kind])
|
|
: defaultNames[kind];
|
|
if (controls.name.textContent !== displayName) controls.name.textContent = displayName;
|
|
controls.name.title = displayName;
|
|
|
|
const valid = current.isValid === true;
|
|
const invalid = current.isValid === false;
|
|
const statusLabel = operatorSettingsText(
|
|
current.status,
|
|
valid ? "사용 가능" : (invalid ? "확인 필요" : "확인 중"));
|
|
if (controls.status.textContent !== statusLabel) controls.status.textContent = statusLabel;
|
|
controls.status.classList.toggle("valid", valid);
|
|
controls.status.classList.toggle("invalid", invalid);
|
|
controls.status.classList.toggle("neutral", !valid && !invalid);
|
|
controls.source.textContent = current.isCustom === true ? "사용자 지정" : "앱 기본값";
|
|
controls.select.disabled = isBusy;
|
|
controls.reset.disabled = isBusy || current.isCustom !== true;
|
|
}
|
|
|
|
function normalizeOperatorAppearanceValue(key, value) {
|
|
const definition = operatorAppearanceControls[key];
|
|
return typeof value === "string" && definition.values.includes(value)
|
|
? value
|
|
: definition.fallback;
|
|
}
|
|
|
|
function operatorAppearanceFromSettings(settings) {
|
|
return {
|
|
colorTheme: normalizeOperatorAppearanceValue(
|
|
"colorTheme",
|
|
settings && settings.colorTheme),
|
|
viewMode: normalizeOperatorAppearanceValue("viewMode", settings && settings.viewMode),
|
|
startWorkspace: normalizeOperatorAppearanceValue(
|
|
"startWorkspace",
|
|
settings && settings.startWorkspace)
|
|
};
|
|
}
|
|
|
|
function operatorAppearanceFromControls() {
|
|
return {
|
|
colorTheme: normalizeOperatorAppearanceValue(
|
|
"colorTheme",
|
|
operatorAppearanceControls.colorTheme.element.value),
|
|
viewMode: normalizeOperatorAppearanceValue(
|
|
"viewMode",
|
|
operatorAppearanceControls.viewMode.element.value),
|
|
startWorkspace: normalizeOperatorAppearanceValue(
|
|
"startWorkspace",
|
|
operatorAppearanceControls.startWorkspace.element.value)
|
|
};
|
|
}
|
|
|
|
function syncOperatorAppearanceControls(appearance) {
|
|
Object.keys(operatorAppearanceControls).forEach(function (key) {
|
|
const control = operatorAppearanceControls[key].element;
|
|
const nextValue = appearance[key];
|
|
if (control.value !== nextValue) control.value = nextValue;
|
|
});
|
|
}
|
|
|
|
function applyOperatorAppearance(appearance) {
|
|
[document.documentElement, document.body].forEach(function (element) {
|
|
element.dataset.colorTheme = appearance.colorTheme;
|
|
element.dataset.viewMode = appearance.viewMode;
|
|
element.dataset.startWorkspace = appearance.startWorkspace;
|
|
});
|
|
}
|
|
|
|
function renderOperatorSettings(state) {
|
|
const settings = state && state.operatorSettings;
|
|
const isBusy = !settings || state.isBusy === true;
|
|
const wasBusy = operatorSettingsWasBusy;
|
|
let revisionChanged = false;
|
|
renderOperatorFolder("design", settings && settings.design, isBusy);
|
|
renderOperatorFolder("resource", settings && settings.resource, isBusy);
|
|
renderOperatorFolder("background", settings && settings.background, isBusy);
|
|
|
|
operatorNavigationExpanded.disabled = isBusy;
|
|
Object.keys(operatorAppearanceControls).forEach(function (key) {
|
|
operatorAppearanceControls[key].element.disabled = isBusy;
|
|
});
|
|
if (settings) {
|
|
const revision = settings.revision === undefined ? null : settings.revision;
|
|
const appearance = operatorAppearanceFromSettings(settings);
|
|
revisionChanged = !operatorSettingsRevisionInitialized ||
|
|
revision !== operatorSettingsRenderRevision;
|
|
if (revisionChanged) {
|
|
operatorAppearanceSnapshot = Object.freeze(appearance);
|
|
syncOperatorAppearanceControls(appearance);
|
|
applyOperatorAppearance(appearance);
|
|
const expanded = settings.navigationExpanded !== false;
|
|
operatorNavigationExpanded.checked = expanded;
|
|
if (window.LegacyWorkspaceNavigation &&
|
|
window.LegacyWorkspaceNavigation.expanded() !== expanded) {
|
|
window.LegacyWorkspaceNavigation.setExpanded(expanded);
|
|
}
|
|
operatorSettingsRenderRevision = revision;
|
|
operatorSettingsRevisionInitialized = true;
|
|
}
|
|
if (window.LegacyWorkspaceNavigation &&
|
|
typeof window.LegacyWorkspaceNavigation.applyStartPreference === "function") {
|
|
window.LegacyWorkspaceNavigation.applyStartPreference(appearance.startWorkspace);
|
|
}
|
|
}
|
|
|
|
const message = operatorSettingsText(settings && settings.message, "");
|
|
operatorSettingsMessage.hidden = !message;
|
|
if (operatorSettingsMessage.textContent !== message) {
|
|
operatorSettingsMessage.textContent = message;
|
|
}
|
|
const messageKind = settings && typeof settings.messageKind === "string"
|
|
? settings.messageKind.toLowerCase()
|
|
: "";
|
|
operatorSettingsMessage.className = "settings-message" +
|
|
(["information", "success", "warning", "error"].includes(messageKind)
|
|
? " " + messageKind
|
|
: "");
|
|
operatorSettingsRestart.hidden = !(settings && settings.restartRequired === true);
|
|
|
|
setOperatorDiagnostic(
|
|
operatorSettingsAppVersion,
|
|
settings && settings.appVersion,
|
|
"확인 중");
|
|
const playout = state && state.playout;
|
|
setOperatorDiagnostic(operatorSettingsPlayoutMode, operatorPlayoutModeLabel(playout), "확인 중");
|
|
setOperatorDiagnostic(
|
|
operatorSettingsPlayoutConnection,
|
|
operatorPlayoutConnectionLabel(playout),
|
|
"확인 중");
|
|
setOperatorDiagnostic(
|
|
operatorSettingsOracleState,
|
|
settings && settings.oracleState,
|
|
"확인 중");
|
|
setOperatorDiagnostic(
|
|
operatorSettingsMariaState,
|
|
settings && settings.mariaState,
|
|
"확인 중");
|
|
const pollingSeconds = Number(settings && settings.databasePollingSeconds);
|
|
operatorSettingsDbMonitor.textContent = Number.isFinite(pollingSeconds) && pollingSeconds > 0
|
|
? String(pollingSeconds) + "초마다"
|
|
: "10초마다";
|
|
|
|
operatorSettingsWasBusy = isBusy;
|
|
if (pendingOperatorSettingsFocusId && !isBusy && (wasBusy || revisionChanged)) {
|
|
const focusId = pendingOperatorSettingsFocusId;
|
|
const fallbackFocusId = pendingOperatorSettingsFallbackFocusId;
|
|
pendingOperatorSettingsFocusId = null;
|
|
pendingOperatorSettingsFallbackFocusId = null;
|
|
window.requestAnimationFrame(function () {
|
|
const primary = document.getElementById(focusId);
|
|
const fallback = fallbackFocusId
|
|
? document.getElementById(fallbackFocusId)
|
|
: null;
|
|
const target = primary && !primary.disabled ? primary : fallback;
|
|
const active = document.activeElement;
|
|
if (target && !target.disabled && !target.closest("[inert]") &&
|
|
(active === document.body || active === null)) {
|
|
target.focus({ preventScroll: true });
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function render(state) {
|
|
const isBusy = state.isBusy === true;
|
|
const treeTabId = beginTreeRender(state);
|
|
uiBusy = isBusy;
|
|
operatorMutationLocked = isOperatorMutationLocked(state);
|
|
document.body.classList.toggle("busy", isBusy);
|
|
document.body.setAttribute("aria-busy", isBusy ? "true" : "false");
|
|
searchInput.readOnly = isBusy;
|
|
searchButton.setAttribute("aria-disabled", isBusy ? "true" : "false");
|
|
syncSearchDraft(searchInput, state.searchText);
|
|
updateDoubleClickTime(state);
|
|
updateCutDragSize(state);
|
|
renderStockResults(state);
|
|
renderStockSearchMeta(state);
|
|
renderCuts(state);
|
|
renderTabs(state);
|
|
movingAverage5.checked = state.movingAverages ? state.movingAverages.ma5 : true;
|
|
movingAverage20.checked = state.movingAverages ? state.movingAverages.ma20 : true;
|
|
movingAverage5.disabled = isBusy;
|
|
movingAverage20.disabled = isBusy;
|
|
catalogExpandAll.disabled = treeInteractionLocked;
|
|
renderFixedCatalog(state);
|
|
renderIndustry(state);
|
|
renderOverseas(state);
|
|
renderComparison(state);
|
|
renderTheme(state);
|
|
renderExpert(state);
|
|
renderTradingHalt(state);
|
|
finishTreeRender(treeTabId);
|
|
renderManualFinancial(state);
|
|
renderManualLists(state);
|
|
renderOperatorCatalog(state);
|
|
renderOperatorSettings(state);
|
|
renderPlaylist(state);
|
|
renderNamedPlaylist(state);
|
|
renderDialog(state);
|
|
syncModalFocus();
|
|
status.textContent = isBusy ? "처리 중" : (state.statusMessage || "");
|
|
}
|
|
|
|
Object.keys(operatorFolderControls).forEach(function (kind) {
|
|
const controls = operatorFolderControls[kind];
|
|
controls.select.addEventListener("click", function () {
|
|
pendingOperatorSettingsFocusId = controls.select.id;
|
|
pendingOperatorSettingsFallbackFocusId = null;
|
|
if (!send("choose-operator-folder", { kind: kind })) {
|
|
pendingOperatorSettingsFocusId = null;
|
|
pendingOperatorSettingsFallbackFocusId = null;
|
|
}
|
|
});
|
|
controls.reset.addEventListener("click", function () {
|
|
pendingOperatorSettingsFocusId = controls.reset.id;
|
|
pendingOperatorSettingsFallbackFocusId = controls.select.id;
|
|
if (!send("reset-operator-folder", { kind: kind })) {
|
|
pendingOperatorSettingsFocusId = null;
|
|
pendingOperatorSettingsFallbackFocusId = null;
|
|
}
|
|
});
|
|
});
|
|
|
|
operatorNavigationExpanded.addEventListener("change", function () {
|
|
const expanded = operatorNavigationExpanded.checked === true;
|
|
pendingOperatorSettingsFocusId = operatorNavigationExpanded.id;
|
|
pendingOperatorSettingsFallbackFocusId = null;
|
|
if (!send("set-operator-navigation-expanded", { expanded: expanded })) {
|
|
pendingOperatorSettingsFocusId = null;
|
|
pendingOperatorSettingsFallbackFocusId = null;
|
|
operatorNavigationExpanded.checked = !expanded;
|
|
return;
|
|
}
|
|
if (window.LegacyWorkspaceNavigation &&
|
|
window.LegacyWorkspaceNavigation.expanded() !== expanded) {
|
|
window.LegacyWorkspaceNavigation.setExpanded(expanded);
|
|
}
|
|
});
|
|
|
|
Object.keys(operatorAppearanceControls).forEach(function (key) {
|
|
const control = operatorAppearanceControls[key].element;
|
|
control.addEventListener("change", function () {
|
|
const appearance = operatorAppearanceFromControls();
|
|
pendingOperatorSettingsFocusId = control.id;
|
|
pendingOperatorSettingsFallbackFocusId = null;
|
|
applyOperatorAppearance(appearance);
|
|
if (!send("set-operator-appearance", {
|
|
colorTheme: appearance.colorTheme,
|
|
viewMode: appearance.viewMode,
|
|
startWorkspace: appearance.startWorkspace
|
|
})) {
|
|
pendingOperatorSettingsFocusId = null;
|
|
pendingOperatorSettingsFallbackFocusId = null;
|
|
syncOperatorAppearanceControls(operatorAppearanceSnapshot);
|
|
applyOperatorAppearance(operatorAppearanceSnapshot);
|
|
}
|
|
});
|
|
});
|
|
|
|
document.addEventListener("input", function (event) {
|
|
rememberSearchDraft(event.target);
|
|
});
|
|
|
|
searchButton.addEventListener("click", function () {
|
|
send("search-stocks", { text: searchInput.value, trigger: "button" });
|
|
});
|
|
|
|
searchInput.addEventListener("keypress", function (event) {
|
|
if (event.key === "Enter" && !isImeComposing(event)) {
|
|
event.preventDefault();
|
|
send("search-stocks", { text: searchInput.value, trigger: "enter" });
|
|
}
|
|
});
|
|
|
|
dbSaveButton.addEventListener("click", function () {
|
|
openNamedPlaylist("save");
|
|
});
|
|
dbLoadButton.addEventListener("click", function () {
|
|
openNamedPlaylist("load");
|
|
});
|
|
namedPlaylistClose.addEventListener("click", closeNamedPlaylist);
|
|
namedPlaylistCancelButton.addEventListener("click", closeNamedPlaylist);
|
|
namedPlaylistDefinitions.addEventListener("dblclick", function (event) {
|
|
const definition = event.target.closest("button[data-named-playlist-definition-id]");
|
|
if (!definition || definition.disabled) return;
|
|
event.preventDefault();
|
|
const definitionId = definition.dataset.namedPlaylistDefinitionId;
|
|
if (namedPlaylistSelection.isPending()) {
|
|
namedPlaylistSelection.defer(definitionId, {
|
|
definitionId: definitionId,
|
|
mode: namedPlaylistMode
|
|
});
|
|
return;
|
|
}
|
|
runNamedPlaylistDoubleClick(definitionId, namedPlaylistMode);
|
|
});
|
|
namedPlaylistDefinitions.addEventListener("click", function (event) {
|
|
const definition = event.target.closest("button[data-named-playlist-definition-id]");
|
|
if (definition && !definition.disabled) {
|
|
beginNamedPlaylistSelection(definition.dataset.namedPlaylistDefinitionId);
|
|
}
|
|
});
|
|
namedPlaylistNewTitle.addEventListener("input", function () {
|
|
namedPlaylistCreateButton.disabled = isNamedPlaylistActionLocked() ||
|
|
!namedPlaylistState ||
|
|
namedPlaylistState.canCreate !== true ||
|
|
!isCanonicalNamedPlaylistTitle(namedPlaylistNewTitle.value);
|
|
});
|
|
namedPlaylistNewTitle.addEventListener("keydown", function (event) {
|
|
if (event.key === "Enter" && !isImeComposing(event) &&
|
|
!namedPlaylistCreateButton.disabled) {
|
|
event.preventDefault();
|
|
namedPlaylistCreateButton.click();
|
|
}
|
|
});
|
|
namedPlaylistCreateButton.addEventListener("click", function () {
|
|
const title = namedPlaylistNewTitle.value;
|
|
if (isCanonicalNamedPlaylistTitle(title)) {
|
|
beginNamedPlaylistBusyCycle("create-named-playlist", { title: title });
|
|
}
|
|
});
|
|
namedPlaylistDeleteButton.addEventListener("click", function () {
|
|
openNamedPlaylistConfirmation({
|
|
kind: "delete",
|
|
message: "프로그램이 삭제됩니다. 계속 하시겠습니까?",
|
|
closeOnNo: false
|
|
});
|
|
});
|
|
namedPlaylistConfirmButton.addEventListener("click", function () {
|
|
const definitionId = namedPlaylistState && namedPlaylistState.selectedDefinitionId;
|
|
if (!definitionId) return;
|
|
if (namedPlaylistMode === "save") {
|
|
openNamedPlaylistConfirmation({
|
|
kind: "save",
|
|
definitionId: definitionId,
|
|
message: "저장하겠습니까?",
|
|
showSavedAlert: true,
|
|
closeOnNo: true
|
|
});
|
|
} else if (namedPlaylistMode === "load") {
|
|
beginNamedPlaylistCommand(
|
|
"load-named-playlist-by-id",
|
|
definitionId,
|
|
false);
|
|
}
|
|
});
|
|
namedPlaylistConfirmationYes.addEventListener("click", function () {
|
|
if (!namedPlaylistConfirmationYes.disabled) {
|
|
resolveNamedPlaylistConfirmation(true);
|
|
}
|
|
});
|
|
namedPlaylistConfirmationNo.addEventListener("click", function () {
|
|
resolveNamedPlaylistConfirmation(false);
|
|
});
|
|
|
|
manualButtons.forEach(function (button) {
|
|
button.addEventListener("click", function () {
|
|
clearManualResultPendingSelection(true);
|
|
manualDraft = null;
|
|
manualDraftKey = "";
|
|
manualDraftResetPending = false;
|
|
send("open-manual-financial", { screen: button.dataset.manualScreen });
|
|
});
|
|
});
|
|
function closeManualFinancial() {
|
|
clearManualResultPendingSelection(true);
|
|
send("close-manual-financial", {});
|
|
}
|
|
manualClose.addEventListener("click", closeManualFinancial);
|
|
manualExit.addEventListener("click", closeManualFinancial);
|
|
manualListButtons.forEach(function (button) {
|
|
button.addEventListener("click", function () {
|
|
clearManualViResultPendingSelection(true);
|
|
send("open-manual-list", { screen: button.dataset.manualListScreen });
|
|
});
|
|
});
|
|
function closeManualList() {
|
|
clearManualViResultPendingSelection(true);
|
|
send("close-manual-list", {});
|
|
}
|
|
manualListClose.addEventListener("click", closeManualList);
|
|
manualListCancel.addEventListener("click", closeManualList);
|
|
manualWorkspace.addEventListener("input", function (event) {
|
|
if (!manualDraft || manualDraft.rawMode !== true ||
|
|
!event.target || !event.target.dataset ||
|
|
event.target.dataset.manualRawStorageIndex === undefined) {
|
|
return;
|
|
}
|
|
const index = Number(event.target.dataset.manualRawStorageIndex);
|
|
if (!Number.isSafeInteger(index) || index < 0) return;
|
|
if (!Array.isArray(manualDraft.rawDirtyStorageIndices)) {
|
|
manualDraft.rawDirtyStorageIndices = [];
|
|
}
|
|
if (!manualDraft.rawDirtyStorageIndices.includes(index)) {
|
|
manualDraft.rawDirtyStorageIndices.push(index);
|
|
}
|
|
});
|
|
|
|
manualWorkspace.addEventListener("dblclick", function (event) {
|
|
const stock = event.target.closest("button[data-manual-stock-result-id]");
|
|
if (stock && !stock.disabled) {
|
|
event.preventDefault();
|
|
selectManualFinancialStock(stock);
|
|
return;
|
|
}
|
|
const row = event.target.closest("button[data-manual-result-id]");
|
|
if (!row || row.disabled) return;
|
|
event.preventDefault();
|
|
clearDelayedClick(manualResultClickTimer);
|
|
manualResultClickTimer = null;
|
|
const pending = manualResultPendingSelection &&
|
|
manualResultPendingSelection.key === row.dataset.manualResultId
|
|
? manualResultPendingSelection
|
|
: beginManualResultPendingSelection(row);
|
|
if (send("activate-manual-financial-result", {
|
|
resultId: row.dataset.manualResultId
|
|
})) {
|
|
if (pending) pending.sent = true;
|
|
manualDraft = null;
|
|
manualDraftKey = "";
|
|
manualDraftResetPending = false;
|
|
} else {
|
|
clearManualResultPendingSelection(true);
|
|
}
|
|
});
|
|
manualWorkspace.addEventListener("click", function (event) {
|
|
const row = event.target.closest("button[data-manual-result-id]");
|
|
if (row) {
|
|
clearDelayedClick(manualResultClickTimer);
|
|
const resultId = row.dataset.manualResultId;
|
|
const pending = beginManualResultPendingSelection(row);
|
|
manualResultClickTimer = deferLegacySingleClick(function () {
|
|
manualResultClickTimer = null;
|
|
if (!pending || manualResultPendingSelection !== pending) return;
|
|
if (send("load-manual-financial", { resultId: resultId })) {
|
|
pending.sent = true;
|
|
manualDraft = null;
|
|
manualDraftKey = "";
|
|
manualDraftResetPending = false;
|
|
} else {
|
|
clearManualResultPendingSelection(true);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
const find = event.target.closest("button[data-manual-find-direction]");
|
|
if (find && !find.disabled) {
|
|
const query = manualWorkspace.querySelector("#manual-financial-find-query");
|
|
const generation = Number(find.dataset.manualGeneration);
|
|
if (query && query.value.trim() && Number.isSafeInteger(generation)) {
|
|
send("find-manual-financial", {
|
|
query: query.value,
|
|
direction: find.dataset.manualFindDirection,
|
|
generation: generation
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
const sort = event.target.closest("button[data-manual-name-sort]");
|
|
if (sort && !sort.disabled) {
|
|
const generation = Number(sort.dataset.manualGeneration);
|
|
if (Number.isSafeInteger(generation)) {
|
|
send("toggle-manual-financial-name-sort", { generation: generation });
|
|
}
|
|
return;
|
|
}
|
|
if (event.target.closest("button[data-manual-list-search]")) {
|
|
const query = manualWorkspace.querySelector("#manual-financial-list-query");
|
|
send("search-manual-financial", { query: query ? query.value : "" });
|
|
return;
|
|
}
|
|
if (event.target.closest("button[data-manual-create]")) {
|
|
const screen = manualDraft ? manualDraft.screen : "";
|
|
manualDraft = blankManualRecord(screen);
|
|
manualDraftKey = screen + "|new";
|
|
manualDraftResetPending = true;
|
|
if (!send("begin-manual-financial-create", {})) {
|
|
manualDraftResetPending = false;
|
|
rememberManualDraft();
|
|
}
|
|
return;
|
|
}
|
|
if (event.target.closest("button[data-manual-stock-search]")) {
|
|
rememberManualDraft();
|
|
const stockName = manualWorkspace.querySelector("#manual-financial-stock-name");
|
|
send("search-manual-financial-stocks", {
|
|
stockName: stockName ? stockName.value.trim() : ""
|
|
});
|
|
return;
|
|
}
|
|
const stock = event.target.closest("button[data-manual-stock-result-id]");
|
|
if (stock && !stock.disabled) {
|
|
// GraphE only committed a candidate from its DoubleClick handler. A
|
|
// single click must remain a harmless focus/highlight gesture so it
|
|
// cannot clear an editor draft after the operator resumes typing.
|
|
focusManualFinancialStock(stock);
|
|
return;
|
|
}
|
|
if (event.target.closest("button[data-manual-save]")) {
|
|
if (manualDraft && manualDraft.rawMode === true) {
|
|
const rawRecord = readManualRawRecord();
|
|
if (rawRecord) {
|
|
send("save-manual-financial-raw", { record: rawRecord });
|
|
}
|
|
return;
|
|
}
|
|
const record = readManualRecord();
|
|
if (record) send("save-manual-financial", { record: record });
|
|
return;
|
|
}
|
|
if (event.target.closest("button[data-manual-delete]")) {
|
|
send("delete-manual-financial", {});
|
|
return;
|
|
}
|
|
if (event.target.closest("button[data-manual-delete-all]")) {
|
|
send("delete-all-manual-financial", {});
|
|
return;
|
|
}
|
|
const action = event.target.closest("button[data-manual-action-id]");
|
|
if (action && !action.disabled) {
|
|
send("activate-manual-financial-action", {
|
|
actionId: action.dataset.manualActionId
|
|
});
|
|
}
|
|
});
|
|
manualWorkspace.addEventListener("keydown", function (event) {
|
|
if (event.key !== "Enter" || isImeComposing(event)) return;
|
|
const stock = event.target.closest("button[data-manual-stock-result-id]");
|
|
if (stock && !stock.disabled) {
|
|
event.preventDefault();
|
|
selectManualFinancialStock(stock);
|
|
} else if (event.target.id === "manual-financial-list-query") {
|
|
event.preventDefault();
|
|
send("search-manual-financial", { query: event.target.value });
|
|
} else if (event.target.id === "manual-financial-find-query") {
|
|
event.preventDefault();
|
|
const down = manualWorkspace.querySelector(
|
|
"button[data-manual-find-direction=\"down\"]");
|
|
if (down) down.click();
|
|
} else if (event.target.id === "manual-financial-stock-name") {
|
|
event.preventDefault();
|
|
rememberManualDraft();
|
|
send("search-manual-financial-stocks", {
|
|
stockName: event.target.value.trim()
|
|
});
|
|
}
|
|
});
|
|
|
|
operatorCatalogClose.addEventListener("click", function () {
|
|
send("close-operator-catalog", {});
|
|
});
|
|
operatorCatalogThemeTab.addEventListener("click", function () {
|
|
send("open-operator-catalog", { entity: "theme" });
|
|
});
|
|
operatorCatalogExpertTab.addEventListener("click", function () {
|
|
send("open-operator-catalog", { entity: "expert" });
|
|
});
|
|
function searchOperatorCatalog() {
|
|
send("search-operator-catalog", {
|
|
query: operatorCatalogQuery.value,
|
|
nxtSession: operatorCatalogSession.value === "afterMarket"
|
|
? "afterMarket" : "preMarket"
|
|
});
|
|
}
|
|
operatorCatalogSearch.addEventListener("click", searchOperatorCatalog);
|
|
operatorCatalogQuery.addEventListener("keydown", function (event) {
|
|
if (event.key === "Enter" && !isImeComposing(event)) {
|
|
event.preventDefault();
|
|
searchOperatorCatalog();
|
|
}
|
|
});
|
|
operatorCatalogNew.addEventListener("click", function () {
|
|
if (!operatorCatalogState) return;
|
|
operatorCatalogDraftKey = "";
|
|
operatorCatalogNameDraft = "";
|
|
if (operatorCatalogState.entity === "theme") {
|
|
send("begin-create-theme-catalog", {
|
|
market: operatorCatalogNewMarket.value === "nxt" ? "nxt" : "krx"
|
|
});
|
|
} else {
|
|
send("begin-create-expert-catalog", {});
|
|
}
|
|
});
|
|
operatorCatalogEditorMarket.addEventListener("change", function () {
|
|
if (!operatorCatalogState || operatorCatalogState.entity !== "theme" ||
|
|
operatorCatalogState.mode !== "create" || operatorCatalogEditorMarket.disabled) return;
|
|
|
|
operatorCatalogNameDraft = operatorCatalogName.value;
|
|
send("change-create-theme-market", {
|
|
market: operatorCatalogEditorMarket.value === "nxt" ? "nxt" : "krx",
|
|
editorName: operatorCatalogName.value.trim()
|
|
});
|
|
});
|
|
operatorCatalogResults.addEventListener("click", function (event) {
|
|
const row = event.target.closest("button[data-operator-catalog-result-id]");
|
|
if (!row || row.disabled) return;
|
|
operatorCatalogDraftKey = "";
|
|
send("select-operator-catalog-result", {
|
|
resultId: row.dataset.operatorCatalogResultId
|
|
});
|
|
});
|
|
operatorCatalogName.addEventListener("input", function () {
|
|
operatorCatalogNameDraft = operatorCatalogName.value;
|
|
operatorCatalogNameStatus.textContent = "";
|
|
operatorCatalogNameStatus.className = "";
|
|
operatorCatalogNameCheck.disabled = uiBusy || !operatorCatalogState ||
|
|
operatorCatalogState.entity !== "theme" ||
|
|
(operatorCatalogState.mode !== "create" && operatorCatalogState.mode !== "edit") ||
|
|
operatorCatalogState.canCheckThemeName !== true ||
|
|
operatorCatalogName.value.trim().length === 0;
|
|
});
|
|
operatorCatalogNameCheck.addEventListener("click", function () {
|
|
const name = operatorCatalogName.value.trim();
|
|
// ThemeA's duplicate-check button silently returned for an empty name.
|
|
if (!name) return;
|
|
|
|
operatorCatalogName.setCustomValidity("");
|
|
operatorCatalogNameDraft = name;
|
|
send("check-operator-catalog-theme-name", { name: name });
|
|
});
|
|
function searchOperatorCatalogStocks() {
|
|
const query = operatorCatalogStockQuery.value.trim();
|
|
send("search-operator-catalog-stocks", { query: query });
|
|
}
|
|
operatorCatalogStockSearch.addEventListener("click", searchOperatorCatalogStocks);
|
|
operatorCatalogStockQuery.addEventListener("keydown", function (event) {
|
|
if (event.key === "Enter" && !isImeComposing(event)) {
|
|
event.preventDefault();
|
|
searchOperatorCatalogStocks();
|
|
}
|
|
});
|
|
operatorCatalogStockResults.addEventListener("dblclick", function (event) {
|
|
const row = event.target.closest("button[data-operator-catalog-stock-result-id]");
|
|
if (!row || row.disabled) return;
|
|
event.preventDefault();
|
|
send("activate-operator-catalog-stock", {
|
|
resultId: row.dataset.operatorCatalogStockResultId,
|
|
buyAmount: null
|
|
});
|
|
});
|
|
operatorCatalogStockResults.addEventListener("click", function (event) {
|
|
const row = event.target.closest("button[data-operator-catalog-stock-result-id]");
|
|
if (!row || row.disabled) return;
|
|
// A real double-click raises click(detail=1), click(detail=2), then dblclick.
|
|
// Select immediately on the first click and ignore the synthetic second
|
|
// click. Keyed rendering above keeps this exact button attached while the
|
|
// selection snapshot returns, so slow Windows double-clicks retain a target.
|
|
if ((event.detail || 1) > 1) return;
|
|
send("select-operator-catalog-stock", {
|
|
resultId: row.dataset.operatorCatalogStockResultId
|
|
});
|
|
});
|
|
operatorCatalogAddStock.addEventListener("click", function () {
|
|
send("add-operator-catalog-stock", { buyAmount: null });
|
|
});
|
|
operatorCatalogDraftRows.addEventListener("click", function (event) {
|
|
const move = event.target.closest("button[data-operator-catalog-move-row-id]");
|
|
if (move && !move.disabled) {
|
|
send("move-operator-catalog-draft-row", {
|
|
rowId: move.dataset.operatorCatalogMoveRowId,
|
|
direction: move.dataset.operatorCatalogMoveDirection
|
|
});
|
|
return;
|
|
}
|
|
const remove = event.target.closest("button[data-operator-catalog-remove-row-id]");
|
|
if (remove && !remove.disabled) {
|
|
send("remove-operator-catalog-draft-row", {
|
|
rowId: remove.dataset.operatorCatalogRemoveRowId
|
|
});
|
|
}
|
|
});
|
|
operatorCatalogDraftRows.addEventListener("change", function (event) {
|
|
const input = event.target.closest("input[data-operator-catalog-buy-amount-row-id]");
|
|
if (!input || input.disabled) return;
|
|
const text = input.value.trim();
|
|
if (!text) {
|
|
input.setCustomValidity("");
|
|
send("set-operator-catalog-draft-buy-amount", {
|
|
rowId: input.dataset.operatorCatalogBuyAmountRowId,
|
|
buyAmount: null
|
|
});
|
|
return;
|
|
}
|
|
const amount = Number(text);
|
|
if (!Number.isSafeInteger(amount) || amount <= 0) {
|
|
input.setCustomValidity("매수가는 1 이상의 정수여야 합니다.");
|
|
input.reportValidity();
|
|
return;
|
|
}
|
|
input.setCustomValidity("");
|
|
send("set-operator-catalog-draft-buy-amount", {
|
|
rowId: input.dataset.operatorCatalogBuyAmountRowId,
|
|
buyAmount: amount
|
|
});
|
|
});
|
|
operatorCatalogSave.addEventListener("click", function () {
|
|
const rawName = operatorCatalogName.value;
|
|
const canonicalName = rawName.trim();
|
|
if (!canonicalName) {
|
|
// EList showed an exact one-button message; ThemeA's empty-name path did
|
|
// not own that message and is kept as a no-op here.
|
|
if (operatorCatalogState && operatorCatalogState.entity === "expert") {
|
|
send("save-operator-catalog", { name: "" });
|
|
}
|
|
return;
|
|
}
|
|
const preserveStoredThemeName = operatorCatalogState &&
|
|
operatorCatalogState.entity === "theme" &&
|
|
operatorCatalogState.mode === "edit";
|
|
const name = preserveStoredThemeName ? rawName : canonicalName;
|
|
operatorCatalogName.setCustomValidity("");
|
|
operatorCatalogNameDraft = name;
|
|
send("save-operator-catalog", { name: name });
|
|
});
|
|
operatorCatalogDeleteAll.addEventListener("click", function () {
|
|
if (!operatorCatalogState || operatorCatalogState.entity !== "theme" ||
|
|
(operatorCatalogState.mode !== "create" && operatorCatalogState.mode !== "edit")) {
|
|
return;
|
|
}
|
|
send("clear-operator-catalog-draft-rows", {});
|
|
});
|
|
operatorCatalogCancel.addEventListener("click", function () {
|
|
operatorCatalogDraftKey = "";
|
|
send("close-operator-catalog", {});
|
|
});
|
|
|
|
playlistMoveUp.addEventListener("click", function () {
|
|
send("move-selected-playlist-rows", { direction: "up" });
|
|
});
|
|
playlistMoveDown.addEventListener("click", function () {
|
|
send("move-selected-playlist-rows", { direction: "down" });
|
|
});
|
|
playlistDeleteSelected.addEventListener("click", function () {
|
|
send("delete-selected-playlist-rows", {});
|
|
});
|
|
playlistDeleteAll.addEventListener("click", function () {
|
|
send("delete-all-playlist-rows", {});
|
|
});
|
|
playlistEnableAll.addEventListener("change", function () {
|
|
if (!playlistProgramEnableMutationLocked) {
|
|
send("set-all-playlist-enabled", { enabled: playlistEnableAll.checked });
|
|
}
|
|
});
|
|
marketTabs.addEventListener("click", function (event) {
|
|
const button = event.target.closest("button[data-tab-id]");
|
|
if (button) {
|
|
clearComparisonTargetPendingSelection(true);
|
|
clearThemeResultPendingSelection(true);
|
|
send("select-tab", { tabId: button.dataset.tabId });
|
|
}
|
|
});
|
|
marketTabs.addEventListener("dragstart", function (event) {
|
|
const button = event.target.closest("button[data-tab-id]");
|
|
if (!button || !event.dataTransfer) return;
|
|
clearPendingTabHoverSwap();
|
|
draggedTabId = button.dataset.tabId;
|
|
tabDragLastTargetId = null;
|
|
event.dataTransfer.effectAllowed = "move";
|
|
event.dataTransfer.setData("text/x-mbn-tab", draggedTabId);
|
|
button.classList.add("dragging");
|
|
});
|
|
marketTabs.addEventListener("dragover", onMarketTabDragOver);
|
|
marketTabs.addEventListener("drop", function (event) {
|
|
const target = event.target.closest("button[data-tab-id]");
|
|
if (!target || !event.dataTransfer || !draggedTabId) return;
|
|
event.preventDefault();
|
|
if (event.dataTransfer.getData("text/x-mbn-tab") !== draggedTabId) return;
|
|
draggedTabId = null;
|
|
tabDragLastTargetId = null;
|
|
marketTabs.querySelectorAll("button.dragging")
|
|
.forEach(function (button) { button.classList.remove("dragging"); });
|
|
});
|
|
marketTabs.addEventListener("dragend", function () {
|
|
draggedTabId = null;
|
|
tabDragLastTargetId = null;
|
|
marketTabs.querySelectorAll("button.dragging")
|
|
.forEach(function (button) { button.classList.remove("dragging"); });
|
|
});
|
|
function sendMovingAverages() {
|
|
send("set-moving-averages", {
|
|
ma5: movingAverage5.checked,
|
|
ma20: movingAverage20.checked
|
|
});
|
|
}
|
|
movingAverage5.addEventListener("change", sendMovingAverages);
|
|
movingAverage20.addEventListener("change", sendMovingAverages);
|
|
catalogExpandAll.addEventListener("change", function () {
|
|
if (treeInteractionLocked) {
|
|
const sections = Array.from(categoryTree.querySelectorAll("details"));
|
|
catalogExpandAll.checked = sections.length > 0 && sections.every(function (details) {
|
|
return details.open === true;
|
|
});
|
|
return;
|
|
}
|
|
categoryTree.querySelectorAll("details").forEach(function (details) {
|
|
details.open = catalogExpandAll.checked;
|
|
syncTreeSectionExpanded(details);
|
|
});
|
|
});
|
|
categoryTree.addEventListener("dblclick", function (event) {
|
|
const treeNode = event.target.closest("[data-tree-node-key]");
|
|
if (treeNode) {
|
|
event.preventDefault();
|
|
if (treeInteractionLocked || treeNode.disabled === true ||
|
|
treeNode.getAttribute("aria-disabled") === "true") return;
|
|
selectTreeNode(treeNode, true);
|
|
// The disclosure is a separate expand/collapse command, not a node action.
|
|
if (event.target.closest("[data-tree-disclosure]")) return;
|
|
}
|
|
const themeResult = event.target.closest("button[data-theme-result-id]");
|
|
if (themeResult && !themeResult.disabled) {
|
|
event.preventDefault();
|
|
clearDelayedClick(themeResultClickTimer);
|
|
themeResultClickTimer = null;
|
|
const pending = themeResultPendingSelection &&
|
|
themeResultPendingSelection.key === themeResult.dataset.themeResultId
|
|
? themeResultPendingSelection
|
|
: beginThemeResultPendingSelection(themeResult);
|
|
if (send("activate-theme-result", {
|
|
resultId: themeResult.dataset.themeResultId
|
|
})) {
|
|
if (pending) pending.sent = true;
|
|
} else {
|
|
clearThemeResultPendingSelection(true);
|
|
}
|
|
return;
|
|
}
|
|
const comparisonChoice = event.target.closest("button[data-comparison-choice]");
|
|
if (comparisonChoice && !comparisonChoice.disabled) {
|
|
event.preventDefault();
|
|
clearDelayedClick(comparisonTargetClickTimer);
|
|
comparisonTargetClickTimer = null;
|
|
const selectionId = comparisonChoice.dataset.comparisonSelectionId;
|
|
const pending = comparisonTargetPendingSelection &&
|
|
comparisonTargetPendingSelection.key === selectionId
|
|
? comparisonTargetPendingSelection
|
|
: beginComparisonTargetPendingSelection(comparisonChoice, selectionId);
|
|
if (send(comparisonChoice.dataset.comparisonChoice === "world"
|
|
? "choose-comparison-world" : "choose-comparison-domestic", {
|
|
selectionId: selectionId
|
|
})) {
|
|
if (pending) pending.sent = true;
|
|
} else {
|
|
clearComparisonTargetPendingSelection(true);
|
|
}
|
|
return;
|
|
}
|
|
const comparisonFixed = event.target.closest("button[data-comparison-target-id]");
|
|
if (comparisonFixed && !comparisonFixed.disabled) {
|
|
event.preventDefault();
|
|
clearDelayedClick(comparisonTargetClickTimer);
|
|
comparisonTargetClickTimer = null;
|
|
const targetId = comparisonFixed.dataset.comparisonTargetId;
|
|
const pending = comparisonTargetPendingSelection &&
|
|
comparisonTargetPendingSelection.key === targetId
|
|
? comparisonTargetPendingSelection
|
|
: beginComparisonTargetPendingSelection(comparisonFixed, targetId);
|
|
if (send("choose-comparison-fixed", { targetId: targetId })) {
|
|
if (pending) pending.sent = true;
|
|
} else {
|
|
clearComparisonTargetPendingSelection(true);
|
|
}
|
|
return;
|
|
}
|
|
const comparisonSection = event.target.closest("summary[data-comparison-section-index]");
|
|
if (comparisonSection) {
|
|
event.preventDefault();
|
|
send("activate-comparison-section", {
|
|
sectionIndex: Number(comparisonSection.dataset.comparisonSectionIndex)
|
|
});
|
|
return;
|
|
}
|
|
const industrySection = event.target.closest("summary[data-industry-section-index]");
|
|
if (industrySection) {
|
|
event.preventDefault();
|
|
send("activate-industry-section", {
|
|
sectionIndex: Number(industrySection.dataset.industrySectionIndex)
|
|
});
|
|
return;
|
|
}
|
|
const comparisonAction = event.target.closest("button[data-comparison-action-id]");
|
|
if (comparisonAction && !comparisonAction.disabled) {
|
|
send("activate-comparison-action", {
|
|
actionId: comparisonAction.dataset.comparisonActionId
|
|
});
|
|
return;
|
|
}
|
|
const industryRow = event.target.closest("button[data-industry-index]");
|
|
if (industryRow) {
|
|
activateIndustryRow(industryRow);
|
|
return;
|
|
}
|
|
const industryAction = event.target.closest("button[data-industry-action-id]");
|
|
if (industryAction) {
|
|
send("activate-industry-action", {
|
|
actionId: industryAction.dataset.industryActionId
|
|
});
|
|
return;
|
|
}
|
|
const action = event.target.closest("button[data-action-id]");
|
|
if (action) {
|
|
send("activate-fixed-action", { actionId: action.dataset.actionId });
|
|
return;
|
|
}
|
|
const section = event.target.closest("summary[data-section-index]");
|
|
if (section && section.getAttribute("aria-disabled") !== "true") {
|
|
event.preventDefault();
|
|
// UC1.treeView_DoubleClick walks every direct child when the selected
|
|
// node is a root. Keep the whole batch behind one native intent so C#
|
|
// can materialize every child first and commit in original order.
|
|
send("activate-fixed-section", {
|
|
sectionIndex: Number(section.dataset.sectionIndex)
|
|
});
|
|
return;
|
|
}
|
|
});
|
|
categoryTree.addEventListener("click", function (event) {
|
|
const treeRoot = event.target.closest("summary[data-tree-root]");
|
|
if (treeRoot) {
|
|
// A native TreeView selects on its label and expands only from the glyph.
|
|
// Cancel <summary>'s whole-row default toggle to preserve that split.
|
|
event.preventDefault();
|
|
if (treeInteractionLocked || treeRoot.getAttribute("aria-disabled") === "true") return;
|
|
selectTreeNode(treeRoot, true);
|
|
if (event.target.closest("[data-tree-disclosure]") && (event.detail || 1) < 2) {
|
|
const details = treeRoot.parentElement;
|
|
details.open = !details.open;
|
|
syncTreeSectionExpanded(details);
|
|
}
|
|
return;
|
|
}
|
|
const treeLeaf = event.target.closest(".tree-item[data-tree-node-key]");
|
|
if (treeLeaf) {
|
|
if (treeInteractionLocked || treeLeaf.disabled === true) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
selectTreeNode(treeLeaf, true);
|
|
}
|
|
const uc4Catalog = event.target.closest("button[data-uc4-theme-catalog]");
|
|
if (uc4Catalog && !uc4Catalog.disabled) {
|
|
const action = uc4Catalog.dataset.uc4ThemeCatalog;
|
|
if (action === "create") {
|
|
send("begin-uc4-theme-catalog", {});
|
|
} else if (action === "edit") {
|
|
send("edit-uc4-selected-theme", {});
|
|
} else if (action === "delete") {
|
|
send("delete-uc4-selected-theme", {});
|
|
}
|
|
return;
|
|
}
|
|
const uc6Catalog = event.target.closest("button[data-uc6-expert-catalog]");
|
|
if (uc6Catalog && !uc6Catalog.disabled) {
|
|
const action = uc6Catalog.dataset.uc6ExpertCatalog;
|
|
if (action === "create") {
|
|
send("begin-uc6-expert-catalog", {});
|
|
} else if (action === "edit") {
|
|
send("edit-uc6-selected-expert", {});
|
|
} else if (action === "delete") {
|
|
send("delete-uc6-selected-expert", {});
|
|
}
|
|
return;
|
|
}
|
|
const catalogOpen = event.target.closest("button[data-operator-catalog-open]");
|
|
if (catalogOpen && !catalogOpen.disabled) {
|
|
send("open-operator-catalog", {
|
|
entity: catalogOpen.dataset.operatorCatalogOpen
|
|
});
|
|
return;
|
|
}
|
|
// UC4/UC6/UC7 wire their scene action controls to WinForms Click.
|
|
// Keep these actions out of the dblclick delegate above so one operator
|
|
// click produces exactly one closed native intent.
|
|
const themeAction = event.target.closest("button[data-theme-action-id]");
|
|
if (themeAction && !themeAction.disabled) {
|
|
send("activate-theme-action", { actionId: themeAction.dataset.themeActionId });
|
|
return;
|
|
}
|
|
const expertAction = event.target.closest("button[data-expert-action-id]");
|
|
if (expertAction && !expertAction.disabled) {
|
|
send("activate-expert-action", { actionId: expertAction.dataset.expertActionId });
|
|
return;
|
|
}
|
|
const haltAction = event.target.closest("button[data-halt-action-id]");
|
|
if (haltAction && !haltAction.disabled) {
|
|
send("activate-trading-halt-action", { actionId: haltAction.dataset.haltActionId });
|
|
return;
|
|
}
|
|
const overseasAction = event.target.closest("button[data-overseas-action-id]");
|
|
if (overseasAction && !overseasAction.disabled) {
|
|
send("activate-overseas-action", {
|
|
actionId: overseasAction.dataset.overseasActionId
|
|
});
|
|
return;
|
|
}
|
|
const overseasTarget = event.target.closest("button[data-overseas-target-id]");
|
|
if (overseasTarget && !overseasTarget.disabled) {
|
|
selectOverseasRow(overseasTarget);
|
|
return;
|
|
}
|
|
const overseasResult = event.target.closest("button[data-overseas-selection-id]");
|
|
if (overseasResult && !overseasResult.disabled) {
|
|
selectOverseasRow(overseasResult);
|
|
return;
|
|
}
|
|
const overseasSearch = event.target.closest("button[data-overseas-search]");
|
|
if (overseasSearch) {
|
|
const kind = overseasSearch.dataset.overseasSearch;
|
|
const input = categoryTree.querySelector("#overseas-" + kind + "-search");
|
|
send(kind === "stock" ? "search-overseas-stocks" : "search-overseas-industries", {
|
|
query: input ? input.value : ""
|
|
});
|
|
return;
|
|
}
|
|
const comparisonChoice = event.target.closest("button[data-comparison-choice]");
|
|
if (comparisonChoice) {
|
|
clearDelayedClick(comparisonTargetClickTimer);
|
|
const kind = comparisonChoice.dataset.comparisonChoice;
|
|
const selectionId = comparisonChoice.dataset.comparisonSelectionId;
|
|
const pending = beginComparisonTargetPendingSelection(
|
|
comparisonChoice,
|
|
selectionId);
|
|
comparisonTargetClickTimer = deferLegacySingleClick(function () {
|
|
comparisonTargetClickTimer = null;
|
|
if (!pending || comparisonTargetPendingSelection !== pending) return;
|
|
if (send(kind === "world"
|
|
? "select-comparison-world" : "select-comparison-domestic", {
|
|
selectionId: selectionId
|
|
})) {
|
|
pending.sent = true;
|
|
} else {
|
|
clearComparisonTargetPendingSelection(true);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
const comparisonFixed = event.target.closest("button[data-comparison-target-id]");
|
|
if (comparisonFixed) {
|
|
clearDelayedClick(comparisonTargetClickTimer);
|
|
const targetId = comparisonFixed.dataset.comparisonTargetId;
|
|
const pending = beginComparisonTargetPendingSelection(comparisonFixed, targetId);
|
|
comparisonTargetClickTimer = deferLegacySingleClick(function () {
|
|
comparisonTargetClickTimer = null;
|
|
if (!pending || comparisonTargetPendingSelection !== pending) return;
|
|
if (send("select-comparison-fixed", { targetId: targetId })) {
|
|
pending.sent = true;
|
|
} else {
|
|
clearComparisonTargetPendingSelection(true);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
const comparisonPair = event.target.closest("button[data-comparison-pair-id]");
|
|
if (comparisonPair) {
|
|
send("select-comparison-pair", { rowId: comparisonPair.dataset.comparisonPairId });
|
|
return;
|
|
}
|
|
const comparisonSearch = event.target.closest("button[data-comparison-search]");
|
|
if (comparisonSearch) {
|
|
const kind = comparisonSearch.dataset.comparisonSearch;
|
|
const input = categoryTree.querySelector("#comparison-" + kind + "-search");
|
|
const searchIntent = kind === "world"
|
|
? "search-comparison-world" : "search-comparison-domestic";
|
|
send(searchIntent, { query: input ? input.value : "" });
|
|
return;
|
|
}
|
|
if (event.target.closest("button[data-comparison-swap]")) {
|
|
send("swap-comparison-targets", {});
|
|
return;
|
|
}
|
|
if (event.target.closest("button[data-comparison-clear]")) {
|
|
send("clear-comparison-targets", {});
|
|
return;
|
|
}
|
|
if (event.target.closest("button[data-comparison-add]")) {
|
|
send("add-comparison-pair", {});
|
|
return;
|
|
}
|
|
if (event.target.closest("button[data-comparison-import]")) {
|
|
send("import-legacy-comparison-pairs", {});
|
|
return;
|
|
}
|
|
const comparisonMove = event.target.closest("button[data-comparison-move]");
|
|
if (comparisonMove) {
|
|
send("move-comparison-pair", { direction: comparisonMove.dataset.comparisonMove });
|
|
return;
|
|
}
|
|
const comparisonDelete = event.target.closest("button[data-comparison-delete]");
|
|
if (comparisonDelete) {
|
|
const all = comparisonDelete.dataset.comparisonDelete === "all";
|
|
send(all ? "delete-all-comparison-pairs" :
|
|
"delete-selected-comparison-pair", {});
|
|
return;
|
|
}
|
|
const expertResult = event.target.closest("button[data-expert-selection-id]");
|
|
if (expertResult) {
|
|
send("select-expert", { selectionId: expertResult.dataset.expertSelectionId });
|
|
return;
|
|
}
|
|
if (event.target.closest("button[data-expert-search]")) {
|
|
const input = categoryTree.querySelector("#expert-search");
|
|
send("search-experts", { query: input ? input.value : "" });
|
|
return;
|
|
}
|
|
const haltResult = event.target.closest("button[data-halt-selection-id]");
|
|
if (haltResult) {
|
|
send("select-trading-halt", { selectionId: haltResult.dataset.haltSelectionId });
|
|
return;
|
|
}
|
|
if (event.target.closest("button[data-halt-search]")) {
|
|
const input = categoryTree.querySelector("#halt-search");
|
|
send("search-trading-halts", { query: input ? input.value : "" });
|
|
return;
|
|
}
|
|
if (event.target.closest("button[data-halt-sort]")) {
|
|
send("toggle-trading-halt-sort", {});
|
|
return;
|
|
}
|
|
const themeResult = event.target.closest("button[data-theme-result-id]");
|
|
if (themeResult) {
|
|
clearDelayedClick(themeResultClickTimer);
|
|
const resultId = themeResult.dataset.themeResultId;
|
|
const pending = beginThemeResultPendingSelection(themeResult);
|
|
themeResultClickTimer = deferLegacySingleClick(function () {
|
|
themeResultClickTimer = null;
|
|
if (!pending || themeResultPendingSelection !== pending) return;
|
|
if (send("select-theme", { resultId: resultId })) {
|
|
pending.sent = true;
|
|
} else {
|
|
clearThemeResultPendingSelection(true);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
if (event.target.closest("button[data-theme-search]")) {
|
|
const input = categoryTree.querySelector("#theme-search");
|
|
send("search-themes", { query: input ? input.value : "" });
|
|
return;
|
|
}
|
|
const industryRow = event.target.closest("button[data-industry-index]");
|
|
if (industryRow) {
|
|
selectIndustryRow(industryRow);
|
|
return;
|
|
}
|
|
if (event.target.closest("button[data-industry-swap]")) {
|
|
send("swap-industries", {});
|
|
}
|
|
});
|
|
categoryTree.addEventListener("change", function (event) {
|
|
if (event.target.matches("input[data-industry-slot]")) {
|
|
send("set-industry-comparison-text", {
|
|
slot: event.target.dataset.industrySlot,
|
|
value: event.target.value
|
|
});
|
|
} else if (event.target.matches("input[data-theme-sort]")) {
|
|
send("set-theme-sort", { sortId: event.target.value });
|
|
}
|
|
});
|
|
categoryTree.addEventListener("keydown", function (event) {
|
|
if (isImeComposing(event)) return;
|
|
const overseasList = event.target.closest("[data-overseas-list]");
|
|
if (overseasList && ["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
moveOverseasListSelection(overseasList, event.key);
|
|
return;
|
|
}
|
|
const industryList = event.target.closest(".industry-list");
|
|
if (industryList && ["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) {
|
|
event.preventDefault();
|
|
const rows = Array.from(
|
|
industryList.querySelectorAll("button[data-industry-index]"));
|
|
if (rows.length === 0) return;
|
|
const selectedIndex = rows.findIndex(function (row) {
|
|
return row.getAttribute("aria-selected") === "true";
|
|
});
|
|
let nextIndex;
|
|
if (event.key === "Home") nextIndex = 0;
|
|
else if (event.key === "End") nextIndex = rows.length - 1;
|
|
else if (selectedIndex < 0) nextIndex = 0;
|
|
else if (event.key === "ArrowUp") nextIndex = Math.max(0, selectedIndex - 1);
|
|
else nextIndex = Math.min(rows.length - 1, selectedIndex + 1);
|
|
const nextRow = rows[nextIndex];
|
|
selectIndustryRow(nextRow);
|
|
nextRow.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
} else {
|
|
const treeRoot = event.target.closest("summary[data-tree-root]");
|
|
if (treeRoot && ["Enter", " ", "ArrowLeft", "ArrowRight"].includes(event.key)) {
|
|
event.preventDefault();
|
|
if (treeInteractionLocked || treeRoot.getAttribute("aria-disabled") === "true") return;
|
|
selectTreeNode(treeRoot, true);
|
|
const details = treeRoot.parentElement;
|
|
if (event.key === "ArrowLeft") details.open = false;
|
|
else if (event.key === "ArrowRight") details.open = true;
|
|
syncTreeSectionExpanded(details);
|
|
} else if (event.key === "Enter" &&
|
|
(event.target.id === "overseas-industry-search" ||
|
|
event.target.id === "overseas-stock-search")) {
|
|
event.preventDefault();
|
|
const kind = event.target.id === "overseas-stock-search" ? "stock" : "industry";
|
|
send(kind === "stock" ? "search-overseas-stocks" : "search-overseas-industries", {
|
|
query: event.target.value
|
|
});
|
|
} else if (event.key === "Enter" &&
|
|
(event.target.id === "comparison-domestic-search" ||
|
|
event.target.id === "comparison-world-search")) {
|
|
event.preventDefault();
|
|
const kind = event.target.id === "comparison-world-search" ? "world" : "domestic";
|
|
send(kind === "world" ? "search-comparison-world" :
|
|
"search-comparison-domestic", { query: event.target.value });
|
|
} else if (event.key === "Enter" && event.target.id === "theme-search") {
|
|
event.preventDefault();
|
|
send("search-themes", { query: event.target.value });
|
|
} else if (event.key === "Enter" && event.target.id === "expert-search") {
|
|
event.preventDefault();
|
|
send("search-experts", { query: event.target.value });
|
|
} else if (event.key === "Enter" && event.target.id === "halt-search") {
|
|
event.preventDefault();
|
|
send("search-trading-halts", { query: event.target.value });
|
|
}
|
|
}
|
|
});
|
|
window.addEventListener("pointerup", function () {
|
|
deferredPlaylistPlainSelectionRowId = null;
|
|
operatorCatalogDragBlockedRowId = null;
|
|
});
|
|
|
|
function closeActiveChildModalFromAltF4(activeModal) {
|
|
if (!activeModal) return false;
|
|
if (activeModal === namedPlaylistDialog) {
|
|
closeNamedPlaylist();
|
|
} else if (activeModal === manualDialog) {
|
|
if (!manualClose.disabled) manualClose.click();
|
|
} else if (activeModal === manualListDialog) {
|
|
if (!manualListClose.disabled) manualListClose.click();
|
|
} else if (activeModal === operatorCatalogDialog) {
|
|
if (!operatorCatalogClose.disabled) operatorCatalogClose.click();
|
|
} else if (activeModal === legacyAlertDialog && !legacyDialogIsConfirmation) {
|
|
send("dismiss-dialog", {});
|
|
}
|
|
// Native Yes/No MessageBox windows do not expose an Alt+F4 close result.
|
|
// Consume Alt+F4 for those and for the nested PList confirmation as well.
|
|
return true;
|
|
}
|
|
|
|
function activateRovingListRow(row) {
|
|
if (!row || row.disabled === true) return false;
|
|
if (row.dataset.treeNodeKey) {
|
|
if (treeInteractionLocked || row.getAttribute("aria-disabled") === "true") {
|
|
return false;
|
|
}
|
|
selectTreeNode(row, false);
|
|
return true;
|
|
}
|
|
if (row.dataset.themeResultId) {
|
|
const pending = beginThemeResultPendingSelection(row);
|
|
if (pending && send("select-theme", {
|
|
resultId: row.dataset.themeResultId
|
|
})) {
|
|
pending.sent = true;
|
|
return true;
|
|
}
|
|
clearThemeResultPendingSelection(true);
|
|
return false;
|
|
}
|
|
if (row.dataset.namedPlaylistDefinitionId) {
|
|
return beginNamedPlaylistSelection(row.dataset.namedPlaylistDefinitionId);
|
|
}
|
|
if (row.dataset.operatorCatalogResultId) {
|
|
operatorCatalogDraftKey = "";
|
|
return send("select-operator-catalog-result", {
|
|
resultId: row.dataset.operatorCatalogResultId
|
|
});
|
|
}
|
|
if (row.dataset.operatorCatalogStockResultId) {
|
|
return send("select-operator-catalog-stock", {
|
|
resultId: row.dataset.operatorCatalogStockResultId
|
|
});
|
|
}
|
|
if (row.dataset.comparisonSelectionId) {
|
|
clearComparisonTargetPendingSelection(true);
|
|
return send(row.dataset.comparisonChoice === "world"
|
|
? "select-comparison-world" : "select-comparison-domestic", {
|
|
selectionId: row.dataset.comparisonSelectionId
|
|
});
|
|
}
|
|
if (row.dataset.comparisonTargetId) {
|
|
clearComparisonTargetPendingSelection(true);
|
|
return send("select-comparison-fixed", {
|
|
targetId: row.dataset.comparisonTargetId
|
|
});
|
|
}
|
|
if (row.dataset.comparisonPairId) {
|
|
return send("select-comparison-pair", { rowId: row.dataset.comparisonPairId });
|
|
}
|
|
if (row.dataset.expertSelectionId) {
|
|
return send("select-expert", { selectionId: row.dataset.expertSelectionId });
|
|
}
|
|
if (row.dataset.haltSelectionId) {
|
|
return send("select-trading-halt", { selectionId: row.dataset.haltSelectionId });
|
|
}
|
|
if (row.dataset.manualResultId) {
|
|
clearManualResultPendingSelection(true);
|
|
manualDraft = null;
|
|
manualDraftKey = "";
|
|
manualDraftResetPending = false;
|
|
return send("load-manual-financial", { resultId: row.dataset.manualResultId });
|
|
}
|
|
if (row.dataset.manualViResultId) {
|
|
clearManualViResultPendingSelection(true);
|
|
return send("select-manual-vi-result", { resultId: row.dataset.manualViResultId });
|
|
}
|
|
return false;
|
|
}
|
|
|
|
document.addEventListener("focusin", function (event) {
|
|
const row = event.target && typeof event.target.closest === "function"
|
|
? event.target.closest("[data-roving-row]") : null;
|
|
const list = row && row.closest("[data-roving-list]");
|
|
if (row && list) focusRovingListRow(list, row, false);
|
|
});
|
|
|
|
document.addEventListener("keydown", function (event) {
|
|
const activeModal = modalFocusManager.getActiveModal();
|
|
if (event.altKey && event.key === "F4") {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
// MainForm consumed Alt+F4 at the main workspace. Child modal behavior
|
|
// remains explicit, while the main workspace shortcut is a no-op.
|
|
if (activeModal && !event.repeat) closeActiveChildModalFromAltF4(activeModal);
|
|
return;
|
|
}
|
|
const rovingRow = event.target && typeof event.target.closest === "function"
|
|
? event.target.closest("[data-roving-row]") : null;
|
|
const rovingList = rovingRow && rovingRow.closest("[data-roving-list]");
|
|
if (rovingList && isRovingListNavigationKey(event.key) &&
|
|
!isImeComposing(event)) {
|
|
if (moveRovingListSelection(
|
|
rovingList,
|
|
rovingRow,
|
|
event.key,
|
|
activateRovingListRow)) {
|
|
event.preventDefault();
|
|
// MainForm.KeyPreview still applied its playlist boundary side effect
|
|
// behind non-modal ListBoxes. ShowDialog-owned GraphE/VI lists isolate it.
|
|
if (!activeModal && !event.repeat &&
|
|
(event.key === "Home" || event.key === "End")) {
|
|
selectPlaylistBoundary(event.key === "Home" ? "first" : "last");
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
const dragLoopShortcut = event.key === "Escape" || event.key === "F2" ||
|
|
event.key === "F3" || event.key === "F8";
|
|
const pointerDragStarted = (cutDragGesture && cutDragGesture.started) ||
|
|
(playlistDragGesture && playlistDragGesture.started);
|
|
if (pointerDragStarted && dragLoopShortcut) {
|
|
// Native DoDragDrop runs its own modal loop. MainForm shortcuts cannot run
|
|
// until that loop ends. Match that behavior for both pointer-capture drag
|
|
// surfaces; Escape cancels without issuing TAKE OUT.
|
|
event.preventDefault();
|
|
if (event.key === "Escape") {
|
|
if (cutDragGesture) clearCutDragGesture();
|
|
if (playlistDragGesture) {
|
|
deferredPlaylistPlainSelectionRowId = null;
|
|
clearPlaylistDragGesture();
|
|
}
|
|
}
|
|
} else if (event.key === "Delete" && !event.repeat && dialog.hidden &&
|
|
!operatorCatalogModal.hidden && operatorCatalogState &&
|
|
(operatorCatalogState.mode === "create" || operatorCatalogState.mode === "edit")) {
|
|
const draftRow = event.target.closest("[data-operator-catalog-row-id]");
|
|
const textEditor = event.target.closest("input, textarea, [contenteditable='true']");
|
|
if (draftRow && !textEditor && !operatorCatalogMutationLocked &&
|
|
draftRow.dataset.operatorCatalogRowId === operatorCatalogState.activeDraftRowId) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
send("remove-active-operator-catalog-draft-row", {});
|
|
}
|
|
} else if (event.key === "Escape" && modalFocusManager.getActiveModal()) {
|
|
// The legacy child forms did not assign a CancelButton and none handled
|
|
// Escape. ShowDialog isolated MainForm, so Escape cannot fall through to
|
|
// MainForm's TAKE OUT shortcut. The one-button MessageBox is the exception:
|
|
// its Escape is equivalent to acknowledging OK.
|
|
event.preventDefault();
|
|
if (!event.repeat && modalFocusManager.getActiveModal() === legacyAlertDialog &&
|
|
!legacyDialogIsConfirmation) {
|
|
send("dismiss-dialog", {});
|
|
}
|
|
} else if ((event.key === "ArrowUp" || event.key === "ArrowDown") &&
|
|
dialog.hidden && manualModal.hidden && manualListModal.hidden &&
|
|
namedPlaylistModal.hidden && namedPlaylistConfirmationModal.hidden &&
|
|
operatorCatalogModal.hidden && event.target.closest(".playlist-data-row")) {
|
|
if (movePlaylistKeyboardSelection(
|
|
event.target.closest(".playlist-data-row"), event)) {
|
|
event.preventDefault();
|
|
}
|
|
} else if (event.key === "Delete" && !event.repeat && dialog.hidden && manualModal.hidden &&
|
|
manualListModal.hidden && namedPlaylistModal.hidden &&
|
|
namedPlaylistConfirmationModal.hidden && operatorCatalogModal.hidden) {
|
|
// MainForm.KeyPreview invoked playlist deletion but did not suppress the key,
|
|
// so an editor that owns focus must still perform its normal text deletion.
|
|
if (!playlistDeleteSelectedLocked) send("delete-selected-playlist-rows", {});
|
|
} else if (event.key === " " && !event.repeat && dialog.hidden && manualModal.hidden &&
|
|
manualListModal.hidden && namedPlaylistModal.hidden &&
|
|
namedPlaylistConfirmationModal.hidden && operatorCatalogModal.hidden &&
|
|
event.target.closest(".playlist-data-row")) {
|
|
event.preventDefault();
|
|
if (!playlistProgramEnableMutationLocked) {
|
|
send("toggle-active-playlist-enabled", {});
|
|
}
|
|
} else if ((event.key === "Home" || event.key === "End") &&
|
|
!event.repeat && dialog.hidden && manualModal.hidden && manualListModal.hidden &&
|
|
namedPlaylistModal.hidden && namedPlaylistConfirmationModal.hidden &&
|
|
operatorCatalogModal.hidden) {
|
|
// MainForm handled its playlist/timer side effect and then returned the
|
|
// base key result, so a focused textbox/list control still receives Home/End.
|
|
selectPlaylistBoundary(event.key === "Home" ? "first" : "last");
|
|
}
|
|
});
|
|
dialogOk.addEventListener("click", function () {
|
|
send(legacyDialogIsConfirmation
|
|
? "confirm-native-dialog"
|
|
: "dismiss-dialog", {});
|
|
});
|
|
dialogNo.addEventListener("click", function () {
|
|
if (legacyDialogIsConfirmation) {
|
|
send("cancel-native-dialog", {});
|
|
}
|
|
});
|
|
|
|
if (webview) {
|
|
webview.addEventListener("message", function (event) {
|
|
if (event.data && event.data.type === "state" && event.data.payload) {
|
|
render(event.data.payload);
|
|
}
|
|
});
|
|
send("ready", {});
|
|
} else {
|
|
status.textContent = "WebView2 브리지를 사용할 수 없습니다.";
|
|
}
|
|
}());
|