From 30e497867da847d49cf99ec9d34868d998cec585 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Tue, 27 Oct 2026 05:48:40 +0000 Subject: [PATCH] Guard keyboard shortcuts in dialogs/inputs; sync notebook emoji from meta - Extract shouldSkipShortcut(): skip t/arrow shortcuts while the shelf popover, delete dialog, or settings overlay is open; skip INPUT, TEXTAREA, SELECT and contenteditable targets (attribute check too); leave arrow keys on buttons for focus movement (#17). - Extract applyNotebookMeta(): onMetaChange now syncs nb.emoji (and handles null) so the in-memory list cannot desync from the manifest after renames/re-fetches (#16). Co-authored-by: setzor --- src/main.ts | 25 +++++++++++--------- src/notebookmeta.test.ts | 37 +++++++++++++++++++++++++++++ src/notebookmeta.ts | 14 +++++++++++ src/shortcuts.test.ts | 50 ++++++++++++++++++++++++++++++++++++++++ src/shortcuts.ts | 26 +++++++++++++++++++++ 5 files changed, 141 insertions(+), 11 deletions(-) create mode 100644 src/notebookmeta.test.ts create mode 100644 src/notebookmeta.ts create mode 100644 src/shortcuts.test.ts create mode 100644 src/shortcuts.ts diff --git a/src/main.ts b/src/main.ts index 2c8fe2c..d84f1c4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -43,6 +43,8 @@ import { } from "./shelf"; import { createToolbar, setToolbarNotebook } from "./toolbar"; import { attachCoverTitleEditing } from "./covertitle"; +import { shouldSkipShortcut } from "./shortcuts"; +import { applyNotebookMeta } from "./notebookmeta"; import { installAppSettings, openAppSettings, getAppSettings } from "./appsettings"; import type { AppSettings } from "./settings"; import { applyDefaultFontStack, fontStack } from "./fonts"; @@ -226,12 +228,7 @@ function renderShelfPopover(): void { shelfPopEl.hidden = true; }, onMetaChange: (meta) => { - const nb = notebooks.find((n) => n.id === meta.id); - if (nb) { - nb.name = meta.name; - nb.cover = meta.cover; - } - renderCover(); + if (applyNotebookMeta(notebooks, meta)) renderCover(); }, }); } @@ -534,11 +531,17 @@ nextDayEl.addEventListener("click", () => todayBtnEl.addEventListener("click", () => void openDate(todayStr)); window.addEventListener("keydown", (event) => { - if (event.target instanceof HTMLElement) { - const tag = event.target.tagName; - if (tag === "INPUT" || tag === "TEXTAREA" || event.target.isContentEditable) { - return; - } + if ( + shouldSkipShortcut(event, { + isPopoverOpen: () => !shelfPopEl.hidden, + isDialogOpen: () => { + if (!confirmOverlayEl.hidden) return true; + const settingsOverlay = document.getElementById("app-settings-overlay"); + return settingsOverlay !== null && !settingsOverlay.hidden; + }, + }) + ) { + return; } if (event.key === "ArrowLeft") void openDate(shiftDate(currentDate, -1)); else if (event.key === "ArrowRight") void openDate(shiftDate(currentDate, 1)); diff --git a/src/notebookmeta.test.ts b/src/notebookmeta.test.ts new file mode 100644 index 0000000..96af418 --- /dev/null +++ b/src/notebookmeta.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { applyNotebookMeta } from "./notebookmeta"; +import type { NotebookMeta } from "./api"; + +describe("applyNotebookMeta", () => { + const notebooks: NotebookMeta[] = [ + { id: "a", name: "Alpha", cover: "kraft", emoji: "🌷" }, + { id: "b", name: "Beta", cover: "navy" }, + ]; + + it("syncs name, cover and emoji from fresh meta", () => { + const nb = applyNotebookMeta(notebooks, { + id: "a", + name: "Renamed", + cover: "navy", + emoji: "🌙", + }); + expect(nb).toMatchObject({ id: "a", name: "Renamed", cover: "navy", emoji: "🌙" }); + expect(notebooks[0].emoji).toBe("🌙"); + }); + + it("clears the emoji when meta reports null", () => { + applyNotebookMeta(notebooks, { id: "a", name: "Renamed", cover: "navy", emoji: null }); + expect(notebooks[0].emoji).toBeNull(); + }); + + it("keeps the existing emoji when meta omits it", () => { + applyNotebookMeta(notebooks, { id: "b", name: "Beta", cover: "kraft", emoji: "🔥" }); + applyNotebookMeta(notebooks, { id: "b", name: "Beta 2", cover: "kraft" }); + expect(notebooks[1].name).toBe("Beta 2"); + expect(notebooks[1].emoji).toBe("🔥"); + }); + + it("returns null for an unknown id", () => { + expect(applyNotebookMeta(notebooks, { id: "zz", name: "x", cover: "kraft" })).toBeNull(); + }); +}); diff --git a/src/notebookmeta.ts b/src/notebookmeta.ts new file mode 100644 index 0000000..a6ff57a --- /dev/null +++ b/src/notebookmeta.ts @@ -0,0 +1,14 @@ +import type { NotebookMeta } from "./api"; + +/** Sync a notebook entry in the in-memory list with fresh backend metadata. */ +export function applyNotebookMeta( + notebooks: NotebookMeta[], + meta: NotebookMeta, +): NotebookMeta | null { + const nb = notebooks.find((n) => n.id === meta.id); + if (!nb) return null; + nb.name = meta.name; + nb.cover = meta.cover; + if (meta.emoji !== undefined) nb.emoji = meta.emoji; + return nb; +} diff --git a/src/shortcuts.test.ts b/src/shortcuts.test.ts new file mode 100644 index 0000000..7cb4cec --- /dev/null +++ b/src/shortcuts.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from "vitest"; +import { shouldSkipShortcut } from "./shortcuts"; + +const closed = { isPopoverOpen: () => false, isDialogOpen: () => false }; + +function keyEvent(key: string, target?: HTMLElement): KeyboardEvent { + const event = new KeyboardEvent("keydown", { key, bubbles: true }); + if (target) { + Object.defineProperty(event, "target", { value: target }); + } + return event; +} + +describe("shouldSkipShortcut", () => { + it("allows plain shortcuts when nothing is open and body has focus", () => { + expect(shouldSkipShortcut(keyEvent("t"), closed)).toBe(false); + expect(shouldSkipShortcut(keyEvent("ArrowLeft"), closed)).toBe(false); + }); + + it("skips when the shelf popover or a dialog is open", () => { + const event = keyEvent("t"); + expect( + shouldSkipShortcut(event, { ...closed, isPopoverOpen: () => true }), + ).toBe(true); + expect( + shouldSkipShortcut(event, { ...closed, isDialogOpen: () => true }), + ).toBe(true); + }); + + it("skips text entry contexts: input, textarea, select, contenteditable", () => { + const input = document.createElement("input"); + const textarea = document.createElement("textarea"); + const select = document.createElement("select"); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + document.body.appendChild(editable); + for (const el of [input, textarea, select, editable]) { + expect(shouldSkipShortcut(keyEvent("t", el), closed)).toBe(true); + expect(shouldSkipShortcut(keyEvent("ArrowLeft", el), closed)).toBe(true); + } + }); + + it("keeps arrow keys on buttons for focus movement but allows 't'", () => { + const button = document.createElement("button"); + expect(shouldSkipShortcut(keyEvent("ArrowLeft", button), closed)).toBe(true); + expect(shouldSkipShortcut(keyEvent("ArrowRight", button), closed)).toBe(true); + expect(shouldSkipShortcut(keyEvent("t", button), closed)).toBe(false); + }); +}); diff --git a/src/shortcuts.ts b/src/shortcuts.ts new file mode 100644 index 0000000..7bdddde --- /dev/null +++ b/src/shortcuts.ts @@ -0,0 +1,26 @@ +export interface ShortcutGuardOptions { + isPopoverOpen: () => boolean; + isDialogOpen: () => boolean; +} + +export function shouldSkipShortcut( + event: KeyboardEvent, + options: ShortcutGuardOptions, +): boolean { + if (options.isPopoverOpen() || options.isDialogOpen()) return true; + const target = event.target; + if (target instanceof HTMLElement) { + const tag = target.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; + if (target.isContentEditable || target.getAttribute("contenteditable") === "true") { + return true; + } + if ( + (event.key === "ArrowLeft" || event.key === "ArrowRight") && + tag === "BUTTON" + ) { + return true; + } + } + return false; +}