(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 '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 브리지를 사용할 수 없습니다."; } }());