feat: restore legacy drag events and Tornado launch integration

This commit is contained in:
2026-07-15 15:35:25 +09:00
parent 6e0c275fdd
commit 06a16e5741
32 changed files with 3096 additions and 101 deletions

View File

@@ -61,6 +61,8 @@
const operatorCatalogCode = document.getElementById("operator-catalog-code");
const operatorCatalogNameLabel = document.getElementById("operator-catalog-name-label");
const operatorCatalogName = document.getElementById("operator-catalog-name");
const operatorCatalogNameCheck = document.getElementById("operator-catalog-name-check");
const operatorCatalogNameStatus = document.getElementById("operator-catalog-name-status");
const operatorCatalogStockQuery = document.getElementById("operator-catalog-stock-query");
const operatorCatalogStockSearch = document.getElementById("operator-catalog-stock-search");
const operatorCatalogStockResults = document.getElementById("operator-catalog-stock-results");
@@ -87,11 +89,36 @@
let namedPlaylistResultClickTimer = null;
let pendingNamedPlaylistCommand = null;
let lastCommandSequence = 0;
const selectedCutsDragToken = "legacy-selected-cuts";
const playlistDragMime = "text/x-mbn-playlist-row";
const operatorCatalogDragMime = "text/x-mbn-operator-catalog-row";
let draggedPlaylistRowId = null;
let deferredPlaylistPlainSelectionRowId = null;
let playlistDragBlockedRowId = null;
let playlistMutationLocked = false;
let draggedTabId = null;
let tabDragLastTargetId = null;
let pendingTabHoverSwap = null;
let draggedOperatorCatalogRowId = null;
let operatorCatalogDragBlockedRowId = null;
let operatorCatalogMutationLocked = false;
function send(type, payload) {
if (webview && !uiBusy) webview.postMessage({ type: type, payload: payload || {} });
}
function isOperatorMutationLocked(state) {
const playout = state && state.playout;
return !state || state.isBusy === true || state.fixedSectionBatch != null ||
(playout && (playout.isBusy === true || playout.outcomeUnknown === true ||
playout.isPlayCompletionPending === true || playout.phase !== "idle"));
}
function dragDropPosition(event, element) {
const bounds = element.getBoundingClientRect();
return event.clientY < bounds.top + (bounds.height / 2) ? "before" : "after";
}
function clearDelayedClick(timer) {
if (timer !== null) window.clearTimeout(timer);
}
@@ -166,7 +193,7 @@
element.addEventListener("dragstart", function (event) {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", "legacy-selected-cuts");
event.dataTransfer.setData("text/plain", selectedCutsDragToken);
}
});
return element;
@@ -197,6 +224,83 @@
}
}
function clearPlaylistDragVisuals() {
playlistRows.querySelectorAll(".dragging, .drag-before, .drag-after")
.forEach(function (row) {
row.classList.remove("dragging", "drag-before", "drag-after");
});
}
function onPlaylistDragStart(event) {
const row = event.currentTarget;
const rowId = row.dataset.rowId;
if (playlistMutationLocked || !rowId || !event.dataTransfer ||
playlistDragBlockedRowId === rowId || event.target.closest("input, button")) {
playlistDragBlockedRowId = null;
event.preventDefault();
return;
}
playlistDragBlockedRowId = null;
deferredPlaylistPlainSelectionRowId = null;
draggedPlaylistRowId = rowId;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(playlistDragMime, rowId);
row.classList.add("dragging");
}
function onPlaylistDragOver(event) {
const target = event.currentTarget;
if (playlistMutationLocked || !draggedPlaylistRowId ||
target.dataset.rowId === draggedPlaylistRowId) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
const position = dragDropPosition(event, target);
target.classList.toggle("drag-before", position === "before");
target.classList.toggle("drag-after", position === "after");
}
function onPlaylistDragLeave(event) {
event.currentTarget.classList.remove("drag-before", "drag-after");
}
function onPlaylistDrop(event) {
const target = event.currentTarget;
const sourceRowId = draggedPlaylistRowId;
const targetRowId = target.dataset.rowId;
if (playlistMutationLocked || !sourceRowId || !targetRowId ||
sourceRowId === targetRowId || !event.dataTransfer) {
clearPlaylistDragVisuals();
draggedPlaylistRowId = null;
return;
}
const token = event.dataTransfer.getData(playlistDragMime);
if (token !== sourceRowId) {
clearPlaylistDragVisuals();
draggedPlaylistRowId = null;
return;
}
event.preventDefault();
const position = dragDropPosition(event, target);
clearPlaylistDragVisuals();
draggedPlaylistRowId = null;
send("reorder-playlist-rows", {
sourceRowId: sourceRowId,
targetRowId: targetRowId,
position: position
});
}
function onPlaylistDragEnd() {
draggedPlaylistRowId = null;
deferredPlaylistPlainSelectionRowId = null;
playlistDragBlockedRowId = null;
clearPlaylistDragVisuals();
}
function createPlaylistElement(row) {
const element = document.createElement("div");
element.className = "playlist-row playlist-data-row";
@@ -222,18 +326,67 @@
}
element.addEventListener("pointerdown", function (event) {
if (event.button !== 0 || enabledCell.contains(event.target)) return;
if (event.button !== 0) return;
if (enabledCell.contains(event.target)) {
// Native dragstart targets the nearest draggable ancestor (the row),
// not necessarily the checkbox originally pressed. Remember the
// pointer origin so a checkbox gesture cannot reorder the row.
playlistDragBlockedRowId = element.dataset.rowId;
return;
}
playlistDragBlockedRowId = null;
element.focus({ preventScroll: true });
const plainSelection = !event.ctrlKey && !event.shiftKey;
const preserveSelectedBlock = plainSelection &&
element.getAttribute("aria-selected") === "true" &&
playlistRows.querySelectorAll('.playlist-data-row[aria-selected="true"]').length > 1;
if (preserveSelectedBlock) {
deferredPlaylistPlainSelectionRowId = element.dataset.rowId;
return;
}
deferredPlaylistPlainSelectionRowId = null;
send("select-playlist-row", {
rowId: element.dataset.rowId,
control: event.ctrlKey,
shift: event.shiftKey
});
});
element.addEventListener("pointerup", function (event) {
if (enabledCell.contains(event.target)) {
playlistDragBlockedRowId = null;
return;
}
if (event.button !== 0 ||
deferredPlaylistPlainSelectionRowId !== element.dataset.rowId) return;
deferredPlaylistPlainSelectionRowId = null;
send("select-playlist-row", {
rowId: element.dataset.rowId,
control: false,
shift: false
});
});
element.addEventListener("pointercancel", function () {
if (playlistDragBlockedRowId === element.dataset.rowId) {
playlistDragBlockedRowId = null;
}
});
element.addEventListener("dragstart", onPlaylistDragStart);
element.addEventListener("dragover", onPlaylistDragOver);
element.addEventListener("dragleave", onPlaylistDragLeave);
element.addEventListener("drop", onPlaylistDrop);
element.addEventListener("dragend", onPlaylistDragEnd);
return element;
}
function renderPlaylist(state) {
playlistMutationLocked = isOperatorMutationLocked(state);
if (playlistMutationLocked) {
draggedPlaylistRowId = null;
deferredPlaylistPlainSelectionRowId = null;
playlistDragBlockedRowId = null;
clearPlaylistDragVisuals();
}
const existingRows = new Map();
Array.from(playlistRows.children).forEach(function (element) {
existingRows.set(element.dataset.rowId, element);
@@ -257,6 +410,8 @@
element.className = "playlist-row playlist-data-row" +
(row.isSelected ? " selected" : "");
element.setAttribute("aria-selected", row.isSelected ? "true" : "false");
element.draggable = !playlistMutationLocked;
element.setAttribute("aria-disabled", playlistMutationLocked ? "true" : "false");
[row.marketText, row.stockName, row.graphicType, row.subtype, row.pageText]
.forEach(function (text, cellIndex) {
const cell = element.children.item(cellIndex + 1);
@@ -281,6 +436,59 @@
playlistDeleteAll.disabled = interactionDisabled;
}
function tabOrderFromDom() {
return Array.from(marketTabs.querySelectorAll("button[data-tab-id]"))
.map(function (button) { return button.dataset.tabId; });
}
function clearPendingTabHoverSwap() {
if (pendingTabHoverSwap && pendingTabHoverSwap.timeoutId !== null) {
window.clearTimeout(pendingTabHoverSwap.timeoutId);
}
pendingTabHoverSwap = null;
}
function acknowledgePendingTabHoverSwap(order) {
if (!pendingTabHoverSwap) return;
const orderKey = order.join("|");
if (orderKey === pendingTabHoverSwap.expectedOrderKey ||
orderKey !== pendingTabHoverSwap.initialOrderKey) {
clearPendingTabHoverSwap();
}
}
function requestTabHoverSwap(targetId) {
if (uiBusy || !draggedTabId || !targetId || draggedTabId === targetId ||
pendingTabHoverSwap) return;
const order = tabOrderFromDom();
const sourceIndex = order.indexOf(draggedTabId);
const targetIndex = order.indexOf(targetId);
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return;
const expectedOrder = order.slice();
expectedOrder[sourceIndex] = targetId;
expectedOrder[targetIndex] = draggedTabId;
const pending = {
initialOrderKey: order.join("|"),
expectedOrderKey: expectedOrder.join("|"),
timeoutId: null
};
pending.timeoutId = window.setTimeout(function () {
if (pendingTabHoverSwap === pending) {
pendingTabHoverSwap = null;
tabDragLastTargetId = null;
}
}, 500);
pendingTabHoverSwap = pending;
send("hover-swap-tabs", {
source: draggedTabId,
target: targetId,
expectedSourceIndex: sourceIndex,
expectedTargetIndex: targetIndex
});
}
function renderTabs(state) {
if (!Array.isArray(state.tabs)) return;
const existingTabs = new Map();
@@ -298,6 +506,8 @@
button.disabled = state.isBusy === true || state.fixedSectionBatch != null;
button.draggable = state.isBusy !== true && state.fixedSectionBatch == null;
});
acknowledgePendingTabHoverSwap(
state.tabs.map(function (tab) { return tab.id; }));
}
function renderFixedCatalog(state) {
@@ -1528,19 +1738,116 @@
(namedPlaylistMode === "save" ? named.canSave !== true : named.canLoad !== true);
}
function clearOperatorCatalogDragVisuals() {
operatorCatalogDraftRows.querySelectorAll(".dragging, .drag-before, .drag-after")
.forEach(function (row) {
row.classList.remove("dragging", "drag-before", "drag-after");
});
}
function onOperatorCatalogPointerDown(event) {
if (event.button !== 0) return;
const rowId = event.currentTarget.dataset.operatorCatalogRowId;
operatorCatalogDragBlockedRowId = event.target.closest("button, input")
? rowId
: null;
}
function onOperatorCatalogPointerEnd(event) {
if (operatorCatalogDragBlockedRowId ===
event.currentTarget.dataset.operatorCatalogRowId) {
operatorCatalogDragBlockedRowId = null;
}
}
function onOperatorCatalogDragStart(event) {
const row = event.currentTarget;
const sourceRowId = row.dataset.operatorCatalogRowId;
if (operatorCatalogMutationLocked || !sourceRowId || !event.dataTransfer ||
operatorCatalogDragBlockedRowId === sourceRowId ||
event.target.closest("button, input")) {
operatorCatalogDragBlockedRowId = null;
event.preventDefault();
return;
}
operatorCatalogDragBlockedRowId = null;
draggedOperatorCatalogRowId = sourceRowId;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(operatorCatalogDragMime, sourceRowId);
row.classList.add("dragging");
}
function onOperatorCatalogDragOver(event) {
const target = event.currentTarget;
if (operatorCatalogMutationLocked || !draggedOperatorCatalogRowId ||
target.dataset.operatorCatalogRowId === draggedOperatorCatalogRowId) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
const position = dragDropPosition(event, target);
target.classList.toggle("drag-before", position === "before");
target.classList.toggle("drag-after", position === "after");
}
function onOperatorCatalogDragLeave(event) {
event.currentTarget.classList.remove("drag-before", "drag-after");
}
function onOperatorCatalogDrop(event) {
const target = event.currentTarget;
const sourceRowId = draggedOperatorCatalogRowId;
const targetRowId = target.dataset.operatorCatalogRowId;
if (operatorCatalogMutationLocked || !sourceRowId || !targetRowId ||
sourceRowId === targetRowId || !event.dataTransfer ||
event.dataTransfer.getData(operatorCatalogDragMime) !== sourceRowId) {
draggedOperatorCatalogRowId = null;
clearOperatorCatalogDragVisuals();
return;
}
event.preventDefault();
const position = dragDropPosition(event, target);
draggedOperatorCatalogRowId = null;
clearOperatorCatalogDragVisuals();
send("reorder-operator-catalog-draft-row", {
sourceRowId: sourceRowId,
targetRowId: targetRowId,
position: position
});
}
function onOperatorCatalogDragEnd() {
draggedOperatorCatalogRowId = null;
operatorCatalogDragBlockedRowId = null;
clearOperatorCatalogDragVisuals();
}
function renderOperatorCatalog(state) {
const catalog = state.operatorCatalog;
const busy = state.isBusy === true;
operatorCatalogMutationLocked = isOperatorMutationLocked(state);
operatorCatalogState = catalog || null;
if (!catalog || catalog.isOpen !== true) {
draggedOperatorCatalogRowId = null;
operatorCatalogDragBlockedRowId = null;
clearOperatorCatalogDragVisuals();
clearDelayedClick(operatorCatalogStockClickTimer);
operatorCatalogStockClickTimer = null;
operatorCatalogModal.hidden = true;
operatorCatalogDraftKey = "";
operatorCatalogNameDraft = "";
operatorCatalogNameStatus.textContent = "";
operatorCatalogNameStatus.className = "";
return;
}
if (operatorCatalogMutationLocked) {
draggedOperatorCatalogRowId = null;
operatorCatalogDragBlockedRowId = null;
clearOperatorCatalogDragVisuals();
}
operatorCatalogModal.hidden = false;
const isTheme = catalog.entity === "theme";
const editing = catalog.mode === "create" || catalog.mode === "edit";
@@ -1599,6 +1906,27 @@
operatorCatalogNameLabel.textContent = isTheme ? "테마명" : "전문가명";
operatorCatalogName.placeholder = isTheme ? "ThemeA 이름" : "전문가 이름";
operatorCatalogName.disabled = busy || !editing;
const currentThemeName = operatorCatalogNameDraft.trim();
const checkedCurrentThemeName = isTheme && currentThemeName.length > 0 &&
catalog.checkedThemeName === currentThemeName;
const themeNameAvailability = checkedCurrentThemeName
? catalog.themeNameAvailability
: "notChecked";
operatorCatalogNameCheck.hidden = !isTheme;
operatorCatalogNameCheck.disabled = busy || !isTheme || !editing ||
catalog.canCheckThemeName !== true || currentThemeName.length === 0;
operatorCatalogNameStatus.hidden = !isTheme;
operatorCatalogNameStatus.className = themeNameAvailability === "available" ||
themeNameAvailability === "duplicate" || themeNameAvailability === "indeterminate"
? themeNameAvailability
: "";
operatorCatalogNameStatus.textContent = themeNameAvailability === "available"
? "사용 가능"
: themeNameAvailability === "duplicate"
? "이미 사용 중"
: themeNameAvailability === "indeterminate"
? "확인 불가"
: "";
if (document.activeElement !== operatorCatalogStockQuery) {
operatorCatalogStockQuery.value = catalog.stockQuery || "";
}
@@ -1632,6 +1960,19 @@
(catalog.draftRows || []).forEach(function (row, index) {
const line = document.createElement("div");
line.className = "operator-catalog-draft-row";
line.dataset.operatorCatalogRowId = row.rowId;
line.draggable = !operatorCatalogMutationLocked;
line.setAttribute(
"aria-disabled",
operatorCatalogMutationLocked ? "true" : "false");
line.addEventListener("pointerdown", onOperatorCatalogPointerDown);
line.addEventListener("pointerup", onOperatorCatalogPointerEnd);
line.addEventListener("pointercancel", onOperatorCatalogPointerEnd);
line.addEventListener("dragstart", onOperatorCatalogDragStart);
line.addEventListener("dragover", onOperatorCatalogDragOver);
line.addEventListener("dragleave", onOperatorCatalogDragLeave);
line.addEventListener("drop", onOperatorCatalogDrop);
line.addEventListener("dragend", onOperatorCatalogDragEnd);
const order = document.createElement("span");
order.textContent = String(row.position + 1);
const name = document.createElement("strong");
@@ -2012,6 +2353,25 @@
});
operatorCatalogName.addEventListener("input", function () {
operatorCatalogNameDraft = operatorCatalogName.value;
operatorCatalogNameStatus.textContent = "";
operatorCatalogNameStatus.className = "";
operatorCatalogNameCheck.disabled = uiBusy || !operatorCatalogState ||
operatorCatalogState.entity !== "theme" ||
(operatorCatalogState.mode !== "create" && operatorCatalogState.mode !== "edit") ||
operatorCatalogState.canCheckThemeName !== true ||
operatorCatalogName.value.trim().length === 0;
});
operatorCatalogNameCheck.addEventListener("click", function () {
const name = operatorCatalogName.value.trim();
if (!name) {
operatorCatalogName.setCustomValidity("이름을 입력하세요.");
operatorCatalogName.reportValidity();
return;
}
operatorCatalogName.setCustomValidity("");
operatorCatalogNameDraft = name;
send("check-operator-catalog-theme-name", { name: name });
});
function searchOperatorCatalogStocks() {
const query = operatorCatalogStockQuery.value.trim();
@@ -2119,12 +2479,28 @@
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 (!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;
event.preventDefault();
event.stopPropagation();
send("drop-selected-cuts", {});
});
playlistMoveUp.addEventListener("click", function () {
@@ -2155,20 +2531,43 @@
marketTabs.addEventListener("dragstart", function (event) {
const button = event.target.closest("button[data-tab-id]");
if (!button || !event.dataTransfer) return;
clearPendingTabHoverSwap();
draggedTabId = button.dataset.tabId;
tabDragLastTargetId = null;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/x-mbn-tab", button.dataset.tabId);
event.dataTransfer.setData("text/x-mbn-tab", draggedTabId);
button.classList.add("dragging");
});
marketTabs.addEventListener("dragover", function (event) {
if (event.target.closest("button[data-tab-id]")) event.preventDefault();
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("drop", function (event) {
const target = event.target.closest("button[data-tab-id]");
if (!target || !event.dataTransfer) return;
if (!target || !event.dataTransfer || !draggedTabId) return;
event.preventDefault();
const source = event.dataTransfer.getData("text/x-mbn-tab");
if (source && source !== target.dataset.tabId) {
send("swap-tabs", { source: source, target: target.dataset.tabId });
}
if (event.dataTransfer.getData("text/x-mbn-tab") !== draggedTabId) return;
draggedTabId = null;
tabDragLastTargetId = null;
marketTabs.querySelectorAll("button.dragging")
.forEach(function (button) { button.classList.remove("dragging"); });
});
marketTabs.addEventListener("dragend", function () {
draggedTabId = null;
tabDragLastTargetId = null;
marketTabs.querySelectorAll("button.dragging")
.forEach(function (button) { button.classList.remove("dragging"); });
});
function sendMovingAverages() {
send("set-moving-averages", {
@@ -2498,6 +2897,10 @@
send("search-trading-halts", { query: event.target.value });
}
});
window.addEventListener("pointerup", function () {
deferredPlaylistPlainSelectionRowId = null;
operatorCatalogDragBlockedRowId = null;
});
document.addEventListener("keydown", function (event) {
if (event.key === "Delete" && !event.repeat && dialog.hidden && manualModal.hidden &&
manualListModal.hidden && namedPlaylistModal.hidden && operatorCatalogModal.hidden) {

View File

@@ -196,10 +196,12 @@
<h3 id="operator-catalog-editor-title">추가·편집</h3>
<span id="operator-catalog-code" class="operator-catalog-code"></span>
</div>
<label class="operator-catalog-name-field">
<span id="operator-catalog-name-label">이름</span>
<div class="operator-catalog-name-field">
<label id="operator-catalog-name-label" for="operator-catalog-name">이름</label>
<input id="operator-catalog-name" maxlength="128" autocomplete="off">
</label>
<button id="operator-catalog-name-check" type="button">중복확인</button>
<small id="operator-catalog-name-status" aria-live="polite"></small>
</div>
<div class="operator-catalog-stock-search">
<input id="operator-catalog-stock-query" type="search" maxlength="64"
autocomplete="off" placeholder="종목명 검색">

View File

@@ -140,6 +140,8 @@ button:disabled { color: #555; opacity: 1; }
.market-tabs { display: flex; height: 29px; border-bottom: 1px solid #999; }
.market-tabs button { min-width: 48px; padding: 3px 8px; border-width: 0 1px 0 0; background: #efefef; }
.market-tabs button.active { background: #fff; border-top: 2px solid #e8781d; }
.market-tabs button[draggable="true"] { cursor: grab; }
.market-tabs button.dragging { opacity: .58; cursor: grabbing; }
.expand-line { height: 31px; padding: 7px 8px 0; }
.category-title { height: 27px; border-bottom: 1px solid #777; font-size: 12px; }
.category-tree { flex: 1; overflow: auto; padding: 7px 12px; background: #fff; border-left: 1px solid #aaa; border-right: 1px solid #aaa; }
@@ -237,7 +239,10 @@ button:disabled { color: #555; opacity: 1; }
.operator-catalog-results button.selected { background: #dcebdc; }
.operator-catalog-editor { grid-template-rows: auto auto auto minmax(92px, 20%) auto auto minmax(120px, 1fr) auto; gap: 7px; padding-bottom: 8px; }
.operator-catalog-editor[hidden] { display: none; }
.operator-catalog-name-field { display: grid; grid-template-columns: 76px minmax(0, 1fr); align-items: center; gap: 6px; padding: 0 9px; }
.operator-catalog-name-field { display: grid; grid-template-columns: 76px minmax(0, 1fr) auto; align-items: center; gap: 6px; padding: 0 9px; }
.operator-catalog-name-field small { grid-column: 2 / 4; min-height: 16px; color: #555; }
.operator-catalog-name-field small.available { color: #176b2d; font-weight: 600; }
.operator-catalog-name-field small.duplicate, .operator-catalog-name-field small.indeterminate { color: #a40000; font-weight: 600; }
.operator-catalog-stock-search { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px; padding: 0 9px; }
.operator-catalog-stock-results { margin: 0 9px; border: 1px solid #aaa; }
.operator-catalog-stock-results button { display: grid; grid-template-columns: minmax(0, 1fr) auto; width: 100%; padding: 4px 7px; border: 0; border-bottom: 1px solid #ddd; background: #fff; text-align: left; }
@@ -252,6 +257,10 @@ button:disabled { color: #555; opacity: 1; }
.operator-catalog-draft-row > span { text-align: center; }
.operator-catalog-draft-row > small { color: #555; text-align: right; }
.operator-catalog-draft-row > input { min-width: 90px; width: 100%; height: 27px; text-align: right; }
.operator-catalog-draft-row[draggable="true"] { cursor: grab; }
.operator-catalog-draft-row.dragging { opacity: .55; cursor: grabbing; }
.operator-catalog-draft-row.drag-before { box-shadow: inset 0 3px #176b2d; }
.operator-catalog-draft-row.drag-after { box-shadow: inset 0 -3px #176b2d; }
.operator-catalog-editor-actions { display: grid; grid-template-columns: auto 1fr auto auto; gap: 7px; padding: 2px 9px 0; }
.operator-catalog-editor-actions button:last-child { min-width: 92px; color: #fff; background: #176b2d; font-weight: 700; }
@@ -272,6 +281,10 @@ button:disabled { color: #555; opacity: 1; }
.playlist-data-row .enabled-cell { text-align: center; }
.playlist-data-row.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; }
.playlist-data-row.drag-before { box-shadow: inset 0 3px #176b2d; }
.playlist-data-row.drag-after { box-shadow: inset 0 -3px #176b2d; }
.playout-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: center; gap: 12px; }
.playout-actions button { height: 51px; background: #fff; font-size: 25px; font-weight: 700; }