diff --git a/static/js/modalManager.js b/static/js/modalManager.js index 59e0b7b76..6f51b537b 100644 --- a/static/js/modalManager.js +++ b/static/js/modalManager.js @@ -28,6 +28,7 @@ import { previewZoneAt, clearPreview, snapModalToZone } from './tileManager.js'; import { suspendDock, resumeDock, clearRightDock, applyEdgeDock } from './modalSnap.js'; import { dismissOrRemove } from './escMenuStack.js'; +import { nextToolWindowZ } from './toolWindowZOrder.js'; const _state = new Map(); // id -> { restoreFn, closeFn, railBtnId, isMinimized, restoreMinHeight } @@ -63,7 +64,14 @@ function _applyRememberedDock(id) { // those statics and bump on every bring-to-front. let _modalTopZ = 300; function _bringToFront(modal) { - if (modal) modal.style.setProperty('z-index', String(++_modalTopZ), 'important'); + if (!modal) return; + const z = nextToolWindowZ({ + exclude: modal, + current: getComputedStyle(modal).zIndex, + floor: _modalTopZ, + }); + _modalTopZ = Math.max(_modalTopZ, z); + modal.style.setProperty('z-index', String(z), 'important'); } function _emitModalOpened(id, modal) { diff --git a/static/js/notes.js b/static/js/notes.js index 58dff6e7f..2aad036fc 100644 --- a/static/js/notes.js +++ b/static/js/notes.js @@ -10,6 +10,7 @@ import { attachColorPicker } from './colorPicker.js'; import { makeWindowDraggable } from './windowDrag.js'; import { snapModalToZone } from './tileManager.js'; import { applyEdgeDock, clearDockSide } from './modalSnap.js'; +import { topToolWindowZ } from './toolWindowZOrder.js'; const API_BASE = window.location.origin; let _open = false; @@ -200,6 +201,23 @@ function _restoreNotesSidebarDock(pane) { applyEdgeDock(pane, 'right'); } +// Notes is not a `.modal`; its backdrop is the top-level stacking surface. +function _topToolWindowZ(exclude = null) { + return topToolWindowZ({ exclude }); +} + +function _bringNotesToFront(pane = document.getElementById('notes-pane')) { + if (!pane) return; + const backdrop = document.getElementById('notes-pane-backdrop') || pane.parentElement; + const z = _topToolWindowZ(backdrop) + 1; + if (backdrop) backdrop.style.setProperty('z-index', String(z), 'important'); + try { + window.dispatchEvent(new CustomEvent('odysseus:modal-opened', { + detail: { id: 'notes-panel', modal: pane }, + })); + } catch (_) {} +} + function _loadPendingHighlights() { try { return new Set(JSON.parse(localStorage.getItem(REMINDER_PENDING_HIGHLIGHT_KEY) || '[]')); } catch { return new Set(); } @@ -1096,7 +1114,10 @@ export async function refreshDueBadge(opts = {}) { // ---- Panel ---- export function openPanel() { - if (_open) return; + if (_open) { + _bringNotesToFront(); + return; + } _open = true; _editingId = null; // Reset the search filter — the rebuilt pane's search input renders empty, so a @@ -1192,6 +1213,7 @@ export function openPanel() { document.body.appendChild(backdrop); _wireNotesWindow(pane); _restoreNotesSidebarDock(pane); + _bringNotesToFront(pane); // Events // (Close chevron removed — swipe down on mobile, tool-rail toggle on desktop.) @@ -1202,6 +1224,9 @@ export function openPanel() { _wireNotesSwipeDismiss(pane.querySelector('.notes-mobile-grabber'), pane); _wireNotesSwipeDismiss(pane.querySelector('.notes-pane-header'), pane); + pane.addEventListener('pointerdown', () => _bringNotesToFront(pane), true); + pane.addEventListener('focusin', () => _bringNotesToFront(pane), true); + const minBtn = document.getElementById('notes-minimize-btn'); if (minBtn) minBtn.addEventListener('click', (e) => { e.preventDefault(); diff --git a/static/js/toolWindowZOrder.js b/static/js/toolWindowZOrder.js new file mode 100644 index 000000000..fa8241044 --- /dev/null +++ b/static/js/toolWindowZOrder.js @@ -0,0 +1,29 @@ +export const TOOL_WINDOW_SELECTOR = 'body > .modal, body > .research-overlay, body > .notes-pane-backdrop'; + +export function topToolWindowZ(options = {}) { + const { + exclude = null, + root = globalThis.document, + getStyle = globalThis.getComputedStyle, + floor = 250, + } = options; + let top = floor; + if (!root || typeof root.querySelectorAll !== 'function' || typeof getStyle !== 'function') return top; + root.querySelectorAll(TOOL_WINDOW_SELECTOR).forEach(el => { + if (!el || el === exclude) return; + if (el.classList?.contains('hidden') || el.classList?.contains('modal-minimized')) return; + const cs = getStyle(el); + if (cs.display === 'none' || cs.visibility === 'hidden') return; + const z = parseInt(cs.zIndex, 10); + if (Number.isFinite(z)) top = Math.max(top, z); + }); + return top; +} + +export function nextToolWindowZ(options = {}) { + const { current = null } = options; + const top = topToolWindowZ(options); + const currentZ = parseInt(current, 10); + if (Number.isFinite(currentZ) && currentZ > top) return currentZ; + return top + 1; +} diff --git a/static/js/ui.js b/static/js/ui.js index aa82cc616..9c7e5a9c0 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -8,6 +8,7 @@ import themeModule from './theme.js'; import * as Modals from './modalManager.js'; import spinnerModule from './spinner.js'; import { registerMenuDismiss, dismissTopMenu, dismissOrRemove } from './escMenuStack.js'; +import { nextToolWindowZ, topToolWindowZ } from './toolWindowZOrder.js'; let toastEl = null; let autoScrollEnabled = true; @@ -1088,14 +1089,22 @@ if ('ontouchstart' in window) { // ---- Bring modal to front on click ---- { - let topModalZ = 250; + const raiseModalToFront = (modal, floor = 250) => { + const z = nextToolWindowZ({ + exclude: modal, + current: getComputedStyle(modal).zIndex, + floor, + }); + modal.style.setProperty('z-index', String(z), 'important'); + return z; + }; + document.addEventListener('mousedown', (e) => { const modalContent = e.target.closest('.modal-content'); if (!modalContent) return; const modal = modalContent.closest('.modal'); if (!modal) return; - topModalZ += 1; - modal.style.zIndex = topModalZ; + raiseModalToFront(modal); }); // Backdrop tap to close — delegated for all modals @@ -1190,9 +1199,15 @@ if (!window._odyEscExpandGuard) { // Re-entry guard: setting style.zIndex itself fires the observer that // calls us back. Skip if this element is already pinned to the top // (matches the current counter) so we don't spin into an infinite loop. - const cur = parseInt(m.style.zIndex, 10) || 0; - if (cur === _zCounter) return; - m.style.zIndex = String(++_zCounter); + const cur = parseInt(getComputedStyle(m).zIndex, 10) || 0; + if (cur === _zCounter && cur > topToolWindowZ({ exclude: m })) return; + const z = nextToolWindowZ({ + exclude: m, + current: cur, + floor: _zCounter, + }); + _zCounter = Math.max(_zCounter, z); + if (z !== cur) m.style.setProperty('z-index', String(z), 'important'); }; new MutationObserver((muts) => { for (const m of muts) { diff --git a/tests/test_notes_z_order_js.py b/tests/test_notes_z_order_js.py new file mode 100644 index 000000000..7d534c33c --- /dev/null +++ b/tests/test_notes_z_order_js.py @@ -0,0 +1,139 @@ +"""Node-driven regression coverage for Notes pane z-order selection. + +Notes uses a body-level backdrop instead of the shared `.modal` element, so the +shared tool-window stack helper must account for both Notes and normal modals +without importing the full browser-heavy modules. +""" + +import json +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +HELPER = ROOT / "static" / "js" / "toolWindowZOrder.js" +pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node binary not on PATH") + + +def _node_eval(source: str): + proc = subprocess.run( + ["node", "--input-type=module"], + input=source, + cwd=ROOT, + capture_output=True, + text=True, + timeout=30, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip()) + + +def test_notes_z_order_uses_floor_when_no_tool_windows_are_open(): + values = _node_eval( + textwrap.dedent( + f""" + import {{ topToolWindowZ }} from '{HELPER.as_uri()}'; + const root = {{ querySelectorAll() {{ return []; }} }}; + console.log(JSON.stringify({{ z: topToolWindowZ({{ root, getStyle: () => ({{}}) }}) }})); + """ + ) + ) + + assert values == {"z": 250} + + +def test_notes_z_order_lands_above_highest_visible_tool_window(): + values = _node_eval( + textwrap.dedent( + f""" + import {{ topToolWindowZ }} from '{HELPER.as_uri()}'; + const cls = (...names) => ({{ contains: (name) => names.includes(name) }}); + const elements = [ + {{ id: 'memory', classList: cls(), style: {{ zIndex: '320' }} }}, + {{ id: 'research', classList: cls(), style: {{ zIndex: '415' }} }}, + {{ id: 'invalid', classList: cls(), style: {{ zIndex: 'auto' }} }}, + ]; + const root = {{ querySelectorAll() {{ return elements; }} }}; + const top = topToolWindowZ({{ root, getStyle: (el) => el.style }}); + console.log(JSON.stringify({{ top, notes: top + 1 }})); + """ + ) + ) + + assert values == {"top": 415, "notes": 416} + + +def test_modal_z_order_handoff_lands_above_notes_tie_on_first_click(): + values = _node_eval( + textwrap.dedent( + f""" + import {{ nextToolWindowZ }} from '{HELPER.as_uri()}'; + const cls = (...names) => ({{ contains: (name) => names.includes(name) }}); + const modal = {{ id: 'modal', classList: cls(), style: {{ zIndex: '416' }} }}; + const notes = {{ id: 'notes', classList: cls(), style: {{ zIndex: '416' }} }}; + const elements = [modal, notes]; + const root = {{ querySelectorAll() {{ return elements; }} }}; + const z = nextToolWindowZ({{ + exclude: modal, + current: modal.style.zIndex, + root, + getStyle: (el) => el.style, + }}); + console.log(JSON.stringify({{ z }})); + """ + ) + ) + + assert values == {"z": 417} + + +def test_modal_z_order_keeps_current_z_when_already_above_stack(): + values = _node_eval( + textwrap.dedent( + f""" + import {{ nextToolWindowZ }} from '{HELPER.as_uri()}'; + const cls = (...names) => ({{ contains: (name) => names.includes(name) }}); + const modal = {{ id: 'modal', classList: cls(), style: {{ zIndex: '420' }} }}; + const notes = {{ id: 'notes', classList: cls(), style: {{ zIndex: '416' }} }}; + const root = {{ querySelectorAll() {{ return [modal, notes]; }} }}; + const z = nextToolWindowZ({{ + exclude: modal, + current: modal.style.zIndex, + root, + getStyle: (el) => el.style, + }}); + console.log(JSON.stringify({{ z }})); + """ + ) + ) + + assert values == {"z": 420} + + +def test_notes_z_order_ignores_hidden_minimized_and_excluded_windows(): + values = _node_eval( + textwrap.dedent( + f""" + import {{ topToolWindowZ }} from '{HELPER.as_uri()}'; + const cls = (...names) => ({{ contains: (name) => names.includes(name) }}); + const excluded = {{ id: 'notes', classList: cls(), style: {{ zIndex: '900' }} }}; + const elements = [ + excluded, + {{ id: 'hidden-class', classList: cls('hidden'), style: {{ zIndex: '800' }} }}, + {{ id: 'minimized', classList: cls('modal-minimized'), style: {{ zIndex: '700' }} }}, + {{ id: 'display-none', classList: cls(), style: {{ zIndex: '600', display: 'none' }} }}, + {{ id: 'visibility-hidden', classList: cls(), style: {{ zIndex: '500', visibility: 'hidden' }} }}, + {{ id: 'visible', classList: cls(), style: {{ zIndex: '310' }} }}, + ]; + const root = {{ querySelectorAll() {{ return elements; }} }}; + const top = topToolWindowZ({{ exclude: excluded, root, getStyle: (el) => el.style }}); + console.log(JSON.stringify({{ top }})); + """ + ) + ) + + assert values == {"top": 310}