|
|
|
|
@@ -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();
|
|
|
|
|
|