feat: align legacy operator and playout behavior

This commit is contained in:
2026-07-15 21:04:05 +09:00
parent 06a16e5741
commit 83398044c6
77 changed files with 6377 additions and 391 deletions

View File

@@ -96,12 +96,194 @@
let deferredPlaylistPlainSelectionRowId = null;
let playlistDragBlockedRowId = null;
let playlistMutationLocked = false;
let cutSelectionEnabled = false;
let lastPresentedPlayoutRowKey = "";
let draggedTabId = null;
let tabDragLastTargetId = null;
let pendingTabHoverSwap = null;
let draggedOperatorCatalogRowId = null;
let operatorCatalogDragBlockedRowId = null;
let operatorCatalogMutationLocked = false;
let renderedTreeTabId = null;
let treeInteractionLocked = false;
const treeUiStateByTab = new Map();
const pendingTreeResetTabs = new Set();
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 decorateTreeNodes(tabId) {
if (!tabId) return;
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);
});
}
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
}));
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 active = categoryTree.contains(document.activeElement)
? document.activeElement.closest("[data-tree-node-key]")
: null;
treeUiStateByTab.set(tabId, {
openSections: openSections,
selectedKey: selected ? selected.dataset.treeNodeKey : previous.selectedKey || null,
focusKey: active ? active.dataset.treeNodeKey : null,
scrollTop: categoryTree.scrollTop
});
}
function beginTreeRender(state) {
const nextTabId = activeTreeTabId(state);
if (renderedTreeTabId) captureTreeUiState(renderedTreeTabId);
if (nextTabId !== renderedTreeTabId) {
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;
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) {
details.forEach(function (section) {
section.open = true;
syncTreeSectionExpanded(section);
});
const firstRoot = roots[0];
selectTreeNode(firstRoot, true);
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 focused = findTreeNodeByKey(saved.focusKey);
if (focused && typeof focused.focus === "function") {
focused.focus({ preventScroll: true });
}
if (Number.isFinite(saved.scrollTop)) categoryTree.scrollTop = saved.scrollTop;
}
function send(type, payload) {
if (webview && !uiBusy) webview.postMessage({ type: type, payload: payload || {} });
@@ -111,7 +293,8 @@
const playout = state && state.playout;
return !state || state.isBusy === true || state.fixedSectionBatch != null ||
(playout && (playout.isBusy === true || playout.outcomeUnknown === true ||
playout.isPlayCompletionPending === true || playout.phase !== "idle"));
playout.isPlayCompletionPending === true ||
playout.isTakeOutCompletionPending === true || playout.phase !== "idle"));
}
function dragDropPosition(event, element) {
@@ -119,22 +302,162 @@
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 clearDelayedClick(timer) {
if (timer !== null) window.clearTimeout(timer);
}
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) 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 () {
send("select-stock", { resultIndex: Number(button.dataset.resultIndex) });
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)) {
@@ -146,16 +469,23 @@
}
}
button.className = "stock-result" +
(state.selectedStockIndex === row.index ? " selected" : "");
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.textContent = row.displayName;
button.setAttribute("aria-selected", state.selectedStockIndex === row.index ? "true" : "false");
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 pointerPosition(event) {
@@ -166,10 +496,43 @@
};
}
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 = true;
const number = document.createElement("span");
@@ -180,6 +543,9 @@
element.append(number, label);
element.addEventListener("pointerdown", function (event) {
if (playlistMutationLocked) return event.preventDefault();
if (!cutSelectionEnabled) return event.preventDefault();
focusCutElement(element, false);
const position = pointerPosition(event);
send("cut-pointer-down", {
physicalIndex: row.physicalIndex,
@@ -190,16 +556,42 @@
timestampMilliseconds: Math.max(0, Math.round(event.timeStamp))
});
});
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 (playlistMutationLocked) return;
if (!cutSelectionEnabled) return;
const target = cutKeyboardTarget(element, event.key);
focusCutElement(target, true);
send("cut-key-down", {
key: key,
control: event.ctrlKey,
shift: event.shiftKey
});
});
element.addEventListener("dragstart", function (event) {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", selectedCutsDragToken);
if (playlistMutationLocked || !event.dataTransfer) {
event.preventDefault();
return;
}
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", selectedCutsDragToken);
});
return element;
}
function renderCuts(state) {
const cutDragLocked = isOperatorMutationLocked(state);
cutSelectionEnabled = Number.isInteger(state.selectedStockIndex) && !cutDragLocked;
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;
state.cutRows.forEach(function (row, index) {
let element = cutList.children.item(index);
if (!element || element.dataset.physicalIndex !== String(row.physicalIndex)) {
@@ -216,7 +608,10 @@
(row.isSeparator ? " separator" : "");
element.setAttribute("aria-selected", row.isSelected ? "true" : "false");
element.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false");
element.draggable = state.isBusy !== true;
element.tabIndex = row.isFocused === true || (focusedCutIndex < 0 && index === 0)
? 0
: -1;
element.draggable = !cutDragLocked;
});
while (cutList.children.length > state.cutRows.length) {
@@ -224,6 +619,11 @@
}
}
cutList.addEventListener("pointerdown", function (event) {
if (event.target !== cutList || playlistMutationLocked) return;
send("clear-cut-selection", {});
});
function clearPlaylistDragVisuals() {
playlistRows.querySelectorAll(".dragging, .drag-before, .drag-after")
.forEach(function (row) {
@@ -312,7 +712,12 @@
enabledCell.className = "enabled-cell";
const enabled = document.createElement("input");
enabled.type = "checkbox";
enabled.tabIndex = -1;
enabled.addEventListener("click", function (event) {
if (playlistMutationLocked) event.preventDefault();
});
enabled.addEventListener("change", function () {
if (playlistMutationLocked) return;
send("set-playlist-enabled", {
rowId: element.dataset.rowId,
enabled: enabled.checked
@@ -332,15 +737,17 @@
// not necessarily the checkbox originally pressed. Remember the
// pointer origin so a checkbox gesture cannot reorder the row.
playlistDragBlockedRowId = element.dataset.rowId;
element.focus({ preventScroll: true });
send("activate-playlist-row", { rowId: element.dataset.rowId });
return;
}
playlistDragBlockedRowId = null;
element.focus({ preventScroll: true });
const plainSelection = !event.ctrlKey && !event.shiftKey;
const preserveSelectedBlock = plainSelection &&
const deferSelectedRowPlainClickForDrag = plainSelection &&
element.getAttribute("aria-selected") === "true" &&
playlistRows.querySelectorAll('.playlist-data-row[aria-selected="true"]').length > 1;
if (preserveSelectedBlock) {
if (deferSelectedRowPlainClickForDrag) {
deferredPlaylistPlainSelectionRowId = element.dataset.rowId;
return;
}
@@ -388,11 +795,14 @@
clearPlaylistDragVisuals();
}
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);
@@ -405,27 +815,41 @@
const enabled = element.querySelector("input");
enabled.checked = row.isEnabled;
enabled.disabled = state.isBusy === true;
enabled.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false");
enabled.disabled = false;
enabled.setAttribute("aria-disabled", playlistMutationLocked ? "true" : "false");
const isCandidate = row.isActive === true;
element.className = "playlist-row playlist-data-row" +
(row.isSelected ? " selected" : "");
element.setAttribute("aria-selected", row.isSelected ? "true" : "false");
(presentation.isOnAir ? " on-air" : "") +
(presentation.isCurrent ? " playout-current" : "") +
(presentation.isSelected || isCandidate ? " selected" : "");
element.dataset.active = isCandidate ? "true" : "false";
element.setAttribute(
"aria-selected",
presentation.isSelected ? "true" : "false");
element.setAttribute(
"aria-current",
presentation.isCurrent ? "true" : "false");
element.draggable = !playlistMutationLocked;
element.setAttribute("aria-disabled", playlistMutationLocked ? "true" : "false");
[row.marketText, row.stockName, row.graphicType, row.subtype, row.pageText]
[row.marketText, row.stockName, row.graphicType, row.subtype, presentation.pageText]
.forEach(function (text, cellIndex) {
const cell = element.children.item(cellIndex + 1);
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();
});
presentCurrentPlaylistRow(currentPlayoutRow, currentPlayoutFocusKey);
const interactionDisabled = state.isBusy === true;
const interactionDisabled = playlistMutationLocked;
const enabledCount = state.playlist.filter(function (row) { return row.isEnabled; }).length;
playlistEnableAll.checked = state.playlist.length === 0 || enabledCount === state.playlist.length;
playlistEnableAll.indeterminate = enabledCount > 0 && enabledCount < state.playlist.length;
@@ -457,14 +881,69 @@
}
}
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;
pendingTabHoverSwap) return false;
const order = tabOrderFromDom();
const sourceIndex = order.indexOf(draggedTabId);
const targetIndex = order.indexOf(targetId);
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return;
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return false;
const expectedOrder = order.slice();
expectedOrder[sourceIndex] = targetId;
@@ -487,6 +966,7 @@
expectedSourceIndex: sourceIndex,
expectedTargetIndex: targetIndex
});
return true;
}
function renderTabs(state) {
@@ -878,6 +1358,7 @@
}
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;
@@ -2040,6 +2521,7 @@
function render(state) {
const isBusy = state.isBusy === true;
const treeTabId = beginTreeRender(state);
uiBusy = isBusy;
document.body.classList.toggle("busy", isBusy);
document.body.setAttribute("aria-busy", isBusy ? "true" : "false");
@@ -2053,7 +2535,7 @@
movingAverage20.checked = state.movingAverages ? state.movingAverages.ma20 : true;
movingAverage5.disabled = isBusy;
movingAverage20.disabled = isBusy;
catalogExpandAll.disabled = isBusy;
catalogExpandAll.disabled = treeInteractionLocked;
renderFixedCatalog(state);
renderIndustry(state);
renderOverseas(state);
@@ -2061,6 +2543,7 @@
renderTheme(state);
renderExpert(state);
renderTradingHalt(state);
finishTreeRender(treeTabId);
renderManualFinancial(state);
renderManualLists(state);
renderOperatorCatalog(state);
@@ -2493,12 +2976,13 @@
// A playlist row also drops inside this ancestor. Only advertise the
// drop zone for the cut-list payload; otherwise the bubbled row drop
// would append the still-selected cut as an extra playlist entry.
if (!hasDragType(event.dataTransfer, "text/plain")) return;
if (playlistMutationLocked ||
!hasDragType(event.dataTransfer, "text/plain")) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
});
playlistDropZone.addEventListener("drop", function (event) {
if (!isSelectedCutsDrop(event.dataTransfer)) return;
if (playlistMutationLocked || !isSelectedCutsDrop(event.dataTransfer)) return;
event.preventDefault();
event.stopPropagation();
send("drop-selected-cuts", {});
@@ -2538,21 +3022,7 @@
event.dataTransfer.setData("text/x-mbn-tab", draggedTabId);
button.classList.add("dragging");
});
marketTabs.addEventListener("dragover", function (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) return;
if (pendingTabHoverSwap) return;
tabDragLastTargetId = targetId;
requestTabHoverSwap(targetId);
});
marketTabs.addEventListener("dragover", onMarketTabDragOver);
marketTabs.addEventListener("drop", function (event) {
const target = event.target.closest("button[data-tab-id]");
if (!target || !event.dataTransfer || !draggedTabId) return;
@@ -2578,11 +3048,28 @@
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();
@@ -2663,6 +3150,28 @@
}
});
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;
@@ -2867,7 +3376,16 @@
}
});
categoryTree.addEventListener("keydown", function (event) {
if (event.key === "Enter" &&
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();
@@ -2904,20 +3422,27 @@
document.addEventListener("keydown", function (event) {
if (event.key === "Delete" && !event.repeat && dialog.hidden && manualModal.hidden &&
manualListModal.hidden && namedPlaylistModal.hidden && operatorCatalogModal.hidden) {
event.preventDefault();
send("delete-selected-playlist-rows", {});
// 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 (!playlistMutationLocked) send("delete-selected-playlist-rows", {});
} else if (event.key === " " && !event.repeat && dialog.hidden && manualModal.hidden &&
manualListModal.hidden && namedPlaylistModal.hidden && operatorCatalogModal.hidden &&
event.target.closest(".playlist-data-row")) {
event.preventDefault();
send("toggle-active-playlist-enabled", {});
if (!playlistMutationLocked) {
const row = event.target.closest(".playlist-data-row");
const enabled = row.querySelector('input[type="checkbox"]');
send("set-playlist-enabled", {
rowId: row.dataset.rowId,
enabled: enabled.checked !== true
});
}
} else if ((event.key === "Home" || event.key === "End") &&
!event.repeat && dialog.hidden && manualModal.hidden && manualListModal.hidden &&
namedPlaylistModal.hidden && operatorCatalogModal.hidden) {
event.preventDefault();
send("select-playlist-boundary", {
boundary: event.key === "Home" ? "first" : "last"
});
// 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");
} else if (event.key === "Escape" && !event.repeat && !namedPlaylistModal.hidden) {
event.preventDefault();
closeNamedPlaylist();

View File

@@ -69,8 +69,8 @@
<label><input id="playout-background-none" type="checkbox" disabled> 배경없음(F3)</label>
<button id="playout-background-file" type="button" disabled>배경파일(F2)</button>
<input id="playout-background-name" class="background-name" value="배경없음" readonly>
<label class="fade-control" for="playout-fade-duration" hidden>효과</label>
<select id="playout-fade-duration" disabled aria-label="장면 효과 시간" hidden>
<label class="fade-control" for="playout-fade-duration">DissolveTime</label>
<select id="playout-fade-duration" disabled aria-label="DissolveTime">
<option value="0">1</option><option value="1">2</option>
<option value="2">3</option><option value="3">4</option>
<option value="4">5</option><option value="5">6</option>

View File

@@ -62,9 +62,20 @@
function nextLabel(playout) {
if (!playout || playout.nextKind === "none") return "";
if (playout.nextKind === "endOfPlaylist") return " · 마지막 항목";
return playout.nextKind === "pageNext" ? " · 다음 페이지" : " · 다음 항목";
}
function canTakeInFromState(playout, hasPlaylist) {
return playout.phase === "prepared" ||
hasPlaylist && (playout.phase === "idle" || playout.phase === "program");
}
function canAdvanceFromState(playout) {
return playout.phase === "program" &&
playout.nextKind !== "none" && playout.nextKind !== "endOfPlaylist";
}
function render(state) {
currentState = state || currentState;
const playout = currentState && currentState.playout;
@@ -79,7 +90,8 @@
}
const commandAvailable = playout.isCommandAvailable === true &&
playout.outcomeUnknown !== true;
playout.outcomeUnknown !== true &&
playout.isTakeOutCompletionPending !== true;
const hasPlaylist = Array.isArray(currentState.playlist) &&
currentState.playlist.some(function (row) { return row.isEnabled === true; });
const liveTakeInAllowed = playout.mode !== "live" ||
@@ -93,15 +105,15 @@
prepare.classList.toggle("active", playout.phase !== "idle");
prepare.disabled = busy || !commandAvailable || !canPrepareToggle ||
playout.isPlayCompletionPending === true;
const canTakeIn = playout.phase === "prepared" ||
(playout.phase === "idle" && hasPlaylist);
const canTakeIn = canTakeInFromState(playout, hasPlaylist);
takeIn.disabled = busy || !commandAvailable || !liveTakeInAllowed ||
!canTakeIn || playout.isPlayCompletionPending === true;
next.disabled = busy || !commandAvailable ||
playout.phase !== "program" || playout.nextKind === "none" ||
!canAdvanceFromState(playout) ||
playout.isPlayCompletionPending === true;
takeOut.disabled = busy || !commandAvailable ||
(playout.phase !== "prepared" && playout.phase !== "program");
// MainForm's Escape path called StopAll whenever the Tornado player existed,
// even if its local m_TakeIn flag had already drifted back to IDLE.
takeOut.disabled = busy || !commandAvailable;
backgroundNone.disabled = busy || playout.canChangeBackground !== true;
backgroundFile.disabled = busy || playout.canChangeBackground !== true;
fadeDuration.disabled = busy || playout.canChangeBackground !== true;
@@ -142,7 +154,7 @@
});
document.addEventListener("keydown", function (event) {
if (event.repeat || hasOpenDialog()) return;
if (event.defaultPrevented || event.repeat || hasOpenDialog()) return;
if (event.key === "F2") {
event.preventDefault();
if (!backgroundFile.disabled) backgroundFile.click();

View File

@@ -43,7 +43,7 @@ button:disabled { color: #555; opacity: 1; }
.search-line button span { font-size: 21px; margin-right: 20px; }
.stock-results { height: 174px; border: 1px solid #999; background: #fff; overflow-y: auto; }
.stock-result { display: block; width: 100%; height: 20px; padding: 1px 5px; border: 0; background: #fff; text-align: left; font-size: 16px; line-height: 18px; }
.stock-result { display: block; width: 100%; height: 17px; padding: 0 5px; border: 0; background: #fff; text-align: left; font-size: 16px; line-height: 17px; }
.stock-result.selected { background: #696969; color: #fff; }
.manual-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin: 9px 0; }
@@ -133,6 +133,7 @@ button:disabled { color: #555; opacity: 1; }
.cut-row > span { padding: 2px 7px; pointer-events: none; }
.cut-row .cut-number { border-right: 1px solid #ddd; text-align: center; color: #145ab4; }
.cut-row.selected { background: #dcdcdc; }
.cut-row:focus { outline: 1px dotted #222; outline-offset: -2px; }
.cut-row.separator { background: #fff; }
.operator-status { min-height: 18px; padding-top: 2px; color: #a40000; font-size: 11px; }
@@ -148,6 +149,16 @@ button:disabled { color: #555; opacity: 1; }
.tree-root { margin: 5px 0 2px; color: #168000; font-size: 16px; }
.tree-root.second { margin-top: 15px; }
.catalog-section > summary { margin: 5px 0 2px; color: #168000; font-size: 16px; cursor: default; user-select: none; }
details > summary[data-tree-root] { display: flex; align-items: center; min-height: 24px; list-style: none; cursor: default; user-select: none; }
details > summary[data-tree-root]::-webkit-details-marker { display: none; }
.tree-disclosure { flex: 0 0 22px; width: 22px; text-align: center; }
.tree-disclosure::before { content: "\25B6"; font-size: 10px; }
details[open] > summary[data-tree-root] .tree-disclosure::before { content: "\25BC"; }
.tree-root-label { flex: 1; min-width: 0; }
summary[data-tree-root].tree-node-selected,
.tree-item.tree-node-selected { background: #dcdcdc; outline: none; }
summary[data-tree-root]:focus-visible { background: #dcdcdc; outline: none; }
summary[data-tree-root][aria-disabled="true"] { color: #777; }
.catalog-actions { display: grid; }
.tree-item { width: 100%; padding: 3px 0 3px 29px; border: 0; background: transparent; color: #111; font: inherit; font-size: 15px; text-align: left; cursor: default; }
.tree-item:hover, .tree-item:focus-visible { background: #dcdcdc; outline: none; }
@@ -279,7 +290,9 @@ button:disabled { color: #555; opacity: 1; }
.playlist-data-row { min-height: 25px; background: #fff; border-bottom: 1px solid #ddd; }
.playlist-data-row > div { padding: 3px 6px; border-right: 1px solid #ddd; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.playlist-data-row .enabled-cell { text-align: center; }
.playlist-data-row.selected { background: #dcdcdc; }
.playlist-data-row.on-air { background: #fdc8d2; }
.playlist-data-row.selected,
.playlist-data-row.on-air.selected { background: #dcdcdc; }
.playlist-data-row:focus { outline: 1px dotted #333; outline-offset: -2px; }
.playlist-data-row[draggable="true"] { cursor: grab; }
.playlist-data-row.dragging { opacity: .55; cursor: grabbing; }