From b59434abaf51567f825c7459fc452d69c4a5ee54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AE=D1=80=D0=B8=D0=B9=20=D0=A7=D0=B5=D1=80=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE?= Date: Thu, 24 Sep 2026 15:37:13 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D0=BA=D0=B8=20Mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui_builder/static/app.js | 82 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 6 deletions(-) diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index 7b85fe6..b2835fd 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -134,6 +134,10 @@ shortcutInventory: { device_id: "", device_name: "", online: false, vmix_connected: false, inventory: { inputs: [] }, devices: [] }, shortcutInventoryLoading: false, shortcutEditorScrollTop: 0, + // BUILD162: an async vMix inventory response must never rewrite Mapping + // controls after the operator has started editing the settings modal. + shortcutEditorInteractionRevision: 0, + shortcutInventoryRefreshDeferred: false, preparedTitles: [], preparedTitleInventory: { device_id: "", device_name: "", inventory: { inputs: [] } }, preparedTitlesLoading: false, @@ -1331,6 +1335,15 @@ popup.remove(); anchor?.classList.remove("control-popover-open"); activeControlPopover = null; + + // BUILD162: if vMix inventory arrived while Mapping was being edited, + // apply it only after the open popover is gone. This keeps the current + // dropdown usable without leaving the inventory permanently stale. + const editor = document.querySelector(".shortcut-sequence-editor"); + if (editor?.isConnected && state.shortcutInventoryRefreshDeferred && !shortcutEditorHasActiveInteraction(editor)) { + state.shortcutInventoryRefreshDeferred = false; + refreshShortcutEditorInventoryControls(editor, { force: true }); + } } function positionControlPopover(anchor, popup) { @@ -15242,8 +15255,17 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { if (!target) return; target[field] = control.type === "checkbox" ? control.checked : control.value; if (field === "input") { + // BUILD162: changing the Mapping Input must not rebuild the whole + // shortcut card. A full rerender destroys the styled popover, + // closes the menu and makes the operator lose the current context. target.selected_name = ""; - rerender(); + const row = control.closest("[data-penalty-target-row]"); + const selectedName = row?.querySelector('[data-penalty-target-field="selected_name"]'); + if (selectedName) { + selectedName.innerHTML = vmixTextSelectedNameOptions(target.input, ""); + selectedName.value = ""; + refreshEnhancedControl(selectedName); + } } }); }); @@ -15442,8 +15464,26 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { return true; } - function refreshShortcutEditorInventoryControls(editor) { + function markShortcutEditorInteraction() { + state.shortcutEditorInteractionRevision += 1; + } + + function shortcutEditorHasActiveInteraction(editor) { + if (!editor?.isConnected) return false; + if (activeControlPopover?.anchor?.isConnected && editor.contains(activeControlPopover.anchor)) return true; + const active = document.activeElement; + if (!active || !editor.contains(active)) return false; + // A focused text field can safely coexist with a vMix inventory refresh; + // only a native select/popover interaction is sensitive to option rebuilds. + return Boolean(active.matches?.("select, .css-select-trigger")); + } + + function refreshShortcutEditorInventoryControls(editor, { force = false } = {}) { if (!editor?.isConnected) return; + if (!force && shortcutEditorHasActiveInteraction(editor)) { + state.shortcutInventoryRefreshDeferred = true; + return; + } const status = editor.querySelector("[data-vmix-inventory-status]"); if (status) status.textContent = shortcutInventoryLabel(); @@ -15542,6 +15582,24 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { const modalCard = editor?.closest(".modal-card"); const searchInput = editor.querySelector("[data-shortcut-search]"); + // BUILD162: remember that the operator has started interacting with Mapping. + // A late vMix inventory response may update state, but it must not rewrite + // the controls while the operator is choosing an Input/Text/Overlay. + editor.addEventListener("pointerdown", (event) => { + if (event.target?.closest?.("input, textarea, select, button, .css-select-trigger")) { + markShortcutEditorInteraction(); + } + }, true); + editor.addEventListener("focusin", (event) => { + if (event.target?.matches?.("input, textarea, select, button")) markShortcutEditorInteraction(); + }, true); + editor.addEventListener("input", (event) => { + if (event.target?.matches?.("input, textarea, select")) markShortcutEditorInteraction(); + }, true); + editor.addEventListener("change", (event) => { + if (event.target?.matches?.("input, textarea, select")) markShortcutEditorInteraction(); + }, true); + const render = ({ focusId = "" } = {}) => { const previousScroll = modalCard?.scrollTop ?? state.shortcutEditorScrollTop ?? 0; const list = editor.querySelector(".shortcut-sequence-list"); @@ -15604,16 +15662,28 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { }); editor.querySelector("[data-open-shortcut-reference]").addEventListener("click", showShortcutsReference); editor.querySelector("[data-refresh-vmix-inventory]").addEventListener("click", async () => { + // Explicit refresh is an operator action, so it is allowed to rebuild + // the inventory after the request. The active control itself is still + // protected by refreshShortcutInventorySelect(). await loadShortcutInventory(); - refreshShortcutEditorInventoryControls(editor); + state.shortcutInventoryRefreshDeferred = false; + refreshShortcutEditorInventoryControls(editor, { force: true }); }); editor.querySelector("[data-save-shortcuts]").addEventListener("click", () => saveConfig()); editor.querySelector("[data-close-shortcuts]").addEventListener("click", closeModal); render(); + const inventoryInteractionRevision = state.shortcutEditorInteractionRevision; loadShortcutInventory({ silent: true }).then(() => { - // BUILD160: do not replace the whole settings DOM after the async vMix - // inventory request completes. Replacing it can close an already-open - // dropdown and visually restore defaults while the operator is editing. + // BUILD160/162: inventory may arrive after the modal is already in use. + // Never rewrite Mapping controls if the operator has interacted since + // the request started. The current DOM/state remains authoritative for + // this editing session; the next opening gets a fresh inventory. + if (!editor.isConnected) return; + if (state.shortcutEditorInteractionRevision !== inventoryInteractionRevision || shortcutEditorHasActiveInteraction(editor)) { + state.shortcutInventoryRefreshDeferred = true; + return; + } + state.shortcutInventoryRefreshDeferred = false; refreshShortcutEditorInventoryControls(editor); }); }