mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-26 22:55:18 -04:00
fix(ui): share one z-order stack across Notes and modals (#3798)
* fix(notes): bring pane above active windows * fix(notes): align tool window z-order handoff --------- Co-authored-by: Matyas Fenyves <16389204+uhhgoat@users.noreply.github.com>
This commit is contained in:
@@ -28,6 +28,7 @@
|
|||||||
import { previewZoneAt, clearPreview, snapModalToZone } from './tileManager.js';
|
import { previewZoneAt, clearPreview, snapModalToZone } from './tileManager.js';
|
||||||
import { suspendDock, resumeDock, clearRightDock, applyEdgeDock } from './modalSnap.js';
|
import { suspendDock, resumeDock, clearRightDock, applyEdgeDock } from './modalSnap.js';
|
||||||
import { dismissOrRemove } from './escMenuStack.js';
|
import { dismissOrRemove } from './escMenuStack.js';
|
||||||
|
import { nextToolWindowZ } from './toolWindowZOrder.js';
|
||||||
|
|
||||||
const _state = new Map(); // id -> { restoreFn, closeFn, railBtnId, isMinimized, restoreMinHeight }
|
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.
|
// those statics and bump on every bring-to-front.
|
||||||
let _modalTopZ = 300;
|
let _modalTopZ = 300;
|
||||||
function _bringToFront(modal) {
|
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) {
|
function _emitModalOpened(id, modal) {
|
||||||
|
|||||||
+26
-1
@@ -10,6 +10,7 @@ import { attachColorPicker } from './colorPicker.js';
|
|||||||
import { makeWindowDraggable } from './windowDrag.js';
|
import { makeWindowDraggable } from './windowDrag.js';
|
||||||
import { snapModalToZone } from './tileManager.js';
|
import { snapModalToZone } from './tileManager.js';
|
||||||
import { applyEdgeDock, clearDockSide } from './modalSnap.js';
|
import { applyEdgeDock, clearDockSide } from './modalSnap.js';
|
||||||
|
import { topToolWindowZ } from './toolWindowZOrder.js';
|
||||||
|
|
||||||
const API_BASE = window.location.origin;
|
const API_BASE = window.location.origin;
|
||||||
let _open = false;
|
let _open = false;
|
||||||
@@ -200,6 +201,23 @@ function _restoreNotesSidebarDock(pane) {
|
|||||||
applyEdgeDock(pane, 'right');
|
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() {
|
function _loadPendingHighlights() {
|
||||||
try { return new Set(JSON.parse(localStorage.getItem(REMINDER_PENDING_HIGHLIGHT_KEY) || '[]')); }
|
try { return new Set(JSON.parse(localStorage.getItem(REMINDER_PENDING_HIGHLIGHT_KEY) || '[]')); }
|
||||||
catch { return new Set(); }
|
catch { return new Set(); }
|
||||||
@@ -1096,7 +1114,10 @@ export async function refreshDueBadge(opts = {}) {
|
|||||||
// ---- Panel ----
|
// ---- Panel ----
|
||||||
|
|
||||||
export function openPanel() {
|
export function openPanel() {
|
||||||
if (_open) return;
|
if (_open) {
|
||||||
|
_bringNotesToFront();
|
||||||
|
return;
|
||||||
|
}
|
||||||
_open = true;
|
_open = true;
|
||||||
_editingId = null;
|
_editingId = null;
|
||||||
// Reset the search filter — the rebuilt pane's search input renders empty, so a
|
// 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);
|
document.body.appendChild(backdrop);
|
||||||
_wireNotesWindow(pane);
|
_wireNotesWindow(pane);
|
||||||
_restoreNotesSidebarDock(pane);
|
_restoreNotesSidebarDock(pane);
|
||||||
|
_bringNotesToFront(pane);
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
// (Close chevron removed — swipe down on mobile, tool-rail toggle on desktop.)
|
// (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-mobile-grabber'), pane);
|
||||||
_wireNotesSwipeDismiss(pane.querySelector('.notes-pane-header'), 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');
|
const minBtn = document.getElementById('notes-minimize-btn');
|
||||||
if (minBtn) minBtn.addEventListener('click', (e) => {
|
if (minBtn) minBtn.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+21
-6
@@ -8,6 +8,7 @@ import themeModule from './theme.js';
|
|||||||
import * as Modals from './modalManager.js';
|
import * as Modals from './modalManager.js';
|
||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
import { registerMenuDismiss, dismissTopMenu, dismissOrRemove } from './escMenuStack.js';
|
import { registerMenuDismiss, dismissTopMenu, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
import { nextToolWindowZ, topToolWindowZ } from './toolWindowZOrder.js';
|
||||||
|
|
||||||
let toastEl = null;
|
let toastEl = null;
|
||||||
let autoScrollEnabled = true;
|
let autoScrollEnabled = true;
|
||||||
@@ -1088,14 +1089,22 @@ if ('ontouchstart' in window) {
|
|||||||
|
|
||||||
// ---- Bring modal to front on click ----
|
// ---- 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) => {
|
document.addEventListener('mousedown', (e) => {
|
||||||
const modalContent = e.target.closest('.modal-content');
|
const modalContent = e.target.closest('.modal-content');
|
||||||
if (!modalContent) return;
|
if (!modalContent) return;
|
||||||
const modal = modalContent.closest('.modal');
|
const modal = modalContent.closest('.modal');
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
topModalZ += 1;
|
raiseModalToFront(modal);
|
||||||
modal.style.zIndex = topModalZ;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Backdrop tap to close — delegated for all modals
|
// 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
|
// Re-entry guard: setting style.zIndex itself fires the observer that
|
||||||
// calls us back. Skip if this element is already pinned to the top
|
// 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.
|
// (matches the current counter) so we don't spin into an infinite loop.
|
||||||
const cur = parseInt(m.style.zIndex, 10) || 0;
|
const cur = parseInt(getComputedStyle(m).zIndex, 10) || 0;
|
||||||
if (cur === _zCounter) return;
|
if (cur === _zCounter && cur > topToolWindowZ({ exclude: m })) return;
|
||||||
m.style.zIndex = String(++_zCounter);
|
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) => {
|
new MutationObserver((muts) => {
|
||||||
for (const m of muts) {
|
for (const m of muts) {
|
||||||
|
|||||||
@@ -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}
|
||||||
Reference in New Issue
Block a user