Files
MBN_STOCK_WEBVIEW/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js

6534 lines
270 KiB
JavaScript

(function () {
"use strict";
const webview = window.chrome && window.chrome.webview;
const searchInput = document.getElementById("stock-search");
const searchButton = document.getElementById("stock-search-button");
const stockSearchMeta = document.getElementById("stock-search-meta");
const stockResults = document.getElementById("stock-results");
const cutList = document.getElementById("cut-list");
const cutDragFeedback = document.getElementById("cut-drag-feedback");
const playlistRows = document.getElementById("playlist-rows");
const playlistHeadingCount = document.getElementById("playlist-heading-count");
const playlistDropZone = document.getElementById("playlist-drop-zone");
const playlistMoveUp = document.getElementById("playlist-move-up");
const playlistMoveDown = document.getElementById("playlist-move-down");
const playlistDeleteSelected = document.getElementById("playlist-delete-selected");
const playlistDeleteAll = document.getElementById("playlist-delete-all");
const playlistEnableAll = document.getElementById("playlist-enable-all");
const marketTabs = document.getElementById("market-tabs");
const categoryTree = document.getElementById("category-tree");
const catalogExpandAll = document.getElementById("catalog-expand-all");
const movingAverage5 = document.getElementById("moving-average-5");
const movingAverage20 = document.getElementById("moving-average-20");
const status = document.getElementById("operator-status");
const operatorSettingsMessage = document.getElementById("operator-settings-message");
const operatorSettingsRestart = document.getElementById("operator-settings-restart");
const operatorNavigationExpanded = document.getElementById("operator-navigation-expanded");
const operatorAppearanceControls = Object.freeze({
colorTheme: Object.freeze({
element: document.getElementById("operator-color-theme"),
values: Object.freeze(["system", "light", "dark"]),
fallback: "system"
}),
viewMode: Object.freeze({
element: document.getElementById("operator-view-mode"),
values: Object.freeze(["automatic", "compact", "cards"]),
fallback: "automatic"
}),
startWorkspace: Object.freeze({
element: document.getElementById("operator-start-workspace"),
values: Object.freeze(["stockCut", "lastWorkspace"]),
fallback: "stockCut"
})
});
const operatorSettingsAppVersion = document.getElementById("operator-settings-app-version");
const operatorSettingsPlayoutMode = document.getElementById("operator-settings-playout-mode");
const operatorSettingsPlayoutConnection = document.getElementById(
"operator-settings-playout-connection");
const operatorSettingsOracleState = document.getElementById("operator-settings-oracle-state");
const operatorSettingsMariaState = document.getElementById("operator-settings-maria-state");
const operatorSettingsDbMonitor = document.getElementById("operator-settings-db-monitor");
const operatorFolderControls = Object.freeze({
design: Object.freeze({
name: document.getElementById("operator-design-folder-name"),
status: document.getElementById("operator-design-folder-status"),
source: document.getElementById("operator-design-folder-source"),
select: document.getElementById("operator-design-folder-select"),
reset: document.getElementById("operator-design-folder-reset")
}),
resource: Object.freeze({
name: document.getElementById("operator-resource-folder-name"),
status: document.getElementById("operator-resource-folder-status"),
source: document.getElementById("operator-resource-folder-source"),
select: document.getElementById("operator-resource-folder-select"),
reset: document.getElementById("operator-resource-folder-reset")
}),
background: Object.freeze({
name: document.getElementById("operator-background-folder-name"),
status: document.getElementById("operator-background-folder-status"),
source: document.getElementById("operator-background-folder-source"),
select: document.getElementById("operator-background-folder-select"),
reset: document.getElementById("operator-background-folder-reset")
})
});
const dialog = document.getElementById("legacy-dialog");
const dialogCaption = document.getElementById("legacy-dialog-caption");
const dialogMessage = document.getElementById("legacy-dialog-message");
const dialogOk = document.getElementById("legacy-dialog-ok");
const dialogNo = document.getElementById("legacy-dialog-no");
const dbSaveButton = document.getElementById("db-save-button");
const dbLoadButton = document.getElementById("db-load-button");
const namedPlaylistModal = document.getElementById("named-playlist-modal");
const namedPlaylistTitle = document.getElementById("named-playlist-title");
const namedPlaylistClose = document.getElementById("named-playlist-close");
const namedPlaylistStatus = document.getElementById("named-playlist-status");
const namedPlaylistDefinitions = document.getElementById("named-playlist-definitions");
const namedPlaylistNewTitle = document.getElementById("named-playlist-new-title");
const namedPlaylistCreateButton = document.getElementById("named-playlist-create-button");
const namedPlaylistDeleteButton = document.getElementById("named-playlist-delete-button");
const namedPlaylistCancelButton = document.getElementById("named-playlist-cancel-button");
const namedPlaylistConfirmButton = document.getElementById("named-playlist-confirm-button");
const namedPlaylistConfirmationModal = document.getElementById(
"named-playlist-confirmation");
const namedPlaylistConfirmationMessage = document.getElementById(
"named-playlist-confirmation-message");
const namedPlaylistConfirmationYes = document.getElementById(
"named-playlist-confirmation-yes");
const namedPlaylistConfirmationNo = document.getElementById(
"named-playlist-confirmation-no");
const manualButtons = Array.from(document.querySelectorAll("button[data-manual-screen]"));
const manualModal = document.getElementById("manual-financial-modal");
const manualTitle = document.getElementById("manual-financial-title");
const manualClose = document.getElementById("manual-financial-close");
const manualExit = document.getElementById("manual-financial-exit");
const manualWorkspace = document.getElementById("manual-financial-workspace");
const manualListButtons = Array.from(
document.querySelectorAll("button[data-manual-list-screen]"));
const manualListModal = document.getElementById("manual-list-modal");
const manualListTitle = document.getElementById("manual-list-title");
const manualListClose = document.getElementById("manual-list-close");
const manualListCancel = document.getElementById("manual-list-cancel");
const manualListWorkspace = document.getElementById("manual-list-workspace");
const operatorCatalogModal = document.getElementById("operator-catalog-modal");
const operatorCatalogTitle = document.getElementById("operator-catalog-title");
const operatorCatalogClose = document.getElementById("operator-catalog-close");
const operatorCatalogStatus = document.getElementById("operator-catalog-status");
const operatorCatalogToolbar = document.getElementById("operator-catalog-toolbar");
const operatorCatalogBody = document.getElementById("operator-catalog-body");
const operatorCatalogListPanel = document.getElementById("operator-catalog-list-panel");
const operatorCatalogThemeTab = document.getElementById("operator-catalog-theme-tab");
const operatorCatalogExpertTab = document.getElementById("operator-catalog-expert-tab");
const operatorCatalogQuery = document.getElementById("operator-catalog-query");
const operatorCatalogSession = document.getElementById("operator-catalog-session");
const operatorCatalogSearch = document.getElementById("operator-catalog-search");
const operatorCatalogNewMarket = document.getElementById("operator-catalog-new-market");
const operatorCatalogNew = document.getElementById("operator-catalog-new");
const operatorCatalogResults = document.getElementById("operator-catalog-results");
const operatorCatalogEditor = document.getElementById("operator-catalog-editor");
const operatorCatalogEditorMarketField = document.getElementById(
"operator-catalog-editor-market-field");
const operatorCatalogEditorMarket = document.getElementById(
"operator-catalog-editor-market");
const operatorCatalogCode = document.getElementById("operator-catalog-code");
const operatorCatalogNameLabel = document.getElementById("operator-catalog-name-label");
const operatorCatalogName = document.getElementById("operator-catalog-name");
const operatorCatalogNameCheck = document.getElementById("operator-catalog-name-check");
const operatorCatalogNameStatus = document.getElementById("operator-catalog-name-status");
const operatorCatalogStockSearchLine = document.getElementById("operator-catalog-stock-search-line");
const operatorCatalogStockQuery = document.getElementById("operator-catalog-stock-query");
const operatorCatalogStockSearch = document.getElementById("operator-catalog-stock-search");
const operatorCatalogStockResults = document.getElementById("operator-catalog-stock-results");
const operatorCatalogAmountLabel = document.getElementById("operator-catalog-amount-label");
const operatorCatalogAmount = document.getElementById("operator-catalog-amount");
const operatorCatalogAddStock = document.getElementById("operator-catalog-add-stock");
const operatorCatalogAddLine = document.getElementById("operator-catalog-add-line");
const operatorCatalogDraftHeader = document.getElementById("operator-catalog-draft-header");
const operatorCatalogDraftRows = document.getElementById("operator-catalog-draft-rows");
const operatorCatalogDeleteAll = document.getElementById("operator-catalog-delete-all");
const operatorCatalogCancel = document.getElementById("operator-catalog-cancel");
const operatorCatalogSave = document.getElementById("operator-catalog-save");
const namedPlaylistDialog = namedPlaylistModal.querySelector("[role='dialog']");
const namedPlaylistConfirmationDialog = namedPlaylistConfirmationModal.querySelector(
"[role='alertdialog']");
const manualDialog = manualModal.querySelector("[role='dialog']");
const manualListDialog = manualListModal.querySelector("[role='dialog']");
const operatorCatalogDialog = operatorCatalogModal.querySelector("[role='dialog']");
const legacyAlertDialog = dialog.querySelector("[role='alertdialog']");
if (!window.LegacyModalFocus || typeof window.LegacyModalFocus.create !== "function") {
throw new Error("Legacy modal focus support was not loaded.");
}
if (!window.LegacyCutDrag ||
typeof window.LegacyCutDrag.createDragRectangle !== "function" ||
typeof window.LegacyCutDrag.shouldStart !== "function" ||
typeof window.LegacyCutDrag.toCssDragSize !== "function") {
throw new Error("Legacy cut drag support was not loaded.");
}
if (!window.LegacyNamedPlaylistSelection ||
typeof window.LegacyNamedPlaylistSelection.create !== "function") {
throw new Error("Legacy named-playlist selection support was not loaded.");
}
const modalFocusManager = window.LegacyModalFocus.create({ document: document });
const namedPlaylistSelection = window.LegacyNamedPlaylistSelection.create();
modalFocusManager.start();
let uiBusy = false;
const searchDraftInputIds = new Set([
"stock-search",
"overseas-industry-search",
"overseas-stock-search",
"comparison-domestic-search",
"comparison-world-search",
"theme-search",
"expert-search",
"halt-search",
"manual-financial-list-query",
"manual-financial-find-query",
"manual-vi-search",
"operator-catalog-query",
"operator-catalog-stock-query"
]);
const searchDrafts = new Map();
let namedPlaylistMode = null;
let namedPlaylistState = null;
let namedPlaylistConfirmation = null;
let manualDraft = null;
let manualDraftKey = "";
let manualDraftResetPending = false;
let operatorCatalogState = null;
let operatorCatalogNameDraft = "";
let operatorCatalogDraftKey = "";
let manualResultClickTimer = null;
let comparisonTargetClickTimer = null;
let themeResultClickTimer = null;
let manualViResultClickTimer = null;
let manualResultPendingSelection = null;
let comparisonTargetPendingSelection = null;
let themeResultPendingSelection = null;
let manualViResultPendingSelection = null;
let pendingNamedPlaylistCommand = null;
let nextNamedPlaylistRequestId = 1;
let lastCommandSequence = 0;
let operatorMutationLocked = true;
const operatorCatalogDragMime = "text/x-mbn-operator-catalog-row";
let deferredPlaylistPlainSelectionRowId = null;
let playlistDragGesture = null;
let playlistMutationLocked = false;
let playlistStructureMutationLocked = true;
let playlistProgramEnableMutationLocked = true;
let playlistDeleteSelectedLocked = true;
let cutSelectionEnabled = false;
let cutDragSize = { width: 4, height: 4 };
let cutDragGesture = null;
let lastPresentedPlayoutRowKey = "";
let draggedTabId = null;
let tabDragLastTargetId = null;
let pendingTabHoverSwap = null;
let draggedOperatorCatalogRowId = null;
let operatorCatalogDragBlockedRowId = null;
let operatorCatalogMutationLocked = false;
let legacyDialogIsConfirmation = false;
let industryRenderStructureKey = "";
let overseasRenderStructureKey = "";
let themeRenderStructureKey = "";
let comparisonRenderStructureKey = "";
let expertRenderStructureKey = "";
let tradingHaltRenderStructureKey = "";
let manualFinancialRenderStateKey = "";
let manualListRenderStateKey = "";
let operatorCatalogRenderStateKey = "";
let manualFinancialSearchFocus = null;
let manualListSearchFocus = null;
let operatorCatalogSearchFocus = null;
let operatorSettingsRenderRevision = null;
let operatorSettingsRevisionInitialized = false;
let operatorAppearanceSnapshot = Object.freeze({
colorTheme: "system",
viewMode: "automatic",
startWorkspace: "stockCut"
});
let pendingOperatorSettingsFocusId = null;
let pendingOperatorSettingsFallbackFocusId = null;
let operatorSettingsWasBusy = false;
let renderedTreeTabId = null;
let treeInteractionLocked = false;
let treeFocusOwnedBeforeRender = false;
const treeUiStateByTab = new Map();
const pendingTreeResetTabs = new Set();
function preferredModalFocus(modal) {
if (modal === legacyAlertDialog) return dialogOk;
if (modal === namedPlaylistConfirmationDialog) return namedPlaylistConfirmationYes;
if (modal === namedPlaylistDialog) {
return namedPlaylistDefinitions.querySelector("button[aria-selected='true']") ||
namedPlaylistDefinitions.querySelector("button") || namedPlaylistNewTitle;
}
if (modal === manualDialog) {
return manualWorkspace.querySelector(
"button.selected[data-manual-stock-result-id]") ||
manualWorkspace.querySelector("button.selected[data-manual-result-id]") ||
manualWorkspace.querySelector("button[data-manual-result-id]") ||
manualWorkspace.querySelector(".manual-financial-list-header");
}
if (modal === manualListDialog) {
return manualListWorkspace.querySelector(
"button.selected[data-manual-vi-result-id]") ||
manualListWorkspace.querySelector("input:not([disabled])") ||
manualListWorkspace.querySelector("button:not([disabled])");
}
if (modal === operatorCatalogDialog) {
if (!operatorCatalogEditor.hidden && !operatorCatalogName.disabled) {
return operatorCatalogName;
}
return operatorCatalogResults.querySelector("button[aria-selected='true']") ||
operatorCatalogResults.querySelector("button") || operatorCatalogQuery;
}
return null;
}
function createModalOpenerReference() {
const opener = document.activeElement;
if (!renderedTreeTabId || !categoryTree.contains(opener)) return opener;
captureTreeUiState(renderedTreeTabId);
const node = opener.closest("[data-tree-node-key]");
const descriptor = {
tabId: renderedTreeTabId,
controlId: opener.id || null,
nodeKey: node ? node.dataset.treeNodeKey : null,
selectionStart: Number.isInteger(opener.selectionStart) ? opener.selectionStart : null,
selectionEnd: Number.isInteger(opener.selectionEnd) ? opener.selectionEnd : null
};
return function resolveTreeModalOpener() {
if (descriptor.tabId !== renderedTreeTabId) return null;
let resolved = descriptor.controlId
? document.getElementById(descriptor.controlId) : null;
if (!resolved && descriptor.nodeKey) {
resolved = findTreeNodeByKey(descriptor.nodeKey);
}
if (!resolved || !categoryTree.contains(resolved) || resolved.disabled === true) {
return null;
}
if (Number.isInteger(descriptor.selectionStart) &&
Number.isInteger(descriptor.selectionEnd) &&
typeof resolved.setSelectionRange === "function") {
try {
resolved.setSelectionRange(descriptor.selectionStart, descriptor.selectionEnd);
} catch (_) {
// A recreated non-text control may expose, but reject, selection ranges.
}
}
return resolved;
};
}
function rememberModalOpener(modal) {
if (modal) modalFocusManager.rememberOpener(modal, createModalOpenerReference());
}
function syncModalFocus() {
modalFocusManager.sync(preferredModalFocus);
}
function activeTreeTabId(state) {
if (!state || !Array.isArray(state.tabs)) return null;
const active = state.tabs.find(function (tab) { return tab.isActive === true; });
return active ? active.id : null;
}
function treeSectionIdentity(summary, index) {
if (summary.dataset.sectionIndex != null) {
return "fixed:" + summary.dataset.sectionIndex;
}
if (summary.dataset.industrySectionIndex != null) {
return "industry:" + summary.dataset.industrySectionIndex;
}
if (summary.dataset.comparisonSectionIndex != null) {
return "comparison:" + summary.dataset.comparisonSectionIndex;
}
return "section:" + index + ":" + (summary.textContent || "").trim();
}
function treeLeafIdentity(button, index) {
return button.dataset.actionId ||
button.dataset.industryActionId ||
button.dataset.comparisonActionId ||
button.dataset.overseasActionId ||
("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.textContent = "[" + section.name + "]";
summary.title = batch
? "현재 수동 입력 단계를 완료하거나 취소하세요."
: "하위 항목을 더블클릭해 편성표에 추가합니다.";
summary.setAttribute("aria-disabled", batch ? "true" : "false");
const children = document.createElement("div");
children.className = "catalog-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;
button.textContent = "▸ " + action.label;
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";
group.appendChild(summary);
industry.actions.forEach(function (action) {
const button = document.createElement("button");
button.type = "button";
button.className = "tree-item";
button.dataset.industryActionId = action.actionId;
button.textContent = "▸ " + action.label;
group.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 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 = 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);
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.title = "더블클릭하면 직계 자식 전체를 원본 순서로 추가합니다.";
group.appendChild(summary);
comparisonSections.set(action.section, group);
actions.appendChild(group);
}
const button = document.createElement("button");
button.type = "button";
button.className = "tree-item" + (action.isAvailable ? "" : " unavailable");
button.dataset.comparisonActionId = action.actionId;
button.textContent = action.label;
button.disabled = !action.isAvailable;
button.title = action.unavailableReason || "더블클릭하여 플레이리스트에 추가";
group.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";
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;
button.textContent = originalActionLabels.get(action.actionId) || action.label;
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";
expert.actions.forEach(function (action) {
const button = document.createElement("button");
button.type = "button";
button.dataset.expertActionId = action.actionId;
button.textContent = action.label;
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";
halt.actions.forEach(function (action) {
const button = document.createElement("button");
button.type = "button";
button.dataset.haltActionId = action.actionId;
button.textContent = action.label;
button.disabled = !action.isAvailable;
actions.appendChild(button);
});
workspace.append(search, results, actions);
categoryTree.replaceChildren(workspace);
tradingHaltRenderStructureKey = structureKey;
updateTradingHaltWorkspace(workspace, halt);
}
const manualFinancialReferenceImages = Object.freeze({
"revenue-composition": "./Previews/pie.bmp",
"growth-metrics": "./Previews/Grow.bmp",
sales: "./Previews/sell.bmp",
"operating-profit": "./Previews/profit.bmp"
});
function manualFinancialPreviewColumns(screen) {
if (screen === "revenue-composition") {
return ["종목명", "기준일", "구성 1", "구성 2", "구성 3", "구성 4", "구성 5"];
}
if (screen === "growth-metrics") {
return ["종목명", "매출 성장", "영업익 성장", "순자산 성장", "순이익 성장", "기간"];
}
return ["종목명", "분기 1", "분기 2", "분기 3", "분기 4", "분기 5", "분기 6"];
}
function manualFinancialPreviewValues(row, count) {
const projected = Array.isArray(row.previewValues)
? row.previewValues.slice(0, count)
: [];
while (projected.length < count) projected.push("");
projected[0] = projected[0] == null || String(projected[0]).trim() === ""
? (row.stockName || "")
: projected[0];
return projected.map(function (value) {
return value == null ? "" : String(value);
});
}
function manualFinancialReferenceFigure(screen, label) {
const source = manualFinancialReferenceImages[screen];
if (!source) return null;
const figure = manualElement("figure", "manual-financial-reference");
const image = document.createElement("img");
image.src = source;
image.width = 480;
image.height = 270;
image.loading = "eager";
image.decoding = "async";
image.alt = label + " 기존 송출 화면 참고 이미지";
const caption = manualElement(
"figcaption",
"",
"기존 송출 화면 참고 · 입력 위치와 구성 확인용");
figure.append(image, caption);
return figure;
}
function blankManualRecord(screen) {
if (screen === "revenue-composition") {
return {
screen: screen,
stockName: "",
baseDate: "",
slices: Array.from({ length: 5 }, function () {
return { label: "", percentageText: "" };
})
};
}
if (screen === "growth-metrics") {
return {
screen: screen,
stockName: "",
salesGrowth: [null, null, null, null],
operatingProfitGrowth: [null, null, null, null],
netAssetGrowth: [null, null, null, null],
netIncomeGrowth: [null, null, null, null],
periods: ["", "", "", ""]
};
}
return {
screen: screen,
stockName: "",
quarters: Array.from({ length: 6 }, function () {
return { quarter: "", value: 0 };
})
};
}
function splitLegacyRawValue(value, count) {
const tokens = String(value === null || value === undefined ? "" : value).split("_");
while (tokens.length < count) tokens.push("");
if (tokens.length > count) {
tokens[count - 1] = tokens.slice(count - 1).join("_");
tokens.length = count;
}
return tokens;
}
function manualRecordFromRaw(raw) {
if (!raw) return null;
const record = blankManualRecord(raw.screen);
record.stockName = raw.stockName || "";
record.rawMode = true;
const values = Array.isArray(raw.storageValues) ? raw.storageValues : [];
record.rawOriginalStorageValues = values.map(function (value) {
return String(value === null || value === undefined ? "" : value);
});
record.rawDirtyStorageIndices = [];
if (record.screen === "revenue-composition") {
record.baseDate = values[0] || "";
record.slices = record.slices.map(function (_, index) {
const tokens = splitLegacyRawValue(values[index + 1] || "", 2);
return { label: tokens[0], percentageText: tokens[1] };
});
} else if (record.screen === "growth-metrics") {
["salesGrowth", "operatingProfitGrowth", "netAssetGrowth", "netIncomeGrowth"]
.forEach(function (seriesName, index) {
record[seriesName] = splitLegacyRawValue(values[index] || "", 4);
});
record.periods = splitLegacyRawValue(values[4] || "", 4);
} else {
record.quarters = record.quarters.map(function (_, index) {
const tokens = splitLegacyRawValue(values[index] || "", 2);
return { quarter: tokens[0], value: tokens[1] };
});
}
return record;
}
function preserveManualRawStorageValue(record, index, rebuiltValue) {
const dirty = Array.isArray(record.rawDirtyStorageIndices) &&
record.rawDirtyStorageIndices.includes(index);
const originals = Array.isArray(record.rawOriginalStorageValues)
? record.rawOriginalStorageValues : [];
return !dirty && index < originals.length
? originals[index]
: rebuiltValue;
}
function cloneManualRecord(record) {
return record ? JSON.parse(JSON.stringify(record)) : null;
}
function shouldResetManualStockDraft(stockIsSelected, stockNameReadOnly, draft) {
return stockIsSelected !== true && stockNameReadOnly !== true &&
!!draft && draft.rawMode !== true;
}
function manualElement(tagName, className, text) {
const element = document.createElement(tagName);
if (className) element.className = className;
if (text !== undefined) element.textContent = text;
return element;
}
function manualInput(value, dataName, dataValue, type) {
const input = document.createElement("input");
input.type = type || "text";
input.value = value === null || value === undefined ? "" : String(value);
if (dataName) input.dataset[dataName] = String(dataValue);
return input;
}
function appendManualLabel(parent, labelText, input) {
const label = manualElement("label", "manual-financial-field");
label.append(manualElement("span", "", labelText), input);
parent.appendChild(label);
}
function renderManualEditorFields(editor, record) {
const fields = manualElement("div", "manual-financial-fields");
if (record.screen === "revenue-composition") {
appendManualLabel(
fields,
"기준일",
manualInput(record.baseDate, "manualBaseDate", "true"));
const slices = manualElement("div", "manual-financial-grid manual-revenue-grid");
slices.append(
manualElement("strong", "", "구성"),
manualElement("strong", "", "비율"));
record.slices.forEach(function (slice, index) {
slices.append(
manualInput(slice ? slice.label : "", "manualRevenueLabel", index),
manualInput(
slice ? slice.percentageText : "",
"manualRevenuePercentage",
index));
});
fields.appendChild(slices);
} else if (record.screen === "growth-metrics") {
const grid = manualElement("div", "manual-financial-grid manual-growth-grid");
grid.appendChild(manualElement("strong", "", "구분"));
record.periods.forEach(function (period, index) {
grid.appendChild(manualInput(period, "manualGrowthPeriod", index));
});
[
["매출 성장", "salesGrowth"],
["영업이익 성장", "operatingProfitGrowth"],
["순자산 성장", "netAssetGrowth"],
["순이익 성장", "netIncomeGrowth"]
].forEach(function (series) {
grid.appendChild(manualElement("span", "", series[0]));
record[series[1]].forEach(function (value, index) {
const input = manualInput(
value,
"manualGrowthValue",
index,
record.rawMode ? "text" : "number");
input.dataset.manualGrowthSeries = series[1];
input.step = "any";
grid.appendChild(input);
});
});
fields.appendChild(grid);
} else {
const grid = manualElement("div", "manual-financial-grid manual-quarter-grid");
grid.append(
manualElement("strong", "", "분기"),
manualElement("strong", "", "값"));
record.quarters.forEach(function (quarter, index) {
grid.append(
manualInput(quarter.quarter, "manualQuarterLabel", index),
manualInput(
quarter.value,
"manualQuarterValue",
index,
record.rawMode ? "text" : "number"));
});
fields.appendChild(grid);
}
editor.appendChild(fields);
}
function bindManualRawStorageInputs(editor, record) {
if (!record || record.rawMode !== true) return;
const bind = function (selector, indexFactory) {
editor.querySelectorAll(selector).forEach(function (input) {
input.dataset.manualRawStorageIndex = String(indexFactory(input));
});
};
bind("[data-manual-base-date]", function () { return 0; });
bind("[data-manual-revenue-label], [data-manual-revenue-percentage]",
function (input) {
return Number(input.dataset.manualRevenueLabel !== undefined
? input.dataset.manualRevenueLabel
: input.dataset.manualRevenuePercentage) + 1;
});
const growthSeriesIndices = {
salesGrowth: 0,
operatingProfitGrowth: 1,
netAssetGrowth: 2,
netIncomeGrowth: 3
};
bind("[data-manual-growth-value]", function (input) {
return growthSeriesIndices[input.dataset.manualGrowthSeries];
});
bind("[data-manual-growth-period]", function () { return 4; });
bind("[data-manual-quarter-label], [data-manual-quarter-value]",
function (input) {
return Number(input.dataset.manualQuarterLabel !== undefined
? input.dataset.manualQuarterLabel
: input.dataset.manualQuarterValue);
});
}
function readManualRecord() {
if (!manualDraft || !manualWorkspace) return null;
const stockName = manualWorkspace.querySelector("#manual-financial-stock-name");
const record = blankManualRecord(manualDraft.screen);
record.stockName = stockName ? stockName.value.trim() : "";
if (record.screen === "revenue-composition") {
const baseDate = manualWorkspace.querySelector("[data-manual-base-date]");
record.baseDate = baseDate ? baseDate.value : "";
record.slices = record.slices.map(function (_, index) {
const label = manualWorkspace.querySelector(
"[data-manual-revenue-label=\"" + index + "\"]");
const percentage = manualWorkspace.querySelector(
"[data-manual-revenue-percentage=\"" + index + "\"]");
const labelValue = label ? label.value.trim() : "";
const percentageValue = percentage ? percentage.value.trim() : "";
return labelValue === "" && percentageValue === "" ? null : {
label: labelValue,
percentageText: percentageValue
};
});
} else if (record.screen === "growth-metrics") {
record.periods = record.periods.map(function (_, index) {
const input = manualWorkspace.querySelector(
"[data-manual-growth-period=\"" + index + "\"]");
return input ? input.value : "";
});
["salesGrowth", "operatingProfitGrowth", "netAssetGrowth", "netIncomeGrowth"]
.forEach(function (seriesName) {
record[seriesName] = record[seriesName].map(function (_, index) {
const input = manualWorkspace.querySelector(
"[data-manual-growth-series=\"" + seriesName + "\"]" +
"[data-manual-growth-value=\"" + index + "\"]");
if (!input || input.value.trim() === "") return null;
const value = Number(input.value);
return Number.isFinite(value) ? value : null;
});
});
} else {
record.quarters = record.quarters.map(function (_, index) {
const quarter = manualWorkspace.querySelector(
"[data-manual-quarter-label=\"" + index + "\"]");
const value = manualWorkspace.querySelector(
"[data-manual-quarter-value=\"" + index + "\"]");
const parsed = value && /^-?\d+$/.test(value.value.trim())
? Number(value.value) : null;
return {
quarter: quarter ? quarter.value.trim() : "",
value: parsed
};
});
}
return record;
}
function readManualRawRecord() {
if (!manualDraft || !manualWorkspace || manualDraft.rawMode !== true) return null;
const stockName = manualWorkspace.querySelector("#manual-financial-stock-name");
const record = {
screen: manualDraft.screen,
stockName: stockName ? stockName.value : "",
storageValues: []
};
if (record.screen === "revenue-composition") {
const baseDate = manualWorkspace.querySelector("[data-manual-base-date]");
record.storageValues.push(preserveManualRawStorageValue(
manualDraft,
0,
baseDate ? baseDate.value : ""));
for (let index = 0; index < 5; index += 1) {
const label = manualWorkspace.querySelector(
"[data-manual-revenue-label=\"" + index + "\"]");
const percentage = manualWorkspace.querySelector(
"[data-manual-revenue-percentage=\"" + index + "\"]");
record.storageValues.push(preserveManualRawStorageValue(
manualDraft,
index + 1,
(label ? label.value : "") + "_" +
(percentage ? percentage.value : "")));
}
} else if (record.screen === "growth-metrics") {
["salesGrowth", "operatingProfitGrowth", "netAssetGrowth", "netIncomeGrowth"]
.forEach(function (seriesName) {
const values = [];
for (let index = 0; index < 4; index += 1) {
const input = manualWorkspace.querySelector(
"[data-manual-growth-series=\"" + seriesName + "\"]" +
"[data-manual-growth-value=\"" + index + "\"]");
values.push(input ? input.value : "");
}
record.storageValues.push(preserveManualRawStorageValue(
manualDraft,
record.storageValues.length,
values.join("_")));
});
const periods = [];
for (let index = 0; index < 4; index += 1) {
const input = manualWorkspace.querySelector(
"[data-manual-growth-period=\"" + index + "\"]");
periods.push(input ? input.value : "");
}
record.storageValues.push(preserveManualRawStorageValue(
manualDraft,
4,
periods.join("_")));
} else {
for (let index = 0; index < 6; index += 1) {
const quarter = manualWorkspace.querySelector(
"[data-manual-quarter-label=\"" + index + "\"]");
const value = manualWorkspace.querySelector(
"[data-manual-quarter-value=\"" + index + "\"]");
record.storageValues.push(preserveManualRawStorageValue(
manualDraft,
index,
(quarter ? quarter.value : "") + "_" + (value ? value.value : "")));
}
}
return record;
}
function rememberManualDraft() {
if (manualDraft && manualDraft.rawMode === true) {
const raw = readManualRawRecord();
if (raw) manualDraft = manualRecordFromRaw(raw);
return;
}
const record = readManualRecord();
if (record) manualDraft = record;
}
function focusManualFinancialStock(stock) {
if (!stock || stock.disabled === true || !manualWorkspace.contains(stock)) return false;
if (document.activeElement !== stock) stock.focus({ preventScroll: true });
return true;
}
function selectManualFinancialStock(stock) {
if (!focusManualFinancialStock(stock)) return false;
rememberManualDraft();
const previousDraft = manualDraft ? cloneManualRecord(manualDraft) : null;
const previousDraftKey = manualDraftKey;
const previousDraftResetPending = manualDraftResetPending;
const stockName = manualWorkspace.querySelector("#manual-financial-stock-name");
const stockIsSelected = stock.getAttribute("aria-selected") === "true";
if (stockName && shouldResetManualStockDraft(
stockIsSelected,
stockName.readOnly,
manualDraft)) {
// The original GraphE forms cleared the other editor cells whenever the
// operator deliberately chose another stock. Do this only for that
// selection transition; ordinary native rerenders keep the live draft.
manualDraft = blankManualRecord(manualDraft.screen);
manualDraftKey = manualDraft.screen + "|new";
manualDraftResetPending = true;
}
if (send("select-manual-financial-stock", {
resultId: stock.dataset.manualStockResultId
})) return true;
manualDraft = previousDraft;
manualDraftKey = previousDraftKey;
manualDraftResetPending = previousDraftResetPending;
return false;
}
function captureManualDraftForChangedModel() {
if (!manualModal.hidden && !manualDraftResetPending) rememberManualDraft();
manualDraftResetPending = false;
}
function renderManualFinancial(state) {
const manual = state.manualFinancial;
manualButtons.forEach(function (button) {
button.disabled = state.isBusy === true;
});
if (!manual) {
clearManualResultPendingSelection(false);
setTransientControlsBusy(manualDialog, false);
manualModal.hidden = true;
manualWorkspace.replaceChildren();
forgetSearchDraft(
"manual-financial-list-query",
"manual-financial-find-query");
manualFinancialSearchFocus = null;
manualDraft = null;
manualDraftKey = "";
manualDraftResetPending = false;
manualFinancialRenderStateKey = "";
return;
}
const capturedSearchFocus = captureSearchDraftFocus(manualWorkspace);
if (capturedSearchFocus) manualFinancialSearchFocus = capturedSearchFocus;
const manualSearchScope = "manual-financial:" + manual.screen;
const renderStateKey = JSON.stringify(manual);
if (manualResultPendingSelection) {
if (manualResultPendingSelection.sent && state.isBusy !== true) {
const accepted = manual.selectedRowId === manualResultPendingSelection.key;
clearManualResultPendingSelection(!accepted);
} else if (!manualResultPendingSelection.sent &&
manualFinancialRenderStateKey &&
renderStateKey !== manualFinancialRenderStateKey) {
clearManualResultPendingSelection(false);
} else {
applyPendingRowSelection(manualResultPendingSelection, false);
applyPendingControlLocks(manualResultPendingSelection.lockedControls);
}
}
if (!manualModal.hidden && renderStateKey === manualFinancialRenderStateKey) {
setTransientControlsBusy(manualDialog, state.isBusy === true);
if (state.isBusy !== true &&
restoreSearchDraftFocus(manualWorkspace, manualFinancialSearchFocus)) {
manualFinancialSearchFocus = null;
}
return;
}
captureManualDraftForChangedModel();
// Clear the previous transient snapshot before applying a changed model.
// Otherwise a busy -> new-model transition can restore the old screen's
// semantic disabled states over the controls rendered below.
setTransientControlsBusy(manualDialog, false);
if (manualModal.hidden) rememberModalOpener(manualDialog);
manualModal.hidden = false;
manualTitle.textContent = manual.label + " · GraphE 수동 입력";
const selectedEditorRecord = manual.selectedRawRecord
? manualRecordFromRaw(manual.selectedRawRecord)
: manual.selectedRecord;
const detailKey = manual.screen + "|" + (manual.selectedRowId || "new") + "|" +
JSON.stringify(manual.selectedRawRecord || manual.selectedRecord || null);
if (selectedEditorRecord && detailKey !== manualDraftKey) {
manualDraft = cloneManualRecord(selectedEditorRecord);
manualDraftKey = detailKey;
} else if (!manualDraft || manualDraft.screen !== manual.screen) {
manualDraft = blankManualRecord(manual.screen);
manualDraftKey = manual.screen + "|new";
}
if (!manual.selectedRecord && manual.verifiedStock) {
manualDraft.stockName = manual.verifiedStock.name;
}
const toolbar = manualElement("div", "manual-financial-toolbar");
const listQuery = manualInput(manual.query || "", "manualListQuery", "true");
listQuery.id = "manual-financial-list-query";
syncSearchDraft(listQuery, manual.query, manualSearchScope);
const listSearch = manualElement("button", "", "목록 검색");
listSearch.type = "button";
listSearch.dataset.manualListSearch = "true";
const findQuery = manualInput(manual.findQuery || "", "manualFindQuery", "true");
findQuery.id = "manual-financial-find-query";
findQuery.placeholder = "종목명 찾기";
syncSearchDraft(findQuery, manual.findQuery, manualSearchScope);
const findUp = manualElement("button", "", "▲ 위로 찾기");
findUp.type = "button";
findUp.dataset.manualFindDirection = "up";
findUp.dataset.manualGeneration = String(manual.generation);
const findDown = manualElement("button", "", "▼ 아래로 찾기");
findDown.type = "button";
findDown.dataset.manualFindDirection = "down";
findDown.dataset.manualGeneration = String(manual.generation);
const create = manualElement("button", "", "새 입력");
create.type = "button";
create.dataset.manualCreate = "true";
toolbar.append(listQuery, listSearch, create, findQuery, findUp, findDown);
const body = manualElement("div", "manual-financial-body");
const list = manualElement("div", "manual-financial-list");
list.setAttribute("aria-label", manual.label + " 저장 목록");
const previewColumns = manualFinancialPreviewColumns(manual.screen);
list.classList.add("manual-financial-preview-columns-" + String(previewColumns.length));
const nameHeader = manualElement(
"button",
"manual-financial-list-header manual-financial-list-row");
nameHeader.type = "button";
nameHeader.dataset.manualNameSort = "true";
nameHeader.dataset.manualGeneration = String(manual.generation);
nameHeader.title = "종목명 정렬 방향 전환";
previewColumns.forEach(function (label, index) {
nameHeader.appendChild(manualElement(
"span",
"manual-financial-preview-cell",
index === 0
? label + " " + (manual.nameSortDirection === "descending" ? "▼" : "▲")
: label));
});
list.appendChild(nameHeader);
let selectedManualRow = null;
manual.rows.forEach(function (row) {
const button = manualElement("button", "manual-financial-list-row");
button.type = "button";
button.id = "manual-financial-result-" + row.resultId;
button.dataset.manualResultId = row.resultId;
const isSelected = manual.selectedRowId === row.resultId;
button.classList.toggle("selected", isSelected);
button.setAttribute("aria-selected", isSelected ? "true" : "false");
const previewValues = manualFinancialPreviewValues(row, previewColumns.length);
previewValues.forEach(function (value) {
const cell = manualElement("span", "manual-financial-preview-cell", value);
if (value) cell.title = value;
button.appendChild(cell);
});
button.setAttribute("aria-label", previewColumns.map(function (label, index) {
return label + " " + (previewValues[index] || "없음");
}).join(", "));
if (row.isDataReadable === false) {
button.classList.add("manual-financial-row-unreadable");
button.title = "종목 이름을 읽을 수 없어 이 행을 사용할 수 없습니다.";
button.setAttribute(
"aria-label",
button.getAttribute("aria-label") + " · 읽기 불가");
} else if (row.isPlayoutReady === false) {
button.classList.add("manual-financial-row-legacy");
button.title = "기존 GraphE 원문 형식입니다. 선택해 원문 그대로 확인하고 편집할 수 있습니다.";
button.setAttribute(
"aria-label",
button.getAttribute("aria-label") + " · 기존 원문 형식");
}
if (isSelected) selectedManualRow = button;
list.appendChild(button);
});
configureRovingList(
list,
Array.from(list.querySelectorAll("button[data-manual-result-id]")),
selectedManualRow);
const editor = manualElement("section", "manual-financial-editor");
const stockLine = manualElement("div", "manual-financial-stock-line");
const stockInput = manualInput(manualDraft.stockName || "");
stockInput.id = "manual-financial-stock-name";
stockInput.readOnly = !!manual.selectedRecord || !!manual.selectedRawRecord;
const stockSearch = manualElement("button", "", "종목 확인");
stockSearch.type = "button";
stockSearch.dataset.manualStockSearch = "true";
stockLine.append(stockInput, stockSearch);
editor.appendChild(stockLine);
const candidates = manualElement("div", "manual-financial-stock-results");
let selectedManualStock = null;
manual.stockCandidates.forEach(function (row) {
const isSelected = manual.selectedStockResultId === row.resultId;
const button = manualElement(
"button",
isSelected ? "selected" : "",
row.name + " · " + row.market);
button.type = "button";
button.id = "manual-financial-stock-result-" + row.resultId;
button.dataset.manualStockResultId = row.resultId;
button.setAttribute(
"aria-selected",
isSelected ? "true" : "false");
if (isSelected) selectedManualStock = button;
candidates.appendChild(button);
});
// A single click on the legacy GraphE candidate ListBox only moved its
// focus/highlight. Keep arrow/page navigation equally non-destructive;
// double-click or Enter remains the explicit stock-selection command.
configureRovingList(
candidates,
Array.from(candidates.querySelectorAll("button[data-manual-stock-result-id]")),
selectedManualStock,
{ activation: "focus", selectionFollowsFocus: false });
editor.appendChild(candidates);
const verification = manualElement(
"div",
"manual-financial-verification",
manual.verifiedStock
? "확인됨: " + manual.verifiedStock.name + " · " + manual.verifiedStock.market
: "정확한 종목 마스터를 확인하세요.");
editor.appendChild(verification);
const referenceFigure = manualFinancialReferenceFigure(manual.screen, manual.label);
if (referenceFigure) editor.appendChild(referenceFigure);
renderManualEditorFields(editor, manualDraft);
bindManualRawStorageInputs(editor, manualDraft);
if (manual.selectedRawRecord && manual.isPlayoutReady !== true) {
editor.appendChild(manualElement(
"div",
"manual-financial-warning",
"기존 GraphE 원문 값으로 불러왔습니다. 편집·삭제할 수 있고, 송출 전에는 장면 데이터 검증을 다시 수행합니다."));
}
const actions = manualElement("div", "manual-financial-actions");
const save = manualElement("button", "manual-primary", "저장");
save.type = "button";
save.dataset.manualSave = "true";
save.disabled = !manual.canSave;
const remove = manualElement("button", "", "선택 삭제");
remove.type = "button";
remove.dataset.manualDelete = "true";
remove.disabled = !manual.canDelete;
const removeAll = manualElement("button", "", "전체 삭제");
removeAll.type = "button";
removeAll.dataset.manualDeleteAll = "true";
removeAll.disabled = manual.writesQuarantined === true;
const add = manualElement("button", "manual-primary", "편성표에 추가");
add.type = "button";
add.dataset.manualActionId = manual.action.actionId;
add.disabled = !manual.canMaterializePlaylist;
actions.append(save, remove, removeAll, add);
editor.appendChild(actions);
if (manual.lastMessage) {
editor.appendChild(manualElement(
"div",
manual.writesQuarantined || manual.listRejectedItemCount > 0 ||
manual.listIsTruncated === true
? "manual-financial-warning"
: "manual-financial-message",
manual.lastMessage));
}
body.append(list, editor);
manualWorkspace.replaceChildren(toolbar, body);
manualClose.disabled = false;
manualExit.disabled = manualClose.disabled;
setTransientControlsBusy(manualDialog, state.isBusy === true);
manualFinancialRenderStateKey = renderStateKey;
if (state.isBusy !== true &&
restoreSearchDraftFocus(manualWorkspace, manualFinancialSearchFocus)) {
manualFinancialSearchFocus = null;
}
}
function isCanonicalNamedPlaylistTitle(value) {
return value.length > 0 && value.length <= 128 && value === value.trim() &&
!/[\u0000-\u001f\u007f-\u009f\ud800-\udfff\ufffd]/.test(value);
}
function renderManualLists(state) {
const manual = state.manualLists;
const fixedBatch = state.fixedSectionBatch;
manualListButtons.forEach(function (button) {
button.disabled = state.isBusy === true || fixedBatch != null;
});
if (!manual || manual.isOpen !== true) {
clearManualViResultPendingSelection(false);
setTransientControlsBusy(manualListDialog, false);
manualListModal.hidden = true;
manualListWorkspace.replaceChildren();
forgetSearchDraft("manual-vi-search");
manualListSearchFocus = null;
manualListRenderStateKey = "";
return;
}
if (manual.screen === "vi") {
const capturedSearchFocus = captureSearchDraftFocus(manualListWorkspace);
if (capturedSearchFocus) manualListSearchFocus = capturedSearchFocus;
} else {
clearManualViResultPendingSelection(false);
forgetSearchDraft("manual-vi-search");
manualListSearchFocus = null;
}
const renderStateKey = JSON.stringify({
manual: manual,
fixedBatch: fixedBatch
});
if (manualViResultPendingSelection) {
if (manualViResultPendingSelection.sent && state.isBusy !== true) {
const accepted = manual.selectedSearchResultId ===
manualViResultPendingSelection.key;
clearManualViResultPendingSelection(!accepted);
} else if (!manualViResultPendingSelection.sent && manualListRenderStateKey &&
renderStateKey !== manualListRenderStateKey) {
clearManualViResultPendingSelection(false);
} else {
applyPendingRowSelection(manualViResultPendingSelection, false);
}
}
if (!manualListModal.hidden && renderStateKey === manualListRenderStateKey) {
setTransientControlsBusy(manualListDialog, state.isBusy === true);
if (state.isBusy !== true && manual.screen === "vi" &&
restoreSearchDraftFocus(manualListWorkspace, manualListSearchFocus)) {
manualListSearchFocus = null;
}
return;
}
setTransientControlsBusy(manualListDialog, false);
if (manualListModal.hidden) rememberModalOpener(manualListDialog);
manualListModal.hidden = false;
const busy = state.isBusy === true;
if (manual.screen !== "vi") {
clearManualViResultPendingSelection(false);
}
const audienceLabels = {
individual: "개인",
foreign: "외국인",
institution: "기관"
};
const baseTitle = manual.screen === "vi"
? "VI 발동 수동 목록"
: (audienceLabels[manual.audience] || "수동") + " 순매도 상위";
manualListTitle.textContent = fixedBatch
? baseTitle + " · " + fixedBatch.sectionName + " " +
String(fixedBatch.manualStepNumber) + "/" + String(fixedBatch.manualStepCount)
: baseTitle;
const fragment = document.createDocumentFragment();
if (fixedBatch) {
const progress = manualElement(
"div",
"manual-list-status",
"섹션 전체 " + String(fixedBatch.totalActionCount) + "개는 아직 편성표에 추가되지 않았습니다. " +
"현재 항목을 확인하면 C#이 다음 수동 항목으로 진행하며, 닫으면 배치를 취소합니다.");
progress.setAttribute("role", "status");
fragment.appendChild(progress);
}
if (manual.writesQuarantined || manual.isAvailable !== true) {
const warning = manualElement(
"div",
"manual-list-status error",
manual.statusMessage || manual.availabilityMessage ||
"수동 목록 저장소를 사용할 수 없습니다.");
warning.setAttribute("role", "alert");
fragment.appendChild(warning);
}
if (manual.screen === "net-sell") {
const tabs = manualElement("div", "manual-list-tabs");
Object.keys(audienceLabels).forEach(function (audience) {
const button = manualElement(
"button",
audience === manual.audience ? "active" : "",
audienceLabels[audience]);
button.type = "button";
button.disabled = fixedBatch != null;
button.addEventListener("click", function () {
send("open-manual-list", { screen: audience });
});
tabs.appendChild(button);
});
fragment.appendChild(tabs);
const wrap = manualElement("div", "manual-list-table-wrap");
const table = manualElement("table", "manual-list-table");
const head = document.createElement("thead");
const headRow = document.createElement("tr");
["#", "좌측 종목", "좌측 금액", "우측 종목", "우측 금액"].forEach(function (label) {
headRow.appendChild(manualElement("th", "", label));
});
head.appendChild(headRow);
table.appendChild(head);
const body = document.createElement("tbody");
const fields = ["leftName", "leftAmount", "rightName", "rightAmount"];
manual.netRows.forEach(function (row, index) {
const tr = document.createElement("tr");
tr.appendChild(manualElement("th", "", String(index + 1)));
fields.forEach(function (field) {
const td = document.createElement("td");
const input = document.createElement("input");
input.type = "text";
input.maxLength = 256;
input.value = row[field] || "";
input.disabled = manual.isAvailable !== true || manual.writesQuarantined;
input.addEventListener("change", function () {
setTransientControlsBusy(manualListDialog, true);
const sent = send("set-manual-net-sell-cell", {
rowId: row.rowId,
field: field,
value: input.value
});
if (!sent) setTransientControlsBusy(manualListDialog, false);
});
td.appendChild(input);
tr.appendChild(td);
});
body.appendChild(tr);
});
table.appendChild(body);
wrap.appendChild(table);
fragment.appendChild(wrap);
} else {
const search = manualElement("div", "manual-list-search");
const input = document.createElement("input");
input.type = "search";
input.id = "manual-vi-search";
input.maxLength = 100;
input.placeholder = "종목명 검색";
syncSearchDraft(input, manual.searchQuery, "manual-list:vi");
const searchButton = manualElement("button", "", "검색");
searchButton.type = "button";
searchButton.disabled = manual.isAvailable !== true;
function submitSearch() {
send("search-manual-vi", { query: input.value });
}
searchButton.addEventListener("click", submitSearch);
input.addEventListener("keydown", function (event) {
if (event.key === "Enter" && !isImeComposing(event)) {
event.preventDefault();
submitSearch();
}
});
search.append(input, searchButton);
fragment.appendChild(search);
const results = manualElement("div", "manual-list-search-results");
results.setAttribute("aria-label", "VI 종목 검색 결과");
let selectedViResult = null;
manual.searchResults.forEach(function (row) {
const isSelected = manual.selectedSearchResultId === row.resultId;
const button = manualElement(
"button",
isSelected ? "selected" : "",
row.displayName + " · " + row.marketText);
button.type = "button";
button.id = "manual-vi-result-" + row.resultId;
button.dataset.manualViResultId = row.resultId;
button.disabled = false;
button.setAttribute("aria-selected", isSelected ? "true" : "false");
if (isSelected) selectedViResult = button;
button.addEventListener("click", function () {
clearDelayedClick(manualViResultClickTimer);
const pending = beginManualViResultPendingSelection(button, row.resultId);
manualViResultClickTimer = deferLegacySingleClick(function () {
manualViResultClickTimer = null;
if (!pending || manualViResultPendingSelection !== pending) return;
if (send("select-manual-vi-result", { resultId: row.resultId })) {
pending.sent = true;
} else {
clearManualViResultPendingSelection(true);
}
});
});
button.addEventListener("dblclick", function (event) {
event.preventDefault();
clearDelayedClick(manualViResultClickTimer);
manualViResultClickTimer = null;
const pending = manualViResultPendingSelection &&
manualViResultPendingSelection.key === row.resultId
? manualViResultPendingSelection
: beginManualViResultPendingSelection(button, row.resultId);
if (send("add-manual-vi-result", { resultId: row.resultId })) {
if (pending) pending.sent = true;
} else {
clearManualViResultPendingSelection(true);
}
});
results.appendChild(button);
});
configureRovingList(
results,
Array.from(results.querySelectorAll("button[data-manual-vi-result-id]")),
selectedViResult);
fragment.appendChild(results);
if (manual.searchIsTruncated === true) {
const truncationWarning = manualElement(
"div",
"manual-list-status warning",
"검색 결과 일부만 표시 중입니다. 검색어를 더 구체적으로 입력해 주세요.");
truncationWarning.setAttribute("role", "status");
fragment.appendChild(truncationWarning);
}
const summary = manualElement(
"div",
"manual-list-status",
String(manual.viItems.length) + "/100종목 · " +
String(manual.viPageCount) + "페이지");
fragment.appendChild(summary);
const wrap = manualElement("div", "manual-list-table-wrap");
const table = manualElement("table", "manual-list-table");
const body = document.createElement("tbody");
manual.viItems.forEach(function (row, index) {
const tr = document.createElement("tr");
tr.append(
manualElement("td", "", String(row.index)),
manualElement("td", "", row.name));
const controls = manualElement("td", "manual-list-row-actions");
[["↑", "up"], ["↓", "down"]].forEach(function (move) {
const button = manualElement("button", "", move[0]);
button.type = "button";
button.disabled = move[1] === "up"
? index === 0
: index === manual.viItems.length - 1;
button.addEventListener("click", function () {
send("move-manual-vi-item", { itemId: row.itemId, direction: move[1] });
});
controls.appendChild(button);
});
const remove = manualElement("button", "", "삭제");
remove.type = "button";
remove.disabled = false;
remove.addEventListener("click", function () {
send("delete-manual-vi-item", { itemId: row.itemId });
});
controls.appendChild(remove);
tr.appendChild(controls);
body.appendChild(tr);
});
table.appendChild(body);
wrap.appendChild(table);
fragment.appendChild(wrap);
}
if (manual.statusMessage) {
fragment.appendChild(manualElement(
"div",
"manual-list-status" +
(manual.statusKind === "error" || manual.writesQuarantined ? " error" : ""),
manual.statusMessage));
}
const actions = manualElement("div", "manual-list-actions-row");
const importButton = manualElement("button", "", "원본 1회 가져오기");
importButton.type = "button";
importButton.disabled = manual.isAvailable !== true || manual.writesQuarantined;
importButton.addEventListener("click", function () {
if (window.confirm("원본 파일을 현재 빈 저장소로 한 번만 가져옵니다. 기존 파일은 덮어쓰지 않습니다.")) {
send("import-manual-lists", {});
}
});
const refresh = manualElement("button", "", "재조회");
refresh.type = "button";
refresh.disabled = manual.isAvailable !== true;
refresh.addEventListener("click", function () { send("refresh-manual-list", {}); });
if (manual.screen === "vi") {
const clear = manualElement("button", "", "전체 삭제");
clear.type = "button";
clear.addEventListener("click", function () {
send("delete-all-manual-vi-items", {});
});
actions.appendChild(clear);
}
const confirm = manualElement("button", "primary", "확인");
confirm.type = "button";
if (fixedBatch) {
confirm.textContent = fixedBatch.manualStepNumber < fixedBatch.manualStepCount
? "확인 후 다음"
: "확인 후 섹션 추가";
}
confirm.disabled = manual.canSave !== true;
confirm.addEventListener("click", function () {
send("confirm-manual-list", {});
});
actions.append(importButton, refresh, confirm);
fragment.appendChild(actions);
manualListWorkspace.replaceChildren(fragment);
manualListClose.disabled = false;
manualListCancel.disabled = manualListClose.disabled;
setTransientControlsBusy(manualListDialog, busy);
manualListRenderStateKey = renderStateKey;
if (!busy && manual.screen === "vi" &&
restoreSearchDraftFocus(manualListWorkspace, manualListSearchFocus)) {
manualListSearchFocus = null;
}
}
function openNamedPlaylist(mode) {
if (operatorMutationLocked ||
(mode === "load" && playlistStructureMutationLocked)) return false;
namedPlaylistSelection.cancel();
namedPlaylistMode = mode;
namedPlaylistTitle.textContent = mode === "save"
? "DB 재생목록 저장" : "DB 재생목록 불러오기";
namedPlaylistConfirmButton.textContent = mode === "save" ? "현재 목록 저장" : "불러오기";
namedPlaylistNewTitle.value = "";
if (namedPlaylistModal.hidden) rememberModalOpener(namedPlaylistDialog);
namedPlaylistModal.hidden = false;
if (namedPlaylistState) {
renderNamedPlaylist({ namedPlaylist: namedPlaylistState, isBusy: uiBusy });
}
syncModalFocus();
beginNamedPlaylistBusyCycle("refresh-named-playlists", {});
return true;
}
function closeNamedPlaylist() {
if (isNamedPlaylistModalLocked() || namedPlaylistSelection.isPending()) return false;
namedPlaylistMode = null;
namedPlaylistModal.hidden = true;
syncModalFocus();
return true;
}
function isNamedPlaylistModalLocked() {
return uiBusy || pendingNamedPlaylistCommand !== null ||
namedPlaylistConfirmation !== null ||
!!(namedPlaylistState && namedPlaylistState.isWriteInProgress === true);
}
function isNamedPlaylistActionLocked() {
return isNamedPlaylistModalLocked() || operatorMutationLocked ||
namedPlaylistSelection.isPending();
}
function isNamedPlaylistDefinitionLocked() {
return operatorMutationLocked || pendingNamedPlaylistCommand !== null ||
namedPlaylistConfirmation !== null ||
!!(namedPlaylistState && namedPlaylistState.isWriteInProgress === true) ||
(uiBusy && !namedPlaylistSelection.isPending());
}
function markNamedPlaylistVisualSelection(definitionId) {
namedPlaylistDefinitions
.querySelectorAll("button[data-named-playlist-definition-id]")
.forEach(function (button) {
const selected = button.dataset.namedPlaylistDefinitionId === definitionId;
button.setAttribute("aria-selected", selected ? "true" : "false");
button.classList.toggle("selected", selected);
});
}
function lockNamedPlaylistSelectionActions() {
namedPlaylistClose.disabled = true;
namedPlaylistCancelButton.disabled = true;
namedPlaylistNewTitle.disabled = true;
namedPlaylistCreateButton.disabled = true;
namedPlaylistDeleteButton.disabled = true;
namedPlaylistConfirmButton.disabled = true;
}
function beginNamedPlaylistSelection(definitionId) {
if (namedPlaylistSelection.isPending()) {
const pending = namedPlaylistSelection.begin(
definitionId,
namedPlaylistState && namedPlaylistState.selectedDefinitionId);
if (pending.accepted) markNamedPlaylistVisualSelection(definitionId);
return pending.accepted;
}
if (isNamedPlaylistModalLocked() || operatorMutationLocked) return false;
const requestId = "named-ui-" + String(nextNamedPlaylistRequestId++);
const started = namedPlaylistSelection.begin(
definitionId,
namedPlaylistState && namedPlaylistState.selectedDefinitionId,
requestId,
lastCommandSequence + 1);
if (!started.accepted) return false;
markNamedPlaylistVisualSelection(definitionId);
if (!started.shouldSend) return true;
lockNamedPlaylistSelectionActions();
if (!send("select-named-playlist", {
definitionId: definitionId,
requestId: started.requestId
})) {
namedPlaylistSelection.cancel();
restoreNamedPlaylistAfterConfirmation();
return false;
}
return true;
}
function runNamedPlaylistDoubleClick(definitionId, mode) {
if (mode === "save") {
return openNamedPlaylistConfirmation({
kind: "save",
definitionId: definitionId,
message: "저장하겠습니까?",
showSavedAlert: false,
closeOnNo: true
});
}
return beginNamedPlaylistCommand("load-named-playlist-by-id", definitionId, false);
}
function lockNamedPlaylistControls() {
namedPlaylistModal.querySelectorAll("button, input").forEach(function (control) {
control.disabled = true;
});
}
function beginNamedPlaylistBusyCycle(type, payload) {
if (isNamedPlaylistActionLocked()) return false;
const requestId = "named-ui-" + String(nextNamedPlaylistRequestId++);
pendingNamedPlaylistCommand = {
command: type,
targetId: requestId,
minimumSequence: lastCommandSequence + 1,
closeOnSuccess: false,
showSavedAlert: false
};
const correlatedPayload = Object.assign({}, payload || {}, { requestId: requestId });
if (!send(type, correlatedPayload)) {
pendingNamedPlaylistCommand = null;
return false;
}
lockNamedPlaylistControls();
return true;
}
function beginNamedPlaylistCommand(command, definitionId, showSavedAlert) {
if (isNamedPlaylistActionLocked() || !definitionId) return false;
const payload = { definitionId: definitionId };
if (command === "save-current-named-playlist-to") {
payload.showSavedAlert = showSavedAlert === true;
}
pendingNamedPlaylistCommand = {
command: command,
targetId: definitionId,
definitionId: definitionId,
minimumSequence: lastCommandSequence + 1,
closeOnSuccess: true
};
if (!send(command, payload)) {
pendingNamedPlaylistCommand = null;
return false;
}
lockNamedPlaylistControls();
return true;
}
function isMatchingNamedPlaylistReceipt(busy, pending, commandResult) {
const exact = !!pending && !!commandResult &&
Number.isSafeInteger(commandResult.sequence) &&
commandResult.sequence >= pending.minimumSequence &&
commandResult.command === pending.command &&
commandResult.targetId === pending.targetId;
return exact && (commandResult.succeeded === false ||
(busy !== true && commandResult.succeeded === true));
}
function openNamedPlaylistConfirmation(options) {
if (!options || isNamedPlaylistActionLocked()) return false;
namedPlaylistConfirmation = options;
namedPlaylistConfirmationMessage.textContent = options.message;
if (namedPlaylistConfirmationModal.hidden) {
rememberModalOpener(namedPlaylistConfirmationDialog);
}
namedPlaylistConfirmationModal.hidden = false;
lockNamedPlaylistControls();
// The confirmation can open immediately after a correlated DB refresh or
// selection receipt. Re-establish its own controls here so a transient
// busy render cannot leave the affirmative action stale-disabled.
namedPlaylistConfirmationYes.disabled = false;
namedPlaylistConfirmationNo.disabled = false;
syncModalFocus();
return true;
}
function restoreNamedPlaylistAfterConfirmation() {
if (!namedPlaylistState || namedPlaylistModal.hidden) return;
renderNamedPlaylist({
namedPlaylist: namedPlaylistState,
isBusy: uiBusy,
commandResult: null,
statusKind: null
});
}
function resolveNamedPlaylistConfirmation(confirmed) {
const confirmation = namedPlaylistConfirmation;
if (!confirmation) return false;
namedPlaylistConfirmation = null;
namedPlaylistConfirmationModal.hidden = true;
syncModalFocus();
if (!confirmed) {
if (confirmation.closeOnNo === true) closeNamedPlaylist();
else restoreNamedPlaylistAfterConfirmation();
return true;
}
const started = confirmation.kind === "delete"
? beginNamedPlaylistBusyCycle("delete-selected-named-playlist", {})
: beginNamedPlaylistCommand(
"save-current-named-playlist-to",
confirmation.definitionId,
confirmation.showSavedAlert === true);
if (!started) restoreNamedPlaylistAfterConfirmation();
return started;
}
function renderNamedPlaylist(state) {
const named = state.namedPlaylist;
namedPlaylistState = named || null;
const busy = state.isBusy === true;
const commandResult = state.commandResult;
const selectionResult = namedPlaylistSelection.reconcile(
commandResult,
named && named.selectedDefinitionId);
const deferredSelectionAction = selectionResult.deferredAction;
if (commandResult && Number.isSafeInteger(commandResult.sequence)) {
lastCommandSequence = Math.max(lastCommandSequence, commandResult.sequence);
}
if (isMatchingNamedPlaylistReceipt(
busy,
pendingNamedPlaylistCommand,
commandResult
)) {
const completed = pendingNamedPlaylistCommand;
const succeeded = commandResult.succeeded === true;
pendingNamedPlaylistCommand = null;
if (succeeded && completed.closeOnSuccess) {
closeNamedPlaylist();
}
}
dbLoadButton.disabled = playlistStructureMutationLocked || busy || !named;
dbSaveButton.disabled = operatorMutationLocked || busy || !named ||
named.canMutate !== true;
if (!named || namedPlaylistModal.hidden) return;
const modalLocked = isNamedPlaylistActionLocked();
const definitionLocked = isNamedPlaylistDefinitionLocked();
const existingDefinitions = new Map();
namedPlaylistDefinitions
.querySelectorAll("button[data-named-playlist-definition-id]")
.forEach(function (button) {
existingDefinitions.set(button.dataset.namedPlaylistDefinitionId, button);
});
const fragment = document.createDocumentFragment();
let selectedDefinition = null;
named.definitions.forEach(function (definition) {
const button = existingDefinitions.get(definition.definitionId) ||
document.createElement("button");
button.type = "button";
button.dataset.namedPlaylistDefinitionId = definition.definitionId;
button.setAttribute("role", "option");
button.setAttribute("aria-selected", definition.isSelected ? "true" : "false");
button.className = definition.isSelected ? "selected" : "";
button.disabled = definitionLocked;
button.textContent = definition.title;
if (definition.isSelected) selectedDefinition = button;
fragment.appendChild(button);
});
namedPlaylistDefinitions.replaceChildren(fragment);
configureRovingList(
namedPlaylistDefinitions,
Array.from(namedPlaylistDefinitions.querySelectorAll(
"button[data-named-playlist-definition-id]")),
selectedDefinition);
const selectedDescription = named.selectedTitle
? "선택: " + named.selectedTitle + " · " + String(named.rowCount) + "행"
: "DB 재생목록을 선택하세요.";
const quarantine = named.isWriteQuarantined === true
? " 결과 불명확 상태로 이번 실행의 추가 쓰기가 차단되었습니다." : "";
const truncated = named.isListTruncated === true
? " 목록 일부만 표시 중입니다." : "";
namedPlaylistStatus.textContent = (named.statusMessage || selectedDescription) +
quarantine + truncated;
namedPlaylistStatus.classList.toggle("error", named.isWriteQuarantined === true ||
named.statusKind === "error");
const closeLocked = isNamedPlaylistModalLocked() ||
namedPlaylistSelection.isPending();
namedPlaylistClose.disabled = closeLocked;
namedPlaylistCancelButton.disabled = closeLocked;
namedPlaylistNewTitle.disabled = modalLocked;
namedPlaylistCreateButton.disabled = modalLocked || named.canCreate !== true ||
!isCanonicalNamedPlaylistTitle(namedPlaylistNewTitle.value);
namedPlaylistDeleteButton.disabled = modalLocked || named.canDelete !== true;
namedPlaylistConfirmButton.disabled = modalLocked ||
(namedPlaylistMode === "save" ? named.canSave !== true : named.canLoad !== true);
// A PROGRAM auto-refresh can begin between pointer-down and pointer-up.
// Keep the already-open confirmation clickable through unrelated busy
// state; the native ordered gate serializes the request and performs the
// authoritative mutation-safety check before any database write.
namedPlaylistConfirmationYes.disabled = pendingNamedPlaylistCommand !== null ||
!!(namedPlaylistState && namedPlaylistState.isWriteInProgress === true);
if (deferredSelectionAction) {
runNamedPlaylistDoubleClick(
deferredSelectionAction.definitionId,
deferredSelectionAction.mode);
}
}
function clearOperatorCatalogDragVisuals() {
operatorCatalogDraftRows.querySelectorAll(".dragging, .drag-before, .drag-after")
.forEach(function (row) {
row.classList.remove("dragging", "drag-before", "drag-after");
});
}
function onOperatorCatalogPointerDown(event) {
if (event.button !== 0) return;
const rowId = event.currentTarget.dataset.operatorCatalogRowId;
operatorCatalogDragBlockedRowId = event.target.closest("button, input")
? rowId
: null;
}
function onOperatorCatalogPointerEnd(event) {
if (operatorCatalogDragBlockedRowId ===
event.currentTarget.dataset.operatorCatalogRowId) {
operatorCatalogDragBlockedRowId = null;
}
}
function onOperatorCatalogDragStart(event) {
const row = event.currentTarget;
const sourceRowId = row.dataset.operatorCatalogRowId;
if (operatorCatalogMutationLocked || !sourceRowId || !event.dataTransfer ||
operatorCatalogDragBlockedRowId === sourceRowId ||
event.target.closest("button, input")) {
operatorCatalogDragBlockedRowId = null;
event.preventDefault();
return;
}
operatorCatalogDragBlockedRowId = null;
draggedOperatorCatalogRowId = sourceRowId;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(operatorCatalogDragMime, sourceRowId);
row.classList.add("dragging");
}
function onOperatorCatalogDragOver(event) {
const target = event.currentTarget;
if (operatorCatalogMutationLocked || !draggedOperatorCatalogRowId ||
target.dataset.operatorCatalogRowId === draggedOperatorCatalogRowId) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
const position = dragDropPosition(event, target);
target.classList.toggle("drag-before", position === "before");
target.classList.toggle("drag-after", position === "after");
}
function onOperatorCatalogDragLeave(event) {
event.currentTarget.classList.remove("drag-before", "drag-after");
}
function onOperatorCatalogDrop(event) {
const target = event.currentTarget;
const sourceRowId = draggedOperatorCatalogRowId;
const targetRowId = target.dataset.operatorCatalogRowId;
if (operatorCatalogMutationLocked || !sourceRowId || !targetRowId ||
sourceRowId === targetRowId || !event.dataTransfer ||
event.dataTransfer.getData(operatorCatalogDragMime) !== sourceRowId) {
draggedOperatorCatalogRowId = null;
clearOperatorCatalogDragVisuals();
return;
}
event.preventDefault();
const position = dragDropPosition(event, target);
draggedOperatorCatalogRowId = null;
clearOperatorCatalogDragVisuals();
send("reorder-operator-catalog-draft-row", {
sourceRowId: sourceRowId,
targetRowId: targetRowId,
position: position
});
}
function onOperatorCatalogDragEnd() {
draggedOperatorCatalogRowId = null;
operatorCatalogDragBlockedRowId = null;
clearOperatorCatalogDragVisuals();
}
function reconcileOperatorCatalogStockResults(rows) {
const existing = new Map();
Array.from(operatorCatalogStockResults.children).forEach(function (button) {
existing.set(button.dataset.operatorCatalogStockResultId, button);
});
(rows || []).forEach(function (row, position) {
let button = existing.get(row.resultId);
if (!button) {
button = document.createElement("button");
button.type = "button";
button.dataset.operatorCatalogStockResultId = row.resultId;
button.title = "더블클릭하면 구성 종목에 추가합니다.";
button.setAttribute("role", "option");
const name = document.createElement("span");
name.className = "operator-catalog-stock-result-name";
const market = document.createElement("small");
market.className = "operator-catalog-stock-result-market";
button.append(name, market);
}
const buttonAtPosition = operatorCatalogStockResults.children.item(position);
if (buttonAtPosition !== button) {
operatorCatalogStockResults.insertBefore(button, buttonAtPosition);
}
existing.delete(row.resultId);
button.className = row.isSelected ? "selected" : "";
button.disabled = row.isCompatible !== true;
button.setAttribute("aria-selected", row.isSelected ? "true" : "false");
button.querySelector(".operator-catalog-stock-result-name").textContent = row.displayName;
button.querySelector(".operator-catalog-stock-result-market").textContent = row.market;
});
existing.forEach(function (button) { button.remove(); });
const stockRows = Array.from(operatorCatalogStockResults.querySelectorAll(
"button[data-operator-catalog-stock-result-id]"));
configureRovingList(
operatorCatalogStockResults,
stockRows,
stockRows.find(function (button) {
return button.getAttribute("aria-selected") === "true";
}) || null);
}
function renderOperatorCatalog(state) {
const catalog = state.operatorCatalog;
const busy = state.isBusy === true;
operatorCatalogMutationLocked = isOperatorMutationLocked(state);
operatorCatalogState = catalog || null;
if (!catalog || catalog.isOpen !== true) {
setTransientControlsBusy(operatorCatalogDialog, false);
draggedOperatorCatalogRowId = null;
operatorCatalogDragBlockedRowId = null;
clearOperatorCatalogDragVisuals();
operatorCatalogModal.hidden = true;
operatorCatalogDraftKey = "";
operatorCatalogNameDraft = "";
forgetSearchDraft(
"operator-catalog-query",
"operator-catalog-stock-query");
operatorCatalogSearchFocus = null;
operatorCatalogNameStatus.textContent = "";
operatorCatalogNameStatus.className = "";
operatorCatalogRenderStateKey = "";
return;
}
const capturedSearchFocus = captureSearchDraftFocus(operatorCatalogDialog);
if (capturedSearchFocus) operatorCatalogSearchFocus = capturedSearchFocus;
if (operatorCatalogMutationLocked) {
draggedOperatorCatalogRowId = null;
operatorCatalogDragBlockedRowId = null;
clearOperatorCatalogDragVisuals();
}
const renderStateKey = JSON.stringify(catalog);
if (!operatorCatalogModal.hidden && renderStateKey === operatorCatalogRenderStateKey) {
setTransientControlsBusy(operatorCatalogDialog, busy);
operatorCatalogDraftRows.querySelectorAll("[data-operator-catalog-row-id]")
.forEach(function (row) {
row.draggable = !operatorCatalogMutationLocked;
row.setAttribute(
"aria-disabled",
operatorCatalogMutationLocked ? "true" : "false");
});
if (!busy &&
restoreSearchDraftFocus(operatorCatalogDialog, operatorCatalogSearchFocus)) {
operatorCatalogSearchFocus = null;
}
return;
}
setTransientControlsBusy(operatorCatalogDialog, false);
if (operatorCatalogModal.hidden) rememberModalOpener(operatorCatalogDialog);
operatorCatalogModal.hidden = false;
const isTheme = catalog.entity === "theme";
const editing = catalog.mode === "create" || catalog.mode === "edit";
operatorCatalogTitle.textContent = isTheme
? "테마추가"
: catalog.mode === "create" ? "전문가 추가" : "전문가 추천 편집";
operatorCatalogThemeTab.classList.toggle("selected", isTheme);
operatorCatalogExpertTab.classList.toggle("selected", !isTheme);
operatorCatalogThemeTab.disabled = editing;
operatorCatalogExpertTab.disabled = editing;
operatorCatalogToolbar.hidden = editing;
operatorCatalogListPanel.hidden = editing;
operatorCatalogBody.classList.toggle("editor-only", editing);
operatorCatalogClose.disabled = false;
operatorCatalogSearch.disabled = false;
operatorCatalogSession.disabled = !isTheme;
operatorCatalogSession.hidden = !isTheme;
operatorCatalogNewMarket.disabled = !isTheme;
operatorCatalogNewMarket.hidden = !isTheme;
operatorCatalogNew.disabled = catalog.canBeginCreate !== true;
operatorCatalogNew.textContent = isTheme ? "새 ThemeA" : "새 EList";
syncSearchDraft(
operatorCatalogQuery,
catalog.query,
"operator-catalog:" + catalog.entity);
operatorCatalogSession.value = catalog.nxtSession === "afterMarket"
? "afterMarket" : "preMarket";
operatorCatalogNewMarket.value = catalog.newThemeMarket === "nxt" ? "nxt" : "krx";
const resultFragment = document.createDocumentFragment();
(catalog.results || []).forEach(function (row) {
const button = document.createElement("button");
button.type = "button";
button.dataset.operatorCatalogResultId = row.resultId;
button.className = row.isSelected ? "selected" : "";
button.disabled = false;
button.setAttribute("role", "option");
button.setAttribute("aria-selected", row.isSelected ? "true" : "false");
const code = document.createElement("strong");
code.textContent = row.codeLabel;
const name = document.createElement("span");
name.textContent = row.displayName;
const scope = document.createElement("small");
scope.textContent = row.scope;
button.append(code, name, scope);
resultFragment.appendChild(button);
});
operatorCatalogResults.replaceChildren(resultFragment);
const catalogRows = Array.from(operatorCatalogResults.querySelectorAll(
"button[data-operator-catalog-result-id]"));
configureRovingList(
operatorCatalogResults,
catalogRows,
catalogRows.find(function (button) {
return button.getAttribute("aria-selected") === "true";
}) || null);
operatorCatalogEditor.hidden = !editing;
const canChangeCreateThemeMarket = isTheme && catalog.mode === "create";
operatorCatalogEditorMarketField.hidden = !canChangeCreateThemeMarket;
operatorCatalogEditorMarket.disabled = !canChangeCreateThemeMarket;
operatorCatalogEditorMarket.value = catalog.newThemeMarket === "nxt" ? "nxt" : "krx";
const draftKey = catalog.entity + "|" + catalog.mode + "|" +
(catalog.selectedResultId || "new") + "|" + (catalog.codeLabel || "");
if (draftKey !== operatorCatalogDraftKey) {
operatorCatalogDraftKey = draftKey;
operatorCatalogNameDraft = catalog.editorName || "";
}
if (document.activeElement !== operatorCatalogName) {
operatorCatalogName.value = operatorCatalogNameDraft;
}
operatorCatalogCode.textContent = catalog.codeLabel
? "코드 " + catalog.codeLabel : "";
operatorCatalogNameLabel.textContent = isTheme ? "테마명" : "전문가명";
operatorCatalogName.placeholder = isTheme ? "ThemeA 이름" : "전문가 이름";
operatorCatalogName.disabled = !editing ||
(!isTheme && catalog.mode === "edit");
const currentThemeName = operatorCatalogNameDraft.trim();
const checkedCurrentThemeName = isTheme && currentThemeName.length > 0 &&
catalog.checkedThemeName === currentThemeName;
const themeNameAvailability = checkedCurrentThemeName
? catalog.themeNameAvailability
: "notChecked";
operatorCatalogNameCheck.hidden = !isTheme;
operatorCatalogNameCheck.disabled = !isTheme || !editing ||
catalog.canCheckThemeName !== true || currentThemeName.length === 0;
operatorCatalogNameStatus.hidden = !isTheme;
operatorCatalogNameStatus.className = themeNameAvailability === "available" ||
themeNameAvailability === "duplicate" || themeNameAvailability === "indeterminate"
? themeNameAvailability
: "";
operatorCatalogNameStatus.textContent = themeNameAvailability === "available"
? "사용 가능"
: themeNameAvailability === "duplicate"
? "이미 사용 중"
: themeNameAvailability === "indeterminate"
? "확인 불가"
: "";
syncSearchDraft(
operatorCatalogStockQuery,
catalog.stockQuery,
"operator-catalog-stock:" + draftKey);
operatorCatalogStockQuery.disabled = !editing;
operatorCatalogStockSearch.disabled = !editing;
const showCatalogRows = isTheme || catalog.mode === "edit";
operatorCatalogStockSearchLine.hidden = !showCatalogRows;
operatorCatalogStockResults.hidden = !showCatalogRows;
operatorCatalogAddLine.hidden = !showCatalogRows;
operatorCatalogDraftHeader.hidden = !showCatalogRows;
operatorCatalogDraftRows.hidden = !showCatalogRows;
// ThemeA and UC6 both add the selected stock immediately. UC6 then lets
// the operator fill the buy amount in column 3 of the newly added row.
operatorCatalogAmountLabel.hidden = true;
operatorCatalogAmount.hidden = true;
operatorCatalogAmount.disabled = true;
operatorCatalogAddStock.disabled = catalog.canAddStock !== true;
reconcileOperatorCatalogStockResults(catalog.stockResults);
const focusedDraftRow = document.activeElement &&
document.activeElement.closest("[data-operator-catalog-row-id]");
const restoreDraftFocus = focusedDraftRow &&
operatorCatalogDraftRows.contains(focusedDraftRow);
const draftFragment = document.createDocumentFragment();
(catalog.draftRows || []).forEach(function (row, index) {
const line = document.createElement("div");
const isActive = row.rowId === catalog.activeDraftRowId;
line.className = "operator-catalog-draft-row" + (isActive ? " selected" : "");
line.dataset.operatorCatalogRowId = row.rowId;
line.tabIndex = isActive || (!catalog.activeDraftRowId && index === 0) ? 0 : -1;
line.setAttribute("role", "row");
line.setAttribute("aria-selected", isActive ? "true" : "false");
line.draggable = !operatorCatalogMutationLocked;
line.setAttribute(
"aria-disabled",
operatorCatalogMutationLocked ? "true" : "false");
line.addEventListener("pointerdown", onOperatorCatalogPointerDown);
line.addEventListener("pointerup", onOperatorCatalogPointerEnd);
line.addEventListener("pointercancel", onOperatorCatalogPointerEnd);
line.addEventListener("dragstart", onOperatorCatalogDragStart);
line.addEventListener("dragover", onOperatorCatalogDragOver);
line.addEventListener("dragleave", onOperatorCatalogDragLeave);
line.addEventListener("drop", onOperatorCatalogDrop);
line.addEventListener("dragend", onOperatorCatalogDragEnd);
let selectionSent = false;
function selectDraftRow() {
if (!selectionSent && row.rowId !== catalog.activeDraftRowId) {
selectionSent = true;
send("select-operator-catalog-draft-row", { rowId: row.rowId });
}
}
line.addEventListener("focus", selectDraftRow);
line.addEventListener("click", function (event) {
if (!event.target.closest("button, input")) {
line.focus({ preventScroll: true });
selectDraftRow();
}
});
const order = document.createElement("span");
order.textContent = String(row.position + 1);
const name = document.createElement("strong");
name.textContent = row.displayName;
const detail = document.createElement(isTheme ? "small" : "input");
if (isTheme) {
detail.textContent = row.market;
} else {
detail.type = "number";
detail.min = "1";
detail.step = "1";
detail.inputMode = "numeric";
detail.value = row.buyAmount == null ? "" : String(row.buyAmount);
detail.dataset.operatorCatalogBuyAmountRowId = row.rowId;
detail.setAttribute("aria-label", row.displayName + " 매수가");
detail.disabled = false;
}
const up = document.createElement("button");
up.type = "button";
up.textContent = "Up";
up.dataset.operatorCatalogMoveRowId = row.rowId;
up.dataset.operatorCatalogMoveDirection = "up";
up.disabled = index === 0;
const down = document.createElement("button");
down.type = "button";
down.textContent = "Down";
down.dataset.operatorCatalogMoveRowId = row.rowId;
down.dataset.operatorCatalogMoveDirection = "down";
down.disabled = index + 1 === catalog.draftRows.length;
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "Delete";
remove.dataset.operatorCatalogRemoveRowId = row.rowId;
remove.disabled = false;
line.append(order, name, detail, up, down, remove);
draftFragment.appendChild(line);
});
operatorCatalogDraftRows.replaceChildren(draftFragment);
if (restoreDraftFocus && catalog.activeDraftRowId) {
const activeRow = Array.from(
operatorCatalogDraftRows.querySelectorAll("[data-operator-catalog-row-id]"))
.find(function (row) {
return row.dataset.operatorCatalogRowId === catalog.activeDraftRowId;
});
if (activeRow) activeRow.focus({ preventScroll: true });
}
operatorCatalogSave.disabled = catalog.canSave !== true;
operatorCatalogDeleteAll.hidden = !isTheme || !editing;
operatorCatalogDeleteAll.disabled = !isTheme || !editing ||
(catalog.draftRows || []).length === 0;
operatorCatalogCancel.disabled = !editing;
const messages = [];
if (catalog.statusMessage) messages.push(catalog.statusMessage);
if (catalog.resultsAreTruncated) messages.push("목록 일부만 표시 중입니다.");
if (catalog.stockResultsAreTruncated) messages.push("종목 검색 결과 일부만 표시 중입니다.");
if (catalog.writesQuarantined) {
messages.push("결과 불명확 상태로 현재 프로세스의 추가 DB 쓰기가 차단되었습니다.");
}
operatorCatalogStatus.textContent = messages.join(" ");
operatorCatalogStatus.classList.toggle(
"error",
catalog.writesQuarantined === true || catalog.statusKind === "error");
setTransientControlsBusy(operatorCatalogDialog, busy);
operatorCatalogRenderStateKey = renderStateKey;
if (!busy &&
restoreSearchDraftFocus(operatorCatalogDialog, operatorCatalogSearchFocus)) {
operatorCatalogSearchFocus = null;
}
}
function renderDialog(state) {
if (state.dialog && state.dialog.message) {
legacyDialogIsConfirmation = state.dialog.isConfirmation === true;
dialogCaption.textContent = state.dialog.caption || "MmoneyCoder";
dialogMessage.textContent = state.dialog.message;
dialogOk.textContent = legacyDialogIsConfirmation ? "예" : "확인";
// A one-button result alert is safe to acknowledge while an unrelated
// refresh is finishing. Disabling it between pointer-down and pointer-up
// swallowed real operator clicks; native dispatch now queues one dismissal.
dialogOk.disabled = legacyDialogIsConfirmation && state.isBusy === true;
dialogNo.hidden = !legacyDialogIsConfirmation;
dialogNo.disabled = state.isBusy === true;
if (dialog.hidden) rememberModalOpener(legacyAlertDialog);
dialog.hidden = false;
} else {
legacyDialogIsConfirmation = false;
dialogOk.disabled = false;
dialogNo.hidden = true;
dialogNo.disabled = false;
dialog.hidden = true;
}
}
function updateCutDragSize(state) {
const metrics = state && state.interactionMetrics;
if (!metrics || metrics.coordinateSpace !== "host-dip" ||
!Number.isInteger(metrics.cutDragWidthDips) ||
!Number.isInteger(metrics.cutDragHeightDips) ||
metrics.cutDragWidthDips <= 0 || metrics.cutDragHeightDips <= 0 ||
typeof metrics.hostRasterizationScale !== "number") return;
const nextDragSize = window.LegacyCutDrag.toCssDragSize({
width: metrics.cutDragWidthDips,
height: metrics.cutDragHeightDips
}, metrics.hostRasterizationScale, window.devicePixelRatio);
cutDragSize = nextDragSize;
if (cutDragGesture && !cutDragGesture.started) {
// The cut-pointer-down reply is also a native state boundary. If a
// Windows preference change raced the previous rendered state, update
// the still-pending gesture before its next pointermove decision.
cutDragGesture.rectangle = window.LegacyCutDrag.createDragRectangle(
cutDragGesture.down,
nextDragSize);
}
if (playlistDragGesture && !playlistDragGesture.started) {
playlistDragGesture.rectangle = window.LegacyCutDrag.createDragRectangle(
playlistDragGesture.down,
nextDragSize);
}
}
function operatorSettingsText(value, fallback) {
if (typeof value === "string" && value.trim()) return value.trim();
if (value && typeof value === "object") {
const candidate = value.displayName || value.label || value.status || value.message;
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
}
return fallback;
}
function operatorDiagnosticTone(value, label) {
if (value && typeof value === "object") {
if (value.isConnected === true || value.isHealthy === true) return "good";
if (value.isConnected === false || value.isHealthy === false) return "bad";
}
const normalized = String(label || "").toLowerCase();
if (/연결됨|정상|사용 가능|ready|connected|healthy/.test(normalized)) return "good";
if (/확인 중|재연결|대기|dry run|감지됨|unknown|waiting/.test(normalized)) {
return "warning";
}
if (/끊김|미연결|오류|실패|불명확|disconnected|fault|error|failed/.test(normalized)) {
return "bad";
}
return "neutral";
}
function setOperatorDiagnostic(element, value, fallback) {
const label = operatorSettingsText(value, fallback);
if (element.textContent !== label) element.textContent = label;
const tone = operatorDiagnosticTone(value, label);
if (tone === "neutral") element.removeAttribute("data-state");
else element.dataset.state = tone;
}
function operatorPlayoutModeLabel(playout) {
if (!playout) return "확인 중";
if (playout.mode === "live") return "LIVE";
if (playout.mode === "dryRun") return "DRY RUN";
if (playout.mode === "disabled") return "송출 비활성";
return operatorSettingsText(playout.mode, "확인 중");
}
function operatorPlayoutConnectionLabel(playout) {
if (!playout) return "확인 중";
if (playout.outcomeUnknown === true) return "결과 불명확";
if (playout.mode === "disabled") return "송출 비활성";
if (playout.mode === "dryRun") return "DRY RUN 준비됨";
if (playout.isConnected === true) return "Tornado2 연결됨";
if (playout.isProcessRunning === true) return "Tornado2 감지됨";
return "미연결";
}
function renderOperatorFolder(kind, folder, isBusy) {
const controls = operatorFolderControls[kind];
const current = folder && typeof folder === "object" ? folder : {};
const defaultNames = {
design: "앱 기본값",
resource: "앱 기본값",
background: "자동"
};
const displayName = current.isCustom === true
? operatorSettingsText(current.displayName, defaultNames[kind])
: defaultNames[kind];
if (controls.name.textContent !== displayName) controls.name.textContent = displayName;
controls.name.title = displayName;
const valid = current.isValid === true;
const invalid = current.isValid === false;
const statusLabel = operatorSettingsText(
current.status,
valid ? "사용 가능" : (invalid ? "확인 필요" : "확인 중"));
if (controls.status.textContent !== statusLabel) controls.status.textContent = statusLabel;
controls.status.classList.toggle("valid", valid);
controls.status.classList.toggle("invalid", invalid);
controls.status.classList.toggle("neutral", !valid && !invalid);
controls.source.textContent = current.isCustom === true ? "사용자 지정" : "앱 기본값";
controls.select.disabled = isBusy;
controls.reset.disabled = isBusy || current.isCustom !== true;
}
function normalizeOperatorAppearanceValue(key, value) {
const definition = operatorAppearanceControls[key];
return typeof value === "string" && definition.values.includes(value)
? value
: definition.fallback;
}
function operatorAppearanceFromSettings(settings) {
return {
colorTheme: normalizeOperatorAppearanceValue(
"colorTheme",
settings && settings.colorTheme),
viewMode: normalizeOperatorAppearanceValue("viewMode", settings && settings.viewMode),
startWorkspace: normalizeOperatorAppearanceValue(
"startWorkspace",
settings && settings.startWorkspace)
};
}
function operatorAppearanceFromControls() {
return {
colorTheme: normalizeOperatorAppearanceValue(
"colorTheme",
operatorAppearanceControls.colorTheme.element.value),
viewMode: normalizeOperatorAppearanceValue(
"viewMode",
operatorAppearanceControls.viewMode.element.value),
startWorkspace: normalizeOperatorAppearanceValue(
"startWorkspace",
operatorAppearanceControls.startWorkspace.element.value)
};
}
function syncOperatorAppearanceControls(appearance) {
Object.keys(operatorAppearanceControls).forEach(function (key) {
const control = operatorAppearanceControls[key].element;
const nextValue = appearance[key];
if (control.value !== nextValue) control.value = nextValue;
});
}
function applyOperatorAppearance(appearance) {
[document.documentElement, document.body].forEach(function (element) {
element.dataset.colorTheme = appearance.colorTheme;
element.dataset.viewMode = appearance.viewMode;
element.dataset.startWorkspace = appearance.startWorkspace;
});
}
function renderOperatorSettings(state) {
const settings = state && state.operatorSettings;
const isBusy = !settings || state.isBusy === true;
const wasBusy = operatorSettingsWasBusy;
let revisionChanged = false;
renderOperatorFolder("design", settings && settings.design, isBusy);
renderOperatorFolder("resource", settings && settings.resource, isBusy);
renderOperatorFolder("background", settings && settings.background, isBusy);
operatorNavigationExpanded.disabled = isBusy;
Object.keys(operatorAppearanceControls).forEach(function (key) {
operatorAppearanceControls[key].element.disabled = isBusy;
});
if (settings) {
const revision = settings.revision === undefined ? null : settings.revision;
const appearance = operatorAppearanceFromSettings(settings);
revisionChanged = !operatorSettingsRevisionInitialized ||
revision !== operatorSettingsRenderRevision;
if (revisionChanged) {
operatorAppearanceSnapshot = Object.freeze(appearance);
syncOperatorAppearanceControls(appearance);
applyOperatorAppearance(appearance);
const expanded = settings.navigationExpanded !== false;
operatorNavigationExpanded.checked = expanded;
if (window.LegacyWorkspaceNavigation &&
window.LegacyWorkspaceNavigation.expanded() !== expanded) {
window.LegacyWorkspaceNavigation.setExpanded(expanded);
}
operatorSettingsRenderRevision = revision;
operatorSettingsRevisionInitialized = true;
}
if (window.LegacyWorkspaceNavigation &&
typeof window.LegacyWorkspaceNavigation.applyStartPreference === "function") {
window.LegacyWorkspaceNavigation.applyStartPreference(appearance.startWorkspace);
}
}
const message = operatorSettingsText(settings && settings.message, "");
operatorSettingsMessage.hidden = !message;
if (operatorSettingsMessage.textContent !== message) {
operatorSettingsMessage.textContent = message;
}
const messageKind = settings && typeof settings.messageKind === "string"
? settings.messageKind.toLowerCase()
: "";
operatorSettingsMessage.className = "settings-message" +
(["information", "success", "warning", "error"].includes(messageKind)
? " " + messageKind
: "");
operatorSettingsRestart.hidden = !(settings && settings.restartRequired === true);
setOperatorDiagnostic(
operatorSettingsAppVersion,
settings && settings.appVersion,
"확인 중");
const playout = state && state.playout;
setOperatorDiagnostic(operatorSettingsPlayoutMode, operatorPlayoutModeLabel(playout), "확인 중");
setOperatorDiagnostic(
operatorSettingsPlayoutConnection,
operatorPlayoutConnectionLabel(playout),
"확인 중");
setOperatorDiagnostic(
operatorSettingsOracleState,
settings && settings.oracleState,
"확인 중");
setOperatorDiagnostic(
operatorSettingsMariaState,
settings && settings.mariaState,
"확인 중");
const pollingSeconds = Number(settings && settings.databasePollingSeconds);
operatorSettingsDbMonitor.textContent = Number.isFinite(pollingSeconds) && pollingSeconds > 0
? String(pollingSeconds) + "초마다"
: "10초마다";
operatorSettingsWasBusy = isBusy;
if (pendingOperatorSettingsFocusId && !isBusy && (wasBusy || revisionChanged)) {
const focusId = pendingOperatorSettingsFocusId;
const fallbackFocusId = pendingOperatorSettingsFallbackFocusId;
pendingOperatorSettingsFocusId = null;
pendingOperatorSettingsFallbackFocusId = null;
window.requestAnimationFrame(function () {
const primary = document.getElementById(focusId);
const fallback = fallbackFocusId
? document.getElementById(fallbackFocusId)
: null;
const target = primary && !primary.disabled ? primary : fallback;
const active = document.activeElement;
if (target && !target.disabled && !target.closest("[inert]") &&
(active === document.body || active === null)) {
target.focus({ preventScroll: true });
}
});
}
}
function render(state) {
const isBusy = state.isBusy === true;
const treeTabId = beginTreeRender(state);
uiBusy = isBusy;
operatorMutationLocked = isOperatorMutationLocked(state);
document.body.classList.toggle("busy", isBusy);
document.body.setAttribute("aria-busy", isBusy ? "true" : "false");
searchInput.readOnly = isBusy;
searchButton.setAttribute("aria-disabled", isBusy ? "true" : "false");
syncSearchDraft(searchInput, state.searchText);
updateDoubleClickTime(state);
updateCutDragSize(state);
renderStockResults(state);
renderStockSearchMeta(state);
renderCuts(state);
renderTabs(state);
movingAverage5.checked = state.movingAverages ? state.movingAverages.ma5 : true;
movingAverage20.checked = state.movingAverages ? state.movingAverages.ma20 : true;
movingAverage5.disabled = isBusy;
movingAverage20.disabled = isBusy;
catalogExpandAll.disabled = treeInteractionLocked;
renderFixedCatalog(state);
renderIndustry(state);
renderOverseas(state);
renderComparison(state);
renderTheme(state);
renderExpert(state);
renderTradingHalt(state);
finishTreeRender(treeTabId);
renderManualFinancial(state);
renderManualLists(state);
renderOperatorCatalog(state);
renderOperatorSettings(state);
renderPlaylist(state);
renderNamedPlaylist(state);
renderDialog(state);
syncModalFocus();
status.textContent = isBusy ? "처리 중" : (state.statusMessage || "");
}
Object.keys(operatorFolderControls).forEach(function (kind) {
const controls = operatorFolderControls[kind];
controls.select.addEventListener("click", function () {
pendingOperatorSettingsFocusId = controls.select.id;
pendingOperatorSettingsFallbackFocusId = null;
if (!send("choose-operator-folder", { kind: kind })) {
pendingOperatorSettingsFocusId = null;
pendingOperatorSettingsFallbackFocusId = null;
}
});
controls.reset.addEventListener("click", function () {
pendingOperatorSettingsFocusId = controls.reset.id;
pendingOperatorSettingsFallbackFocusId = controls.select.id;
if (!send("reset-operator-folder", { kind: kind })) {
pendingOperatorSettingsFocusId = null;
pendingOperatorSettingsFallbackFocusId = null;
}
});
});
operatorNavigationExpanded.addEventListener("change", function () {
const expanded = operatorNavigationExpanded.checked === true;
pendingOperatorSettingsFocusId = operatorNavigationExpanded.id;
pendingOperatorSettingsFallbackFocusId = null;
if (!send("set-operator-navigation-expanded", { expanded: expanded })) {
pendingOperatorSettingsFocusId = null;
pendingOperatorSettingsFallbackFocusId = null;
operatorNavigationExpanded.checked = !expanded;
return;
}
if (window.LegacyWorkspaceNavigation &&
window.LegacyWorkspaceNavigation.expanded() !== expanded) {
window.LegacyWorkspaceNavigation.setExpanded(expanded);
}
});
Object.keys(operatorAppearanceControls).forEach(function (key) {
const control = operatorAppearanceControls[key].element;
control.addEventListener("change", function () {
const appearance = operatorAppearanceFromControls();
pendingOperatorSettingsFocusId = control.id;
pendingOperatorSettingsFallbackFocusId = null;
applyOperatorAppearance(appearance);
if (!send("set-operator-appearance", {
colorTheme: appearance.colorTheme,
viewMode: appearance.viewMode,
startWorkspace: appearance.startWorkspace
})) {
pendingOperatorSettingsFocusId = null;
pendingOperatorSettingsFallbackFocusId = null;
syncOperatorAppearanceControls(operatorAppearanceSnapshot);
applyOperatorAppearance(operatorAppearanceSnapshot);
}
});
});
document.addEventListener("input", function (event) {
rememberSearchDraft(event.target);
});
searchButton.addEventListener("click", function () {
send("search-stocks", { text: searchInput.value, trigger: "button" });
});
searchInput.addEventListener("keypress", function (event) {
if (event.key === "Enter" && !isImeComposing(event)) {
event.preventDefault();
send("search-stocks", { text: searchInput.value, trigger: "enter" });
}
});
dbSaveButton.addEventListener("click", function () {
openNamedPlaylist("save");
});
dbLoadButton.addEventListener("click", function () {
openNamedPlaylist("load");
});
namedPlaylistClose.addEventListener("click", closeNamedPlaylist);
namedPlaylistCancelButton.addEventListener("click", closeNamedPlaylist);
namedPlaylistDefinitions.addEventListener("dblclick", function (event) {
const definition = event.target.closest("button[data-named-playlist-definition-id]");
if (!definition || definition.disabled) return;
event.preventDefault();
const definitionId = definition.dataset.namedPlaylistDefinitionId;
if (namedPlaylistSelection.isPending()) {
namedPlaylistSelection.defer(definitionId, {
definitionId: definitionId,
mode: namedPlaylistMode
});
return;
}
runNamedPlaylistDoubleClick(definitionId, namedPlaylistMode);
});
namedPlaylistDefinitions.addEventListener("click", function (event) {
const definition = event.target.closest("button[data-named-playlist-definition-id]");
if (definition && !definition.disabled) {
beginNamedPlaylistSelection(definition.dataset.namedPlaylistDefinitionId);
}
});
namedPlaylistNewTitle.addEventListener("input", function () {
namedPlaylistCreateButton.disabled = isNamedPlaylistActionLocked() ||
!namedPlaylistState ||
namedPlaylistState.canCreate !== true ||
!isCanonicalNamedPlaylistTitle(namedPlaylistNewTitle.value);
});
namedPlaylistNewTitle.addEventListener("keydown", function (event) {
if (event.key === "Enter" && !isImeComposing(event) &&
!namedPlaylistCreateButton.disabled) {
event.preventDefault();
namedPlaylistCreateButton.click();
}
});
namedPlaylistCreateButton.addEventListener("click", function () {
const title = namedPlaylistNewTitle.value;
if (isCanonicalNamedPlaylistTitle(title)) {
beginNamedPlaylistBusyCycle("create-named-playlist", { title: title });
}
});
namedPlaylistDeleteButton.addEventListener("click", function () {
openNamedPlaylistConfirmation({
kind: "delete",
message: "프로그램이 삭제됩니다. 계속 하시겠습니까?",
closeOnNo: false
});
});
namedPlaylistConfirmButton.addEventListener("click", function () {
const definitionId = namedPlaylistState && namedPlaylistState.selectedDefinitionId;
if (!definitionId) return;
if (namedPlaylistMode === "save") {
openNamedPlaylistConfirmation({
kind: "save",
definitionId: definitionId,
message: "저장하겠습니까?",
showSavedAlert: true,
closeOnNo: true
});
} else if (namedPlaylistMode === "load") {
beginNamedPlaylistCommand(
"load-named-playlist-by-id",
definitionId,
false);
}
});
namedPlaylistConfirmationYes.addEventListener("click", function () {
if (!namedPlaylistConfirmationYes.disabled) {
resolveNamedPlaylistConfirmation(true);
}
});
namedPlaylistConfirmationNo.addEventListener("click", function () {
resolveNamedPlaylistConfirmation(false);
});
manualButtons.forEach(function (button) {
button.addEventListener("click", function () {
clearManualResultPendingSelection(true);
manualDraft = null;
manualDraftKey = "";
manualDraftResetPending = false;
send("open-manual-financial", { screen: button.dataset.manualScreen });
});
});
function closeManualFinancial() {
clearManualResultPendingSelection(true);
send("close-manual-financial", {});
}
manualClose.addEventListener("click", closeManualFinancial);
manualExit.addEventListener("click", closeManualFinancial);
manualListButtons.forEach(function (button) {
button.addEventListener("click", function () {
clearManualViResultPendingSelection(true);
send("open-manual-list", { screen: button.dataset.manualListScreen });
});
});
function closeManualList() {
clearManualViResultPendingSelection(true);
send("close-manual-list", {});
}
manualListClose.addEventListener("click", closeManualList);
manualListCancel.addEventListener("click", closeManualList);
manualWorkspace.addEventListener("input", function (event) {
if (!manualDraft || manualDraft.rawMode !== true ||
!event.target || !event.target.dataset ||
event.target.dataset.manualRawStorageIndex === undefined) {
return;
}
const index = Number(event.target.dataset.manualRawStorageIndex);
if (!Number.isSafeInteger(index) || index < 0) return;
if (!Array.isArray(manualDraft.rawDirtyStorageIndices)) {
manualDraft.rawDirtyStorageIndices = [];
}
if (!manualDraft.rawDirtyStorageIndices.includes(index)) {
manualDraft.rawDirtyStorageIndices.push(index);
}
});
manualWorkspace.addEventListener("dblclick", function (event) {
const stock = event.target.closest("button[data-manual-stock-result-id]");
if (stock && !stock.disabled) {
event.preventDefault();
selectManualFinancialStock(stock);
return;
}
const row = event.target.closest("button[data-manual-result-id]");
if (!row || row.disabled) return;
event.preventDefault();
clearDelayedClick(manualResultClickTimer);
manualResultClickTimer = null;
const pending = manualResultPendingSelection &&
manualResultPendingSelection.key === row.dataset.manualResultId
? manualResultPendingSelection
: beginManualResultPendingSelection(row);
if (send("activate-manual-financial-result", {
resultId: row.dataset.manualResultId
})) {
if (pending) pending.sent = true;
manualDraft = null;
manualDraftKey = "";
manualDraftResetPending = false;
} else {
clearManualResultPendingSelection(true);
}
});
manualWorkspace.addEventListener("click", function (event) {
const row = event.target.closest("button[data-manual-result-id]");
if (row) {
clearDelayedClick(manualResultClickTimer);
const resultId = row.dataset.manualResultId;
const pending = beginManualResultPendingSelection(row);
manualResultClickTimer = deferLegacySingleClick(function () {
manualResultClickTimer = null;
if (!pending || manualResultPendingSelection !== pending) return;
if (send("load-manual-financial", { resultId: resultId })) {
pending.sent = true;
manualDraft = null;
manualDraftKey = "";
manualDraftResetPending = false;
} else {
clearManualResultPendingSelection(true);
}
});
return;
}
const find = event.target.closest("button[data-manual-find-direction]");
if (find && !find.disabled) {
const query = manualWorkspace.querySelector("#manual-financial-find-query");
const generation = Number(find.dataset.manualGeneration);
if (query && query.value.trim() && Number.isSafeInteger(generation)) {
send("find-manual-financial", {
query: query.value,
direction: find.dataset.manualFindDirection,
generation: generation
});
}
return;
}
const sort = event.target.closest("button[data-manual-name-sort]");
if (sort && !sort.disabled) {
const generation = Number(sort.dataset.manualGeneration);
if (Number.isSafeInteger(generation)) {
send("toggle-manual-financial-name-sort", { generation: generation });
}
return;
}
if (event.target.closest("button[data-manual-list-search]")) {
const query = manualWorkspace.querySelector("#manual-financial-list-query");
send("search-manual-financial", { query: query ? query.value : "" });
return;
}
if (event.target.closest("button[data-manual-create]")) {
const screen = manualDraft ? manualDraft.screen : "";
manualDraft = blankManualRecord(screen);
manualDraftKey = screen + "|new";
manualDraftResetPending = true;
if (!send("begin-manual-financial-create", {})) {
manualDraftResetPending = false;
rememberManualDraft();
}
return;
}
if (event.target.closest("button[data-manual-stock-search]")) {
rememberManualDraft();
const stockName = manualWorkspace.querySelector("#manual-financial-stock-name");
send("search-manual-financial-stocks", {
stockName: stockName ? stockName.value.trim() : ""
});
return;
}
const stock = event.target.closest("button[data-manual-stock-result-id]");
if (stock && !stock.disabled) {
// GraphE only committed a candidate from its DoubleClick handler. A
// single click must remain a harmless focus/highlight gesture so it
// cannot clear an editor draft after the operator resumes typing.
focusManualFinancialStock(stock);
return;
}
if (event.target.closest("button[data-manual-save]")) {
if (manualDraft && manualDraft.rawMode === true) {
const rawRecord = readManualRawRecord();
if (rawRecord) {
send("save-manual-financial-raw", { record: rawRecord });
}
return;
}
const record = readManualRecord();
if (record) send("save-manual-financial", { record: record });
return;
}
if (event.target.closest("button[data-manual-delete]")) {
send("delete-manual-financial", {});
return;
}
if (event.target.closest("button[data-manual-delete-all]")) {
send("delete-all-manual-financial", {});
return;
}
const action = event.target.closest("button[data-manual-action-id]");
if (action && !action.disabled) {
send("activate-manual-financial-action", {
actionId: action.dataset.manualActionId
});
}
});
manualWorkspace.addEventListener("keydown", function (event) {
if (event.key !== "Enter" || isImeComposing(event)) return;
const stock = event.target.closest("button[data-manual-stock-result-id]");
if (stock && !stock.disabled) {
event.preventDefault();
selectManualFinancialStock(stock);
} else if (event.target.id === "manual-financial-list-query") {
event.preventDefault();
send("search-manual-financial", { query: event.target.value });
} else if (event.target.id === "manual-financial-find-query") {
event.preventDefault();
const down = manualWorkspace.querySelector(
"button[data-manual-find-direction=\"down\"]");
if (down) down.click();
} else if (event.target.id === "manual-financial-stock-name") {
event.preventDefault();
rememberManualDraft();
send("search-manual-financial-stocks", {
stockName: event.target.value.trim()
});
}
});
operatorCatalogClose.addEventListener("click", function () {
send("close-operator-catalog", {});
});
operatorCatalogThemeTab.addEventListener("click", function () {
send("open-operator-catalog", { entity: "theme" });
});
operatorCatalogExpertTab.addEventListener("click", function () {
send("open-operator-catalog", { entity: "expert" });
});
function searchOperatorCatalog() {
send("search-operator-catalog", {
query: operatorCatalogQuery.value,
nxtSession: operatorCatalogSession.value === "afterMarket"
? "afterMarket" : "preMarket"
});
}
operatorCatalogSearch.addEventListener("click", searchOperatorCatalog);
operatorCatalogQuery.addEventListener("keydown", function (event) {
if (event.key === "Enter" && !isImeComposing(event)) {
event.preventDefault();
searchOperatorCatalog();
}
});
operatorCatalogNew.addEventListener("click", function () {
if (!operatorCatalogState) return;
operatorCatalogDraftKey = "";
operatorCatalogNameDraft = "";
if (operatorCatalogState.entity === "theme") {
send("begin-create-theme-catalog", {
market: operatorCatalogNewMarket.value === "nxt" ? "nxt" : "krx"
});
} else {
send("begin-create-expert-catalog", {});
}
});
operatorCatalogEditorMarket.addEventListener("change", function () {
if (!operatorCatalogState || operatorCatalogState.entity !== "theme" ||
operatorCatalogState.mode !== "create" || operatorCatalogEditorMarket.disabled) return;
operatorCatalogNameDraft = operatorCatalogName.value;
send("change-create-theme-market", {
market: operatorCatalogEditorMarket.value === "nxt" ? "nxt" : "krx",
editorName: operatorCatalogName.value.trim()
});
});
operatorCatalogResults.addEventListener("click", function (event) {
const row = event.target.closest("button[data-operator-catalog-result-id]");
if (!row || row.disabled) return;
operatorCatalogDraftKey = "";
send("select-operator-catalog-result", {
resultId: row.dataset.operatorCatalogResultId
});
});
operatorCatalogName.addEventListener("input", function () {
operatorCatalogNameDraft = operatorCatalogName.value;
operatorCatalogNameStatus.textContent = "";
operatorCatalogNameStatus.className = "";
operatorCatalogNameCheck.disabled = uiBusy || !operatorCatalogState ||
operatorCatalogState.entity !== "theme" ||
(operatorCatalogState.mode !== "create" && operatorCatalogState.mode !== "edit") ||
operatorCatalogState.canCheckThemeName !== true ||
operatorCatalogName.value.trim().length === 0;
});
operatorCatalogNameCheck.addEventListener("click", function () {
const name = operatorCatalogName.value.trim();
// ThemeA's duplicate-check button silently returned for an empty name.
if (!name) return;
operatorCatalogName.setCustomValidity("");
operatorCatalogNameDraft = name;
send("check-operator-catalog-theme-name", { name: name });
});
function searchOperatorCatalogStocks() {
const query = operatorCatalogStockQuery.value.trim();
send("search-operator-catalog-stocks", { query: query });
}
operatorCatalogStockSearch.addEventListener("click", searchOperatorCatalogStocks);
operatorCatalogStockQuery.addEventListener("keydown", function (event) {
if (event.key === "Enter" && !isImeComposing(event)) {
event.preventDefault();
searchOperatorCatalogStocks();
}
});
operatorCatalogStockResults.addEventListener("dblclick", function (event) {
const row = event.target.closest("button[data-operator-catalog-stock-result-id]");
if (!row || row.disabled) return;
event.preventDefault();
send("activate-operator-catalog-stock", {
resultId: row.dataset.operatorCatalogStockResultId,
buyAmount: null
});
});
operatorCatalogStockResults.addEventListener("click", function (event) {
const row = event.target.closest("button[data-operator-catalog-stock-result-id]");
if (!row || row.disabled) return;
// A real double-click raises click(detail=1), click(detail=2), then dblclick.
// Select immediately on the first click and ignore the synthetic second
// click. Keyed rendering above keeps this exact button attached while the
// selection snapshot returns, so slow Windows double-clicks retain a target.
if ((event.detail || 1) > 1) return;
send("select-operator-catalog-stock", {
resultId: row.dataset.operatorCatalogStockResultId
});
});
operatorCatalogAddStock.addEventListener("click", function () {
send("add-operator-catalog-stock", { buyAmount: null });
});
operatorCatalogDraftRows.addEventListener("click", function (event) {
const move = event.target.closest("button[data-operator-catalog-move-row-id]");
if (move && !move.disabled) {
send("move-operator-catalog-draft-row", {
rowId: move.dataset.operatorCatalogMoveRowId,
direction: move.dataset.operatorCatalogMoveDirection
});
return;
}
const remove = event.target.closest("button[data-operator-catalog-remove-row-id]");
if (remove && !remove.disabled) {
send("remove-operator-catalog-draft-row", {
rowId: remove.dataset.operatorCatalogRemoveRowId
});
}
});
operatorCatalogDraftRows.addEventListener("change", function (event) {
const input = event.target.closest("input[data-operator-catalog-buy-amount-row-id]");
if (!input || input.disabled) return;
const text = input.value.trim();
if (!text) {
input.setCustomValidity("");
send("set-operator-catalog-draft-buy-amount", {
rowId: input.dataset.operatorCatalogBuyAmountRowId,
buyAmount: null
});
return;
}
const amount = Number(text);
if (!Number.isSafeInteger(amount) || amount <= 0) {
input.setCustomValidity("매수가는 1 이상의 정수여야 합니다.");
input.reportValidity();
return;
}
input.setCustomValidity("");
send("set-operator-catalog-draft-buy-amount", {
rowId: input.dataset.operatorCatalogBuyAmountRowId,
buyAmount: amount
});
});
operatorCatalogSave.addEventListener("click", function () {
const rawName = operatorCatalogName.value;
const canonicalName = rawName.trim();
if (!canonicalName) {
// EList showed an exact one-button message; ThemeA's empty-name path did
// not own that message and is kept as a no-op here.
if (operatorCatalogState && operatorCatalogState.entity === "expert") {
send("save-operator-catalog", { name: "" });
}
return;
}
const preserveStoredThemeName = operatorCatalogState &&
operatorCatalogState.entity === "theme" &&
operatorCatalogState.mode === "edit";
const name = preserveStoredThemeName ? rawName : canonicalName;
operatorCatalogName.setCustomValidity("");
operatorCatalogNameDraft = name;
send("save-operator-catalog", { name: name });
});
operatorCatalogDeleteAll.addEventListener("click", function () {
if (!operatorCatalogState || operatorCatalogState.entity !== "theme" ||
(operatorCatalogState.mode !== "create" && operatorCatalogState.mode !== "edit")) {
return;
}
send("clear-operator-catalog-draft-rows", {});
});
operatorCatalogCancel.addEventListener("click", function () {
operatorCatalogDraftKey = "";
send("close-operator-catalog", {});
});
playlistMoveUp.addEventListener("click", function () {
send("move-selected-playlist-rows", { direction: "up" });
});
playlistMoveDown.addEventListener("click", function () {
send("move-selected-playlist-rows", { direction: "down" });
});
playlistDeleteSelected.addEventListener("click", function () {
send("delete-selected-playlist-rows", {});
});
playlistDeleteAll.addEventListener("click", function () {
send("delete-all-playlist-rows", {});
});
playlistEnableAll.addEventListener("change", function () {
if (!playlistProgramEnableMutationLocked) {
send("set-all-playlist-enabled", { enabled: playlistEnableAll.checked });
}
});
marketTabs.addEventListener("click", function (event) {
const button = event.target.closest("button[data-tab-id]");
if (button) {
clearComparisonTargetPendingSelection(true);
clearThemeResultPendingSelection(true);
send("select-tab", { tabId: button.dataset.tabId });
}
});
marketTabs.addEventListener("dragstart", function (event) {
const button = event.target.closest("button[data-tab-id]");
if (!button || !event.dataTransfer) return;
clearPendingTabHoverSwap();
draggedTabId = button.dataset.tabId;
tabDragLastTargetId = null;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/x-mbn-tab", draggedTabId);
button.classList.add("dragging");
});
marketTabs.addEventListener("dragover", onMarketTabDragOver);
marketTabs.addEventListener("drop", function (event) {
const target = event.target.closest("button[data-tab-id]");
if (!target || !event.dataTransfer || !draggedTabId) return;
event.preventDefault();
if (event.dataTransfer.getData("text/x-mbn-tab") !== draggedTabId) return;
draggedTabId = null;
tabDragLastTargetId = null;
marketTabs.querySelectorAll("button.dragging")
.forEach(function (button) { button.classList.remove("dragging"); });
});
marketTabs.addEventListener("dragend", function () {
draggedTabId = null;
tabDragLastTargetId = null;
marketTabs.querySelectorAll("button.dragging")
.forEach(function (button) { button.classList.remove("dragging"); });
});
function sendMovingAverages() {
send("set-moving-averages", {
ma5: movingAverage5.checked,
ma20: movingAverage20.checked
});
}
movingAverage5.addEventListener("change", sendMovingAverages);
movingAverage20.addEventListener("change", sendMovingAverages);
catalogExpandAll.addEventListener("change", function () {
if (treeInteractionLocked) {
const sections = Array.from(categoryTree.querySelectorAll("details"));
catalogExpandAll.checked = sections.length > 0 && sections.every(function (details) {
return details.open === true;
});
return;
}
categoryTree.querySelectorAll("details").forEach(function (details) {
details.open = catalogExpandAll.checked;
syncTreeSectionExpanded(details);
});
});
categoryTree.addEventListener("dblclick", function (event) {
const treeNode = event.target.closest("[data-tree-node-key]");
if (treeNode) {
event.preventDefault();
if (treeInteractionLocked || treeNode.disabled === true ||
treeNode.getAttribute("aria-disabled") === "true") return;
selectTreeNode(treeNode, true);
// The disclosure is a separate expand/collapse command, not a node action.
if (event.target.closest("[data-tree-disclosure]")) return;
}
const themeResult = event.target.closest("button[data-theme-result-id]");
if (themeResult && !themeResult.disabled) {
event.preventDefault();
clearDelayedClick(themeResultClickTimer);
themeResultClickTimer = null;
const pending = themeResultPendingSelection &&
themeResultPendingSelection.key === themeResult.dataset.themeResultId
? themeResultPendingSelection
: beginThemeResultPendingSelection(themeResult);
if (send("activate-theme-result", {
resultId: themeResult.dataset.themeResultId
})) {
if (pending) pending.sent = true;
} else {
clearThemeResultPendingSelection(true);
}
return;
}
const comparisonChoice = event.target.closest("button[data-comparison-choice]");
if (comparisonChoice && !comparisonChoice.disabled) {
event.preventDefault();
clearDelayedClick(comparisonTargetClickTimer);
comparisonTargetClickTimer = null;
const selectionId = comparisonChoice.dataset.comparisonSelectionId;
const pending = comparisonTargetPendingSelection &&
comparisonTargetPendingSelection.key === selectionId
? comparisonTargetPendingSelection
: beginComparisonTargetPendingSelection(comparisonChoice, selectionId);
if (send(comparisonChoice.dataset.comparisonChoice === "world"
? "choose-comparison-world" : "choose-comparison-domestic", {
selectionId: selectionId
})) {
if (pending) pending.sent = true;
} else {
clearComparisonTargetPendingSelection(true);
}
return;
}
const comparisonFixed = event.target.closest("button[data-comparison-target-id]");
if (comparisonFixed && !comparisonFixed.disabled) {
event.preventDefault();
clearDelayedClick(comparisonTargetClickTimer);
comparisonTargetClickTimer = null;
const targetId = comparisonFixed.dataset.comparisonTargetId;
const pending = comparisonTargetPendingSelection &&
comparisonTargetPendingSelection.key === targetId
? comparisonTargetPendingSelection
: beginComparisonTargetPendingSelection(comparisonFixed, targetId);
if (send("choose-comparison-fixed", { targetId: targetId })) {
if (pending) pending.sent = true;
} else {
clearComparisonTargetPendingSelection(true);
}
return;
}
const comparisonSection = event.target.closest("summary[data-comparison-section-index]");
if (comparisonSection) {
event.preventDefault();
send("activate-comparison-section", {
sectionIndex: Number(comparisonSection.dataset.comparisonSectionIndex)
});
return;
}
const industrySection = event.target.closest("summary[data-industry-section-index]");
if (industrySection) {
event.preventDefault();
send("activate-industry-section", {
sectionIndex: Number(industrySection.dataset.industrySectionIndex)
});
return;
}
const comparisonAction = event.target.closest("button[data-comparison-action-id]");
if (comparisonAction && !comparisonAction.disabled) {
send("activate-comparison-action", {
actionId: comparisonAction.dataset.comparisonActionId
});
return;
}
const industryRow = event.target.closest("button[data-industry-index]");
if (industryRow) {
activateIndustryRow(industryRow);
return;
}
const industryAction = event.target.closest("button[data-industry-action-id]");
if (industryAction) {
send("activate-industry-action", {
actionId: industryAction.dataset.industryActionId
});
return;
}
const action = event.target.closest("button[data-action-id]");
if (action) {
send("activate-fixed-action", { actionId: action.dataset.actionId });
return;
}
const section = event.target.closest("summary[data-section-index]");
if (section && section.getAttribute("aria-disabled") !== "true") {
event.preventDefault();
// UC1.treeView_DoubleClick walks every direct child when the selected
// node is a root. Keep the whole batch behind one native intent so C#
// can materialize every child first and commit in original order.
send("activate-fixed-section", {
sectionIndex: Number(section.dataset.sectionIndex)
});
return;
}
});
categoryTree.addEventListener("click", function (event) {
const treeRoot = event.target.closest("summary[data-tree-root]");
if (treeRoot) {
// A native TreeView selects on its label and expands only from the glyph.
// Cancel <summary>'s whole-row default toggle to preserve that split.
event.preventDefault();
if (treeInteractionLocked || treeRoot.getAttribute("aria-disabled") === "true") return;
selectTreeNode(treeRoot, true);
if (event.target.closest("[data-tree-disclosure]") && (event.detail || 1) < 2) {
const details = treeRoot.parentElement;
details.open = !details.open;
syncTreeSectionExpanded(details);
}
return;
}
const treeLeaf = event.target.closest(".tree-item[data-tree-node-key]");
if (treeLeaf) {
if (treeInteractionLocked || treeLeaf.disabled === true) {
event.preventDefault();
return;
}
selectTreeNode(treeLeaf, true);
}
const uc4Catalog = event.target.closest("button[data-uc4-theme-catalog]");
if (uc4Catalog && !uc4Catalog.disabled) {
const action = uc4Catalog.dataset.uc4ThemeCatalog;
if (action === "create") {
send("begin-uc4-theme-catalog", {});
} else if (action === "edit") {
send("edit-uc4-selected-theme", {});
} else if (action === "delete") {
send("delete-uc4-selected-theme", {});
}
return;
}
const uc6Catalog = event.target.closest("button[data-uc6-expert-catalog]");
if (uc6Catalog && !uc6Catalog.disabled) {
const action = uc6Catalog.dataset.uc6ExpertCatalog;
if (action === "create") {
send("begin-uc6-expert-catalog", {});
} else if (action === "edit") {
send("edit-uc6-selected-expert", {});
} else if (action === "delete") {
send("delete-uc6-selected-expert", {});
}
return;
}
const catalogOpen = event.target.closest("button[data-operator-catalog-open]");
if (catalogOpen && !catalogOpen.disabled) {
send("open-operator-catalog", {
entity: catalogOpen.dataset.operatorCatalogOpen
});
return;
}
// UC4/UC6/UC7 wire their scene action controls to WinForms Click.
// Keep these actions out of the dblclick delegate above so one operator
// click produces exactly one closed native intent.
const themeAction = event.target.closest("button[data-theme-action-id]");
if (themeAction && !themeAction.disabled) {
send("activate-theme-action", { actionId: themeAction.dataset.themeActionId });
return;
}
const expertAction = event.target.closest("button[data-expert-action-id]");
if (expertAction && !expertAction.disabled) {
send("activate-expert-action", { actionId: expertAction.dataset.expertActionId });
return;
}
const haltAction = event.target.closest("button[data-halt-action-id]");
if (haltAction && !haltAction.disabled) {
send("activate-trading-halt-action", { actionId: haltAction.dataset.haltActionId });
return;
}
const overseasAction = event.target.closest("button[data-overseas-action-id]");
if (overseasAction && !overseasAction.disabled) {
send("activate-overseas-action", {
actionId: overseasAction.dataset.overseasActionId
});
return;
}
const overseasTarget = event.target.closest("button[data-overseas-target-id]");
if (overseasTarget && !overseasTarget.disabled) {
selectOverseasRow(overseasTarget);
return;
}
const overseasResult = event.target.closest("button[data-overseas-selection-id]");
if (overseasResult && !overseasResult.disabled) {
selectOverseasRow(overseasResult);
return;
}
const overseasSearch = event.target.closest("button[data-overseas-search]");
if (overseasSearch) {
const kind = overseasSearch.dataset.overseasSearch;
const input = categoryTree.querySelector("#overseas-" + kind + "-search");
send(kind === "stock" ? "search-overseas-stocks" : "search-overseas-industries", {
query: input ? input.value : ""
});
return;
}
const comparisonChoice = event.target.closest("button[data-comparison-choice]");
if (comparisonChoice) {
clearDelayedClick(comparisonTargetClickTimer);
const kind = comparisonChoice.dataset.comparisonChoice;
const selectionId = comparisonChoice.dataset.comparisonSelectionId;
const pending = beginComparisonTargetPendingSelection(
comparisonChoice,
selectionId);
comparisonTargetClickTimer = deferLegacySingleClick(function () {
comparisonTargetClickTimer = null;
if (!pending || comparisonTargetPendingSelection !== pending) return;
if (send(kind === "world"
? "select-comparison-world" : "select-comparison-domestic", {
selectionId: selectionId
})) {
pending.sent = true;
} else {
clearComparisonTargetPendingSelection(true);
}
});
return;
}
const comparisonFixed = event.target.closest("button[data-comparison-target-id]");
if (comparisonFixed) {
clearDelayedClick(comparisonTargetClickTimer);
const targetId = comparisonFixed.dataset.comparisonTargetId;
const pending = beginComparisonTargetPendingSelection(comparisonFixed, targetId);
comparisonTargetClickTimer = deferLegacySingleClick(function () {
comparisonTargetClickTimer = null;
if (!pending || comparisonTargetPendingSelection !== pending) return;
if (send("select-comparison-fixed", { targetId: targetId })) {
pending.sent = true;
} else {
clearComparisonTargetPendingSelection(true);
}
});
return;
}
const comparisonPair = event.target.closest("button[data-comparison-pair-id]");
if (comparisonPair) {
send("select-comparison-pair", { rowId: comparisonPair.dataset.comparisonPairId });
return;
}
const comparisonSearch = event.target.closest("button[data-comparison-search]");
if (comparisonSearch) {
const kind = comparisonSearch.dataset.comparisonSearch;
const input = categoryTree.querySelector("#comparison-" + kind + "-search");
const searchIntent = kind === "world"
? "search-comparison-world" : "search-comparison-domestic";
send(searchIntent, { query: input ? input.value : "" });
return;
}
if (event.target.closest("button[data-comparison-swap]")) {
send("swap-comparison-targets", {});
return;
}
if (event.target.closest("button[data-comparison-clear]")) {
send("clear-comparison-targets", {});
return;
}
if (event.target.closest("button[data-comparison-add]")) {
send("add-comparison-pair", {});
return;
}
if (event.target.closest("button[data-comparison-import]")) {
send("import-legacy-comparison-pairs", {});
return;
}
const comparisonMove = event.target.closest("button[data-comparison-move]");
if (comparisonMove) {
send("move-comparison-pair", { direction: comparisonMove.dataset.comparisonMove });
return;
}
const comparisonDelete = event.target.closest("button[data-comparison-delete]");
if (comparisonDelete) {
const all = comparisonDelete.dataset.comparisonDelete === "all";
send(all ? "delete-all-comparison-pairs" :
"delete-selected-comparison-pair", {});
return;
}
const expertResult = event.target.closest("button[data-expert-selection-id]");
if (expertResult) {
send("select-expert", { selectionId: expertResult.dataset.expertSelectionId });
return;
}
if (event.target.closest("button[data-expert-search]")) {
const input = categoryTree.querySelector("#expert-search");
send("search-experts", { query: input ? input.value : "" });
return;
}
const haltResult = event.target.closest("button[data-halt-selection-id]");
if (haltResult) {
send("select-trading-halt", { selectionId: haltResult.dataset.haltSelectionId });
return;
}
if (event.target.closest("button[data-halt-search]")) {
const input = categoryTree.querySelector("#halt-search");
send("search-trading-halts", { query: input ? input.value : "" });
return;
}
if (event.target.closest("button[data-halt-sort]")) {
send("toggle-trading-halt-sort", {});
return;
}
const themeResult = event.target.closest("button[data-theme-result-id]");
if (themeResult) {
clearDelayedClick(themeResultClickTimer);
const resultId = themeResult.dataset.themeResultId;
const pending = beginThemeResultPendingSelection(themeResult);
themeResultClickTimer = deferLegacySingleClick(function () {
themeResultClickTimer = null;
if (!pending || themeResultPendingSelection !== pending) return;
if (send("select-theme", { resultId: resultId })) {
pending.sent = true;
} else {
clearThemeResultPendingSelection(true);
}
});
return;
}
if (event.target.closest("button[data-theme-search]")) {
const input = categoryTree.querySelector("#theme-search");
send("search-themes", { query: input ? input.value : "" });
return;
}
const industryRow = event.target.closest("button[data-industry-index]");
if (industryRow) {
selectIndustryRow(industryRow);
return;
}
if (event.target.closest("button[data-industry-swap]")) {
send("swap-industries", {});
}
});
categoryTree.addEventListener("change", function (event) {
if (event.target.matches("input[data-industry-slot]")) {
send("set-industry-comparison-text", {
slot: event.target.dataset.industrySlot,
value: event.target.value
});
} else if (event.target.matches("input[data-theme-sort]")) {
send("set-theme-sort", { sortId: event.target.value });
}
});
categoryTree.addEventListener("keydown", function (event) {
if (isImeComposing(event)) return;
const overseasList = event.target.closest("[data-overseas-list]");
if (overseasList && ["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) {
event.preventDefault();
event.stopPropagation();
moveOverseasListSelection(overseasList, event.key);
return;
}
const industryList = event.target.closest(".industry-list");
if (industryList && ["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) {
event.preventDefault();
const rows = Array.from(
industryList.querySelectorAll("button[data-industry-index]"));
if (rows.length === 0) return;
const selectedIndex = rows.findIndex(function (row) {
return row.getAttribute("aria-selected") === "true";
});
let nextIndex;
if (event.key === "Home") nextIndex = 0;
else if (event.key === "End") nextIndex = rows.length - 1;
else if (selectedIndex < 0) nextIndex = 0;
else if (event.key === "ArrowUp") nextIndex = Math.max(0, selectedIndex - 1);
else nextIndex = Math.min(rows.length - 1, selectedIndex + 1);
const nextRow = rows[nextIndex];
selectIndustryRow(nextRow);
nextRow.scrollIntoView({ block: "nearest", inline: "nearest" });
} else {
const treeRoot = event.target.closest("summary[data-tree-root]");
if (treeRoot && ["Enter", " ", "ArrowLeft", "ArrowRight"].includes(event.key)) {
event.preventDefault();
if (treeInteractionLocked || treeRoot.getAttribute("aria-disabled") === "true") return;
selectTreeNode(treeRoot, true);
const details = treeRoot.parentElement;
if (event.key === "ArrowLeft") details.open = false;
else if (event.key === "ArrowRight") details.open = true;
syncTreeSectionExpanded(details);
} else if (event.key === "Enter" &&
(event.target.id === "overseas-industry-search" ||
event.target.id === "overseas-stock-search")) {
event.preventDefault();
const kind = event.target.id === "overseas-stock-search" ? "stock" : "industry";
send(kind === "stock" ? "search-overseas-stocks" : "search-overseas-industries", {
query: event.target.value
});
} else if (event.key === "Enter" &&
(event.target.id === "comparison-domestic-search" ||
event.target.id === "comparison-world-search")) {
event.preventDefault();
const kind = event.target.id === "comparison-world-search" ? "world" : "domestic";
send(kind === "world" ? "search-comparison-world" :
"search-comparison-domestic", { query: event.target.value });
} else if (event.key === "Enter" && event.target.id === "theme-search") {
event.preventDefault();
send("search-themes", { query: event.target.value });
} else if (event.key === "Enter" && event.target.id === "expert-search") {
event.preventDefault();
send("search-experts", { query: event.target.value });
} else if (event.key === "Enter" && event.target.id === "halt-search") {
event.preventDefault();
send("search-trading-halts", { query: event.target.value });
}
}
});
window.addEventListener("pointerup", function () {
deferredPlaylistPlainSelectionRowId = null;
operatorCatalogDragBlockedRowId = null;
});
function closeActiveChildModalFromAltF4(activeModal) {
if (!activeModal) return false;
if (activeModal === namedPlaylistDialog) {
closeNamedPlaylist();
} else if (activeModal === manualDialog) {
if (!manualClose.disabled) manualClose.click();
} else if (activeModal === manualListDialog) {
if (!manualListClose.disabled) manualListClose.click();
} else if (activeModal === operatorCatalogDialog) {
if (!operatorCatalogClose.disabled) operatorCatalogClose.click();
} else if (activeModal === legacyAlertDialog && !legacyDialogIsConfirmation) {
send("dismiss-dialog", {});
}
// Native Yes/No MessageBox windows do not expose an Alt+F4 close result.
// Consume Alt+F4 for those and for the nested PList confirmation as well.
return true;
}
function activateRovingListRow(row) {
if (!row || row.disabled === true) return false;
if (row.dataset.treeNodeKey) {
if (treeInteractionLocked || row.getAttribute("aria-disabled") === "true") {
return false;
}
selectTreeNode(row, false);
return true;
}
if (row.dataset.themeResultId) {
const pending = beginThemeResultPendingSelection(row);
if (pending && send("select-theme", {
resultId: row.dataset.themeResultId
})) {
pending.sent = true;
return true;
}
clearThemeResultPendingSelection(true);
return false;
}
if (row.dataset.namedPlaylistDefinitionId) {
return beginNamedPlaylistSelection(row.dataset.namedPlaylistDefinitionId);
}
if (row.dataset.operatorCatalogResultId) {
operatorCatalogDraftKey = "";
return send("select-operator-catalog-result", {
resultId: row.dataset.operatorCatalogResultId
});
}
if (row.dataset.operatorCatalogStockResultId) {
return send("select-operator-catalog-stock", {
resultId: row.dataset.operatorCatalogStockResultId
});
}
if (row.dataset.comparisonSelectionId) {
clearComparisonTargetPendingSelection(true);
return send(row.dataset.comparisonChoice === "world"
? "select-comparison-world" : "select-comparison-domestic", {
selectionId: row.dataset.comparisonSelectionId
});
}
if (row.dataset.comparisonTargetId) {
clearComparisonTargetPendingSelection(true);
return send("select-comparison-fixed", {
targetId: row.dataset.comparisonTargetId
});
}
if (row.dataset.comparisonPairId) {
return send("select-comparison-pair", { rowId: row.dataset.comparisonPairId });
}
if (row.dataset.expertSelectionId) {
return send("select-expert", { selectionId: row.dataset.expertSelectionId });
}
if (row.dataset.haltSelectionId) {
return send("select-trading-halt", { selectionId: row.dataset.haltSelectionId });
}
if (row.dataset.manualResultId) {
clearManualResultPendingSelection(true);
manualDraft = null;
manualDraftKey = "";
manualDraftResetPending = false;
return send("load-manual-financial", { resultId: row.dataset.manualResultId });
}
if (row.dataset.manualViResultId) {
clearManualViResultPendingSelection(true);
return send("select-manual-vi-result", { resultId: row.dataset.manualViResultId });
}
return false;
}
document.addEventListener("focusin", function (event) {
const row = event.target && typeof event.target.closest === "function"
? event.target.closest("[data-roving-row]") : null;
const list = row && row.closest("[data-roving-list]");
if (row && list) focusRovingListRow(list, row, false);
});
document.addEventListener("keydown", function (event) {
const activeModal = modalFocusManager.getActiveModal();
if (event.altKey && event.key === "F4") {
event.preventDefault();
event.stopPropagation();
// MainForm consumed Alt+F4 at the main workspace. Child modal behavior
// remains explicit, while the main workspace shortcut is a no-op.
if (activeModal && !event.repeat) closeActiveChildModalFromAltF4(activeModal);
return;
}
const rovingRow = event.target && typeof event.target.closest === "function"
? event.target.closest("[data-roving-row]") : null;
const rovingList = rovingRow && rovingRow.closest("[data-roving-list]");
if (rovingList && isRovingListNavigationKey(event.key) &&
!isImeComposing(event)) {
if (moveRovingListSelection(
rovingList,
rovingRow,
event.key,
activateRovingListRow)) {
event.preventDefault();
// MainForm.KeyPreview still applied its playlist boundary side effect
// behind non-modal ListBoxes. ShowDialog-owned GraphE/VI lists isolate it.
if (!activeModal && !event.repeat &&
(event.key === "Home" || event.key === "End")) {
selectPlaylistBoundary(event.key === "Home" ? "first" : "last");
}
}
return;
}
const dragLoopShortcut = event.key === "Escape" || event.key === "F2" ||
event.key === "F3" || event.key === "F8";
const pointerDragStarted = (cutDragGesture && cutDragGesture.started) ||
(playlistDragGesture && playlistDragGesture.started);
if (pointerDragStarted && dragLoopShortcut) {
// Native DoDragDrop runs its own modal loop. MainForm shortcuts cannot run
// until that loop ends. Match that behavior for both pointer-capture drag
// surfaces; Escape cancels without issuing TAKE OUT.
event.preventDefault();
if (event.key === "Escape") {
if (cutDragGesture) clearCutDragGesture();
if (playlistDragGesture) {
deferredPlaylistPlainSelectionRowId = null;
clearPlaylistDragGesture();
}
}
} else if (event.key === "Delete" && !event.repeat && dialog.hidden &&
!operatorCatalogModal.hidden && operatorCatalogState &&
(operatorCatalogState.mode === "create" || operatorCatalogState.mode === "edit")) {
const draftRow = event.target.closest("[data-operator-catalog-row-id]");
const textEditor = event.target.closest("input, textarea, [contenteditable='true']");
if (draftRow && !textEditor && !operatorCatalogMutationLocked &&
draftRow.dataset.operatorCatalogRowId === operatorCatalogState.activeDraftRowId) {
event.preventDefault();
event.stopPropagation();
send("remove-active-operator-catalog-draft-row", {});
}
} else if (event.key === "Escape" && modalFocusManager.getActiveModal()) {
// The legacy child forms did not assign a CancelButton and none handled
// Escape. ShowDialog isolated MainForm, so Escape cannot fall through to
// MainForm's TAKE OUT shortcut. The one-button MessageBox is the exception:
// its Escape is equivalent to acknowledging OK.
event.preventDefault();
if (!event.repeat && modalFocusManager.getActiveModal() === legacyAlertDialog &&
!legacyDialogIsConfirmation) {
send("dismiss-dialog", {});
}
} else if ((event.key === "ArrowUp" || event.key === "ArrowDown") &&
dialog.hidden && manualModal.hidden && manualListModal.hidden &&
namedPlaylistModal.hidden && namedPlaylistConfirmationModal.hidden &&
operatorCatalogModal.hidden && event.target.closest(".playlist-data-row")) {
if (movePlaylistKeyboardSelection(
event.target.closest(".playlist-data-row"), event)) {
event.preventDefault();
}
} else if (event.key === "Delete" && !event.repeat && dialog.hidden && manualModal.hidden &&
manualListModal.hidden && namedPlaylistModal.hidden &&
namedPlaylistConfirmationModal.hidden && operatorCatalogModal.hidden) {
// MainForm.KeyPreview invoked playlist deletion but did not suppress the key,
// so an editor that owns focus must still perform its normal text deletion.
if (!playlistDeleteSelectedLocked) send("delete-selected-playlist-rows", {});
} else if (event.key === " " && !event.repeat && dialog.hidden && manualModal.hidden &&
manualListModal.hidden && namedPlaylistModal.hidden &&
namedPlaylistConfirmationModal.hidden && operatorCatalogModal.hidden &&
event.target.closest(".playlist-data-row")) {
event.preventDefault();
if (!playlistProgramEnableMutationLocked) {
send("toggle-active-playlist-enabled", {});
}
} else if ((event.key === "Home" || event.key === "End") &&
!event.repeat && dialog.hidden && manualModal.hidden && manualListModal.hidden &&
namedPlaylistModal.hidden && namedPlaylistConfirmationModal.hidden &&
operatorCatalogModal.hidden) {
// MainForm handled its playlist/timer side effect and then returned the
// base key result, so a focused textbox/list control still receives Home/End.
selectPlaylistBoundary(event.key === "Home" ? "first" : "last");
}
});
dialogOk.addEventListener("click", function () {
send(legacyDialogIsConfirmation
? "confirm-native-dialog"
: "dismiss-dialog", {});
});
dialogNo.addEventListener("click", function () {
if (legacyDialogIsConfirmation) {
send("cancel-native-dialog", {});
}
});
if (webview) {
webview.addEventListener("message", function (event) {
if (event.data && event.data.type === "state" && event.data.payload) {
render(event.data.payload);
}
});
send("ready", {});
} else {
status.textContent = "WebView2 브리지를 사용할 수 없습니다.";
}
}());