From 4d661118a235c852bb42abfa19b00bf4a91c4170 Mon Sep 17 00:00:00 2001 From: ll-nick <68419636+ll-nick@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:31:26 +0200 Subject: [PATCH 1/5] Move window sync open into exported function --- src/ts/presenter/windowsync.ts | 38 ++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/src/ts/presenter/windowsync.ts b/src/ts/presenter/windowsync.ts index c5cc4e4d..047d38c1 100644 --- a/src/ts/presenter/windowsync.ts +++ b/src/ts/presenter/windowsync.ts @@ -94,22 +94,30 @@ function detachLink(): void { linkPoll = undefined; } +// Set once by initWindowSync; read by openSyncedWindow so the keyboard binding +// (keyboard.ts's "n") can call the same logic as the click listener without +// threading wsPort through a second parameter. +let _wsPort: number | null = null; + +export function openSyncedWindow(): void { + if (_wsPort === null && state.windowLink) return; // already linked (2-window cap) + // No target name: a named target gets reused/refocused by a later + // window.open() call with the same name — including from inside the window + // it names, which would just reload itself. Serve has no window-count cap + // (the WS relay already syncs however many windows connect), so repeat + // clicks must always open a genuinely new window rather than colliding with + // whichever window happens to hold that name. + const child = window.open(location.href); + if (!child) { + showNotify(POPUP_BLOCKED_MESSAGE, "yellow"); + return; + } + if (_wsPort === null) attachLink(child); +} + export function initWindowSync(wsPort: number | null): void { - btnPresenterView.addEventListener("click", () => { - if (wsPort === null && state.windowLink) return; // already linked (2-window cap) - // No target name: a named target gets reused/refocused by a later - // window.open() call with the same name — including from inside the window - // it names, which would just reload itself. Serve has no window-count cap - // (the WS relay already syncs however many windows connect), so repeat - // clicks must always open a genuinely new window rather than colliding with - // whichever window happens to hold that name. - const child = window.open(location.href); - if (!child) { - showNotify(POPUP_BLOCKED_MESSAGE, "yellow"); - return; - } - if (wsPort === null) attachLink(child); - }); + _wsPort = wsPort; + btnPresenterView.addEventListener("click", openSyncedWindow); if (wsPort !== null) return; // serve: WS already syncs any window opened this way From 22f70a310c7d87dd6f4c510f55744cc282390ed5 Mon Sep 17 00:00:00 2001 From: ll-nick <68419636+ll-nick@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:32:52 +0200 Subject: [PATCH 2/5] Add functions to allow manipulating edit drop down with keyboard --- src/ts/presenter/edit.test.ts | 60 ++++++++++++++++++++++++++++++++++- src/ts/presenter/edit.ts | 44 ++++++++++++++++++------- 2 files changed, 92 insertions(+), 12 deletions(-) diff --git a/src/ts/presenter/edit.test.ts b/src/ts/presenter/edit.test.ts index 37bc8e2f..c1f1a813 100644 --- a/src/ts/presenter/edit.test.ts +++ b/src/ts/presenter/edit.test.ts @@ -14,6 +14,8 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest"; let initEditMenu: typeof import("./edit").initEditMenu; let renderEditButton: typeof import("./edit").renderEditButton; +let editMenuSetActive: typeof import("./edit").editMenuSetActive; +let editMenuCommit: typeof import("./edit").editMenuCommit; let menuOpened: typeof import("./menus").menuOpened; let state: typeof import("./state").state; let btnEdit: HTMLButtonElement; @@ -46,7 +48,8 @@ beforeEach(async () => {
`; vi.resetModules(); - ({ initEditMenu, renderEditButton } = await import("./edit")); + ({ initEditMenu, renderEditButton, editMenuSetActive, editMenuCommit } = + await import("./edit")); ({ menuOpened } = await import("./menus")); ({ state } = await import("./state")); btnEdit = document.getElementById("btn-edit") as HTMLButtonElement; @@ -284,3 +287,58 @@ test("the general command also applies to SVG files when no SVG-specific overrid ); expect(navigator.clipboard.writeText).not.toHaveBeenCalled(); }); + +// ── Keyboard navigation (editMenuSetActive / editMenuCommit) ────────────────── +// The arrow/j/k/Enter wiring itself lives in keyboard.ts's global cascade (not +// tested here — no keyboard.test.ts exists in this codebase, matching how +// overview.ts's identically-shaped overviewSetActive/overviewCommit also have +// no dedicated cascade test); this covers the exported units keyboard.ts calls. + +test("opening the menu highlights the first row", () => { + state.slides = [ + slideWith( + { label: "Layout", name: "a.svg", path: "/deck/a.svg" }, + { label: "Content", name: "b.md", path: "/deck/b.md" }, + ), + ]; + initEditMenu({ svg: false, default: false }, 7778); + btnEdit.click(); + const rows = editMenu.querySelectorAll(".edit-row"); + expect(rows[0].classList.contains("active")).toBe(true); + expect(rows[1].classList.contains("active")).toBe(false); + expect(state._editActive).toBe(0); +}); + +test("editMenuSetActive moves the highlight and clamps at both ends", () => { + state.slides = [ + slideWith( + { label: "Layout", name: "a.svg", path: "/deck/a.svg" }, + { label: "Content", name: "b.md", path: "/deck/b.md" }, + ), + ]; + initEditMenu({ svg: false, default: false }, 7778); + btnEdit.click(); + const rows = editMenu.querySelectorAll(".edit-row"); + editMenuSetActive(1); + expect(rows[1].classList.contains("active")).toBe(true); + expect(rows[0].classList.contains("active")).toBe(false); + editMenuSetActive(5); // past the end + expect(state._editActive).toBe(1); + editMenuSetActive(-3); // before the start + expect(state._editActive).toBe(0); +}); + +test("editMenuCommit acts on the highlighted row, not necessarily the first", () => { + state.slides = [ + slideWith( + { label: "Layout", name: "a.svg", path: "/deck/a.svg" }, + { label: "Content", name: "b.md", path: "/deck/b.md" }, + ), + ]; + initEditMenu({ svg: false, default: false }, 7778); + btnEdit.click(); + editMenuSetActive(1); + editMenuCommit(); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("/deck/b.md"); + expect(editMenu.classList.contains("open")).toBe(false); +}); diff --git a/src/ts/presenter/edit.ts b/src/ts/presenter/edit.ts index 81c77009..5b488ae3 100644 --- a/src/ts/presenter/edit.ts +++ b/src/ts/presenter/edit.ts @@ -92,6 +92,36 @@ export function renderEditButton(): void { } } +// ── Keyboard navigation (mirrors overview.ts's active-index pattern) ─────────── +// Unlike syncmenu.ts's identically-shaped dropdown, this one can't just cycle +// through options on a single keypress — acting on a file has a real side effect +// (a clipboard write or a spawned editor command), so highlighting a row and +// committing it must be separate steps. Ownership of the open-state keys +// (arrows/j/k/Enter/Escape) therefore lives in keyboard.ts's global cascade, not +// a listener local to this module: a local listener registered only while the +// menu is open would run *after* keyboard.ts's (registered once at boot), too +// late to stop it from also treating j/k/arrows as slide navigation underneath +// the open menu. + +export function editMenuSetActive(i: number): void { + const rows = Array.from( + editMenu.querySelectorAll(".edit-row"), + ); + if (rows.length === 0) return; + state._editActive = Math.max(0, Math.min(rows.length - 1, i)); + rows.forEach((row, idx) => { + row.classList.toggle("active", idx === state._editActive); + }); + rows[state._editActive]?.scrollIntoView({ block: "nearest" }); +} + +export function editMenuCommit(): void { + const files = state.slides[state.slideIndex]?.editableFiles ?? []; + const file = files[state._editActive]; + if (file) actOn(file); + closeMenu(); +} + // ── Menu open/close ──────────────────────────────────────────────────────────── function onDocClick(e: MouseEvent): void { @@ -99,31 +129,23 @@ function onDocClick(e: MouseEvent): void { if (!btnEdit.contains(t) && !editMenu.contains(t)) closeMenu(); } -function onKeydown(e: KeyboardEvent): void { - if (e.key === "Escape") { - closeMenu(); - btnEdit.focus(); - } -} - function openMenu(): void { editMenu.classList.add("open"); btnEdit.setAttribute("aria-expanded", "true"); + editMenuSetActive(0); document.addEventListener("click", onDocClick); - document.addEventListener("keydown", onKeydown); menuOpened(closeMenu); } -function closeMenu(): void { +export function closeMenu(): void { if (!editMenu.classList.contains("open")) return; editMenu.classList.remove("open"); btnEdit.setAttribute("aria-expanded", "false"); document.removeEventListener("click", onDocClick); - document.removeEventListener("keydown", onKeydown); menuClosed(closeMenu); } -function toggleMenu(): void { +export function toggleMenu(): void { if (editMenu.classList.contains("open")) closeMenu(); else openMenu(); } From 443d11b0fe89d80a8c65a455645483fb1ebfff09 Mon Sep 17 00:00:00 2001 From: ll-nick <68419636+ll-nick@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:33:21 +0200 Subject: [PATCH 3/5] Wire up edit button in keyboard handling via "e" shortcut --- src/inkflow/presenter.html | 6 ++++-- src/ts/presenter/keyboard.ts | 32 ++++++++++++++++++++++++++++++++ src/ts/presenter/state.ts | 1 + 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/inkflow/presenter.html b/src/inkflow/presenter.html index b70999ee..3b6f0578 100644 --- a/src/inkflow/presenter.html +++ b/src/inkflow/presenter.html @@ -57,6 +57,7 @@

KEYBINDINGS

$ EndLast slide gSlide picker oSlide overview + eEdit source Present .Laser pointer Ctrl + scrollZoom · Ctrl + drag to pan @@ -69,6 +70,7 @@

KEYBINDINGS

pPresenter panel dDiagnostics mMessages + nNew window sCycle sync mode ?This help @@ -255,7 +257,7 @@

MESSAGES

- - - - - + | + + + + diff --git a/src/ts/presenter/websocket.ts b/src/ts/presenter/websocket.ts index 1c579c74..41b3874f 100644 --- a/src/ts/presenter/websocket.ts +++ b/src/ts/presenter/websocket.ts @@ -179,6 +179,7 @@ export function connectWS(wsPort: number | null, authoritative: boolean): void { state.ws.onopen = () => { wsDot.className = "connected"; + wsDot.dataset.tooltip = "Connected"; const assert = authoritative && sends(); firstPositionPending = assert; if (assert) sendNav(); @@ -226,6 +227,7 @@ export function connectWS(wsPort: number | null, authoritative: boolean): void { state.ws.onclose = () => { wsDot.className = ""; + wsDot.dataset.tooltip = "Disconnected"; state.ws = null; // A reconnecting live window re-asserts its position rather than being // adopted by a possibly-stale server.