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 @@