feat: restore legacy modal and drag behavior

This commit is contained in:
2026-07-16 01:33:48 +09:00
parent 83398044c6
commit d7ec571343
30 changed files with 3597 additions and 321 deletions

View File

@@ -34,6 +34,14 @@
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");
@@ -73,9 +81,33 @@
const operatorCatalogDelete = document.getElementById("operator-catalog-delete");
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;
let namedPlaylistMode = null;
let namedPlaylistState = null;
let namedPlaylistConfirmation = null;
let manualDraft = null;
let manualDraftKey = "";
let operatorCatalogState = null;
@@ -86,10 +118,10 @@
let comparisonTargetClickTimer = null;
let manualViResultClickTimer = null;
let operatorCatalogStockClickTimer = null;
let namedPlaylistResultClickTimer = null;
let pendingNamedPlaylistCommand = null;
let nextNamedPlaylistRequestId = 1;
let lastCommandSequence = 0;
const selectedCutsDragToken = "legacy-selected-cuts";
let operatorMutationLocked = true;
const playlistDragMime = "text/x-mbn-playlist-row";
const operatorCatalogDragMime = "text/x-mbn-operator-catalog-row";
let draggedPlaylistRowId = null;
@@ -97,6 +129,8 @@
let playlistDragBlockedRowId = null;
let playlistMutationLocked = false;
let cutSelectionEnabled = false;
let cutDragSize = { width: 4, height: 4 };
let cutDragGesture = null;
let lastPresentedPlayoutRowKey = "";
let draggedTabId = null;
let tabDragLastTargetId = null;
@@ -106,9 +140,80 @@
let operatorCatalogMutationLocked = 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-result-id]") ||
manualWorkspace.querySelector("button[data-manual-result-id]") ||
manualWorkspace.querySelector(".manual-financial-list-header");
}
if (modal === manualListDialog) {
return 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; });
@@ -214,19 +319,33 @@
});
const selected = categoryTree.querySelector(
"[data-tree-node-key].tree-node-selected");
const active = categoryTree.contains(document.activeElement)
? document.activeElement.closest("[data-tree-node-key]")
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,
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 : 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) {
renderedTreeTabId = nextTabId;
@@ -250,7 +369,7 @@
syncTreeSectionExpanded(section);
});
const firstRoot = roots[0];
selectTreeNode(firstRoot, true);
selectTreeNode(firstRoot, !modalFocusManager.getActiveModal());
if (typeof firstRoot.scrollIntoView === "function") {
firstRoot.scrollIntoView({ block: "nearest", inline: "nearest" });
}
@@ -278,15 +397,41 @@
}
const selected = findTreeNodeByKey(saved.selectedKey);
if (selected) selectTreeNode(selected, false);
const canRestoreFocus = treeFocusOwnedBeforeRender &&
!modalFocusManager.getActiveModal();
const focused = findTreeNodeByKey(saved.focusKey);
if (focused && typeof focused.focus === "function") {
if (canRestoreFocus && focused && typeof focused.focus === "function") {
focused.focus({ preventScroll: true });
}
if (canRestoreFocus && saved.controlFocus && saved.controlFocus.id) {
const control = document.getElementById(saved.controlFocus.id);
if (control && categoryTree.contains(control) && control.disabled !== true &&
typeof control.focus === "function") {
control.focus({ preventScroll: true });
if (Number.isInteger(saved.controlFocus.selectionStart) &&
Number.isInteger(saved.controlFocus.selectionEnd) &&
typeof control.setSelectionRange === "function") {
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) webview.postMessage({ type: type, payload: payload || {} });
if (!webview || uiBusy) return false;
try {
webview.postMessage({ type: type, payload: payload || {} });
return true;
} catch (_) {
return false;
}
}
function isOperatorMutationLocked(state) {
@@ -491,11 +636,94 @@
function pointerPosition(event) {
const bounds = cutList.getBoundingClientRect();
return {
x: Math.max(0, Math.round(event.clientX - bounds.left)),
y: Math.max(0, Math.round(event.clientY - bounds.top))
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;
if (!gesture) return;
cutDragGesture = null;
gesture.element.classList.remove("dragging");
playlistDropZone.classList.remove("cut-drag-over");
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 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();
playlistDropZone.classList.toggle(
"cut-drag-over",
!playlistMutationLocked && pointInsideElement(playlistDropZone, event));
}
}
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",
@@ -533,7 +761,7 @@
element.setAttribute("role", "option");
element.dataset.physicalIndex = String(row.physicalIndex);
element.tabIndex = -1;
element.draggable = true;
element.draggable = false;
const number = document.createElement("span");
number.className = "cut-number";
@@ -551,11 +779,18 @@
physicalIndex: row.physicalIndex,
control: event.ctrlKey,
shift: event.shiftKey,
x: position.x,
y: position.y,
// 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))
});
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;
@@ -572,14 +807,6 @@
shift: event.shiftKey
});
});
element.addEventListener("dragstart", function (event) {
if (playlistMutationLocked || !event.dataTransfer) {
event.preventDefault();
return;
}
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", selectedCutsDragToken);
});
return element;
}
@@ -592,6 +819,10 @@
// 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)) {
@@ -605,18 +836,24 @@
element.className = "cut-row" +
(row.isSelected ? " selected" : "") +
(row.isSeparator ? " separator" : "");
(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 = !cutDragLocked;
element.draggable = false;
element.dataset.cutDragEnabled = cutSelectionEnabled ? "true" : "false";
});
while (cutList.children.length > state.cutRows.length) {
cutList.lastElementChild.remove();
}
if (cutDragGesture && !cutList.contains(cutDragGesture.element)) {
clearCutDragGesture();
}
}
cutList.addEventListener("pointerdown", function (event) {
@@ -1772,6 +2009,7 @@
return;
}
if (manualModal.hidden) rememberModalOpener(manualDialog);
manualModal.hidden = false;
manualTitle.textContent = manual.label + " · GraphE 수동 입력";
const detailKey = manual.screen + "|" + (manual.selectedRowId || "new") + "|" +
@@ -1914,6 +2152,7 @@
return;
}
if (manualListModal.hidden) rememberModalOpener(manualListDialog);
manualListModal.hidden = false;
const busy = state.isBusy === true;
if (manual.screen !== "vi") {
@@ -2144,24 +2383,210 @@
}
function openNamedPlaylist(mode) {
if (operatorMutationLocked) 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 });
}
send("refresh-named-playlists", {});
syncModalFocus();
beginNamedPlaylistBusyCycle("refresh-named-playlists", {});
return true;
}
function closeNamedPlaylist() {
clearDelayedClick(namedPlaylistResultClickTimer);
namedPlaylistResultClickTimer = null;
pendingNamedPlaylistCommand = null;
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;
pendingNamedPlaylistCommand = {
command: command,
targetId: definitionId,
definitionId: definitionId,
minimumSequence: lastCommandSequence + 1,
closeOnSuccess: true,
showSavedAlert: showSavedAlert === true
};
if (!send(command, { definitionId: definitionId })) {
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();
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) {
@@ -2169,32 +2594,49 @@
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 (!busy && pendingNamedPlaylistCommand && commandResult &&
commandResult.sequence >= pendingNamedPlaylistCommand.minimumSequence &&
commandResult.command === pendingNamedPlaylistCommand.command &&
commandResult.targetId === pendingNamedPlaylistCommand.definitionId) {
if (isMatchingNamedPlaylistReceipt(
busy,
pendingNamedPlaylistCommand,
commandResult
)) {
const completed = pendingNamedPlaylistCommand;
const succeeded = commandResult.succeeded === true;
pendingNamedPlaylistCommand = null;
if (succeeded) {
if (succeeded && completed.closeOnSuccess) {
if (completed.showSavedAlert) window.alert("저장되었습니다");
closeNamedPlaylist();
}
}
dbLoadButton.disabled = busy || !named;
dbSaveButton.disabled = busy || !named || named.canMutate !== true;
dbLoadButton.disabled = operatorMutationLocked || 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();
named.definitions.forEach(function (definition) {
const button = document.createElement("button");
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 = busy || named.isWriteInProgress === true;
button.disabled = definitionLocked;
button.textContent = definition.title;
fragment.appendChild(button);
});
@@ -2212,11 +2654,24 @@
namedPlaylistStatus.classList.toggle("error", named.isWriteQuarantined === true ||
named.statusKind === "error");
namedPlaylistCreateButton.disabled = busy || named.canCreate !== true ||
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 = busy || named.canDelete !== true;
namedPlaylistConfirmButton.disabled = busy ||
namedPlaylistDeleteButton.disabled = modalLocked || named.canDelete !== true;
namedPlaylistConfirmButton.disabled = modalLocked ||
(namedPlaylistMode === "save" ? named.canSave !== true : named.canLoad !== true);
namedPlaylistConfirmationYes.disabled = operatorMutationLocked || uiBusy ||
pendingNamedPlaylistCommand !== null ||
!!(namedPlaylistState && namedPlaylistState.isWriteInProgress === true);
if (deferredSelectionAction) {
runNamedPlaylistDoubleClick(
deferredSelectionAction.definitionId,
deferredSelectionAction.mode);
}
}
function clearOperatorCatalogDragVisuals() {
@@ -2329,6 +2784,7 @@
clearOperatorCatalogDragVisuals();
}
if (operatorCatalogModal.hidden) rememberModalOpener(operatorCatalogDialog);
operatorCatalogModal.hidden = false;
const isTheme = catalog.entity === "theme";
const editing = catalog.mode === "create" || catalog.mode === "edit";
@@ -2512,22 +2968,46 @@
function renderDialog(state) {
if (state.dialog && state.dialog.message) {
dialogMessage.textContent = state.dialog.message;
if (dialog.hidden) rememberModalOpener(legacyAlertDialog);
dialog.hidden = false;
dialogOk.focus();
} else {
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);
}
}
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");
if (document.activeElement !== searchInput) searchInput.value = state.searchText || "";
updateCutDragSize(state);
renderStockResults(state);
renderCuts(state);
renderTabs(state);
@@ -2550,6 +3030,7 @@
renderPlaylist(state);
renderNamedPlaylist(state);
renderDialog(state);
syncModalFocus();
status.textContent = isBusy ? "종목을 검색하고 있습니다." : (state.statusMessage || "");
}
@@ -2576,36 +3057,25 @@
const definition = event.target.closest("button[data-named-playlist-definition-id]");
if (!definition || definition.disabled) return;
event.preventDefault();
clearDelayedClick(namedPlaylistResultClickTimer);
namedPlaylistResultClickTimer = null;
const definitionId = definition.dataset.namedPlaylistDefinitionId;
if (namedPlaylistMode === "save" &&
!window.confirm("선택한 DB 재생목록에 현재 목록을 저장하시겠습니까?")) {
if (namedPlaylistSelection.isPending()) {
namedPlaylistSelection.defer(definitionId, {
definitionId: definitionId,
mode: namedPlaylistMode
});
return;
}
const command = namedPlaylistMode === "save"
? "save-current-named-playlist-to" : "load-named-playlist-by-id";
pendingNamedPlaylistCommand = {
command: command,
definitionId: definitionId,
minimumSequence: lastCommandSequence + 1
};
send(command, { definitionId: definitionId });
runNamedPlaylistDoubleClick(definitionId, namedPlaylistMode);
});
namedPlaylistDefinitions.addEventListener("click", function (event) {
const definition = event.target.closest("button[data-named-playlist-definition-id]");
if (definition && !definition.disabled) {
clearDelayedClick(namedPlaylistResultClickTimer);
namedPlaylistResultClickTimer = window.setTimeout(function () {
namedPlaylistResultClickTimer = null;
send("select-named-playlist", {
definitionId: definition.dataset.namedPlaylistDefinitionId
});
}, 250);
beginNamedPlaylistSelection(definition.dataset.namedPlaylistDefinitionId);
}
});
namedPlaylistNewTitle.addEventListener("input", function () {
namedPlaylistCreateButton.disabled = uiBusy || !namedPlaylistState ||
namedPlaylistCreateButton.disabled = isNamedPlaylistActionLocked() ||
!namedPlaylistState ||
namedPlaylistState.canCreate !== true ||
!isCanonicalNamedPlaylistTitle(namedPlaylistNewTitle.value);
});
@@ -2618,23 +3088,42 @@
namedPlaylistCreateButton.addEventListener("click", function () {
const title = namedPlaylistNewTitle.value;
if (isCanonicalNamedPlaylistTitle(title)) {
send("create-named-playlist", { title: title });
beginNamedPlaylistBusyCycle("create-named-playlist", { title: title });
}
});
namedPlaylistDeleteButton.addEventListener("click", function () {
const title = namedPlaylistState && namedPlaylistState.selectedTitle
? "'" + namedPlaylistState.selectedTitle + "'" : "선택한 재생목록";
if (window.confirm(title + "을(를) DB에서 삭제하겠습니까?")) {
send("delete-selected-named-playlist", {});
}
openNamedPlaylistConfirmation({
kind: "delete",
message: "프로그램이 삭제됩니다. 계속 하시겠습니까?",
closeOnNo: false
});
});
namedPlaylistConfirmButton.addEventListener("click", function () {
const definitionId = namedPlaylistState && namedPlaylistState.selectedDefinitionId;
if (!definitionId) return;
if (namedPlaylistMode === "save") {
send("save-current-named-playlist", {});
openNamedPlaylistConfirmation({
kind: "save",
definitionId: definitionId,
message: "저장하겠습니까?",
showSavedAlert: true,
closeOnNo: true
});
} else if (namedPlaylistMode === "load") {
send("load-selected-named-playlist", {});
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 () {
@@ -2962,31 +3451,6 @@
searchOperatorCatalog();
});
function hasDragType(dataTransfer, expectedType) {
return !!dataTransfer && Array.from(dataTransfer.types || [])
.some(function (type) { return type === expectedType; });
}
function isSelectedCutsDrop(dataTransfer) {
return hasDragType(dataTransfer, "text/plain") &&
dataTransfer.getData("text/plain") === selectedCutsDragToken;
}
playlistDropZone.addEventListener("dragover", function (event) {
// 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 (playlistMutationLocked ||
!hasDragType(event.dataTransfer, "text/plain")) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
});
playlistDropZone.addEventListener("drop", function (event) {
if (playlistMutationLocked || !isSelectedCutsDrop(event.dataTransfer)) return;
event.preventDefault();
event.stopPropagation();
send("drop-selected-cuts", {});
});
playlistMoveUp.addEventListener("click", function () {
send("move-selected-playlist-rows", { direction: "up" });
});
@@ -3420,13 +3884,31 @@
operatorCatalogDragBlockedRowId = null;
});
document.addEventListener("keydown", function (event) {
if (event.key === "Delete" && !event.repeat && dialog.hidden && manualModal.hidden &&
manualListModal.hidden && namedPlaylistModal.hidden && operatorCatalogModal.hidden) {
const cutDragShortcut = event.key === "Escape" || event.key === "F2" ||
event.key === "F3" || event.key === "F8";
if (cutDragGesture && cutDragGesture.started && cutDragShortcut) {
// Native DoDragDrop runs its own modal loop. MainForm shortcuts cannot run
// until that loop ends; Escape cancels the drag without issuing TAKE OUT.
event.preventDefault();
if (event.key === "Escape") clearCutDragGesture();
} 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) {
send("dismiss-dialog", {});
}
} 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 (!playlistMutationLocked) send("delete-selected-playlist-rows", {});
} else if (event.key === " " && !event.repeat && dialog.hidden && manualModal.hidden &&
manualListModal.hidden && namedPlaylistModal.hidden && operatorCatalogModal.hidden &&
manualListModal.hidden && namedPlaylistModal.hidden &&
namedPlaylistConfirmationModal.hidden && operatorCatalogModal.hidden &&
event.target.closest(".playlist-data-row")) {
event.preventDefault();
if (!playlistMutationLocked) {
@@ -3439,26 +3921,11 @@
}
} else if ((event.key === "Home" || event.key === "End") &&
!event.repeat && dialog.hidden && manualModal.hidden && manualListModal.hidden &&
namedPlaylistModal.hidden && operatorCatalogModal.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");
} else if (event.key === "Escape" && !event.repeat && !namedPlaylistModal.hidden) {
event.preventDefault();
closeNamedPlaylist();
} else if (event.key === "Escape" && !event.repeat && !manualModal.hidden) {
event.preventDefault();
clearDelayedClick(manualResultClickTimer);
manualResultClickTimer = null;
send("close-manual-financial", {});
} else if (event.key === "Escape" && !event.repeat && !manualListModal.hidden) {
event.preventDefault();
send("close-manual-list", {});
} else if (event.key === "Escape" && !event.repeat && !operatorCatalogModal.hidden) {
event.preventDefault();
clearDelayedClick(operatorCatalogStockClickTimer);
operatorCatalogStockClickTimer = null;
send("close-operator-catalog", {});
}
});
dialogOk.addEventListener("click", function () {