From 6030c48abb2e2b0fe3fb2ea393bb1c460aff73b4 Mon Sep 17 00:00:00 2001 From: Do Siki Date: Wed, 19 Aug 2026 22:51:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(cms):=20keyboard=20shortcuts=20=E2=80=94?= =?UTF-8?q?=20Ctrl+S=20save,=20Ctrl+P=20publish,=20=3F=20help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ctrl/Cmd+S saves (native browser save dialog suppressed) - Ctrl/Cmd+P publishes; Ctrl/Cmd+Shift+V opens the Versions panel - '?' toggles a shortcuts overlay (Esc/click closes) - plain typing in inputs never triggers actions (modifiers required; '?' only outside editing targets) - jsdom regression tests run the real client script with dispatched KeyboardEvents; the client is evaluated once per suite because each eval would stack another keydown listener on the shared document Closes MITHOME-75 --- docs/felhasznaloi-utmutato.md | 9 ++ .../__tests__/cms-editor-shortcuts.test.ts | 101 ++++++++++++++++++ scripts/cms-editor-client.js | 56 ++++++++++ scripts/test-content-editor-serializer.js | 2 + 4 files changed, 168 insertions(+) create mode 100644 proto/src/__tests__/cms-editor-shortcuts.test.ts diff --git a/docs/felhasznaloi-utmutato.md b/docs/felhasznaloi-utmutato.md index c35bbce..5d0d084 100644 --- a/docs/felhasznaloi-utmutato.md +++ b/docs/felhasznaloi-utmutato.md @@ -70,6 +70,15 @@ A dokumentum a repó része, és **folyamatosan karbantartott**: minden funkció - A régi logó mentésre kerül — a csere biztonságos és visszavonható (a mentések a `.content-backups` mappában). - A **CMS azonnal** az új logót mutatja; a **weboldalon a Publikálás (deploy) után** jelenik meg. +### ⌨️ Gyorsbillentyűk + +- **Ctrl/Cmd + S** — Mentés +- **Ctrl/Cmd + P** — Publikálás +- **Ctrl/Cmd + Shift + V** — Verziók panel megnyitása új fülön +- **?** — gyorsbillentyű-súgó megjelenítése (Esc vagy kattintás zárja) + +A gyorsbillentyűk csak a szerkesztő főoldalán működnek; beviteli mezőben gépelve a normál karakterként viselkednek. + ### 🕘 Verziók — korábbi állapotok - Az alsó sáv **🕘 Verziók** gombja megnyitja az éppen szerkesztett fájl mentéseit (minden Mentés automatikus másolatot készít). diff --git a/proto/src/__tests__/cms-editor-shortcuts.test.ts b/proto/src/__tests__/cms-editor-shortcuts.test.ts new file mode 100644 index 0000000..e036e94 --- /dev/null +++ b/proto/src/__tests__/cms-editor-shortcuts.test.ts @@ -0,0 +1,101 @@ +/** + * Regression tests for the Content Editor keyboard shortcuts (MITHOME-75). + * Runs the real scripts/cms-editor-client.js in jsdom, dispatches actual + * KeyboardEvents and asserts: + * 1. Ctrl+S calls save (fetch /save) and the browser save dialog is + * suppressed (preventDefault) + * 2. Ctrl+P calls publish + * 3. Ctrl+Shift+V opens the versions page in a new tab + * 4. '?' opens the shortcuts overlay, Esc closes it + * 5. plain 's' inside an input does NOT trigger save + */ +import fs from 'fs' +import path from 'path' + +const clientJs = fs.readFileSync(path.join(__dirname, '../../../scripts/cms-editor-client.js'), 'utf8') + +const makeData = () => ({ + hero: { title: 'T', subtitle: 'S', description: 'D' }, +}) + +function press(target: Document | Element, init: KeyboardEventInit) { + const ev = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }) + target.dispatchEvent(ev) + return ev +} + +const flush = () => new Promise(r => setTimeout(r, 0)) + +// Globals must exist BEFORE the eval — the client script boots immediately +// (render(DATA, …)). The eval runs ONCE: every eval would add another keydown +// listener to the shared jsdom document, and the toggle-style overlay handler +// would then open/close itself multiple times per keypress. +;(global as any).DATA = makeData() +;(global as any).FILE = 'home' +;(global as any).CSRF_TOKEN = 'boot' +;(global as any).CONTENT_HASH = 'x'.repeat(64) +;(global as any).fetch = jest.fn(async () => ({ status: 200, ok: true, json: async () => ({ ok: true }) })) +;(global as any).window = global +document.body.innerHTML = '
' +;(0, eval)(clientJs) + +describe('Content Editor keyboard shortcuts', () => { + let fetchCalls: Array<{ url: string; init?: RequestInit }> + + beforeEach(() => { + fetchCalls = [] + ;(global as any).DATA = makeData() + ;(global as any).FILE = 'home' + ;(global as any).CSRF_TOKEN = 'test-csrf' + ;(global as any).CONTENT_HASH = 'x'.repeat(64) + ;(global as any).fetch = jest.fn(async (url: string, init?: RequestInit) => { + fetchCalls.push({ url, init }) + return { status: 200, ok: true, json: async () => ({ ok: true, contentHash: 'y'.repeat(64) }) } + }) + ;(global as any).window = global + ;(window as any).open = jest.fn() + document.body.innerHTML = '
' + }) + + afterEach(() => { + delete (global as any).DATA + delete (global as any).FILE + delete (global as any).CSRF_TOKEN + delete (global as any).CONTENT_HASH + }) + + it('Ctrl+S saves via fetch and suppresses the browser save dialog', () => { + const ev = press(document, { key: 's', ctrlKey: true }) + expect(ev.defaultPrevented).toBe(true) + expect(fetchCalls.length).toBeGreaterThanOrEqual(1) + expect(fetchCalls[0].url).toContain('/save?file=home') + }) + + it('Cmd+P publishes', async () => { + const ev = press(document, { key: 'p', metaKey: true }) + expect(ev.defaultPrevented).toBe(true) + await flush() // publish awaits save() before its own fetch + expect(fetchCalls.some(c => c.url === '/publish')).toBe(true) + }) + + it('Ctrl+Shift+V opens the versions page in a new tab', () => { + const ev = press(document, { key: 'V', ctrlKey: true, shiftKey: true }) + expect(ev.defaultPrevented).toBe(true) + expect((window as any).open).toHaveBeenCalledWith('/versions?file=home', '_blank') + }) + + it("'?' opens the shortcuts overlay and Esc closes it", () => { + press(document, { key: '?' }) + expect(document.getElementById('shortcuts-overlay')).not.toBeNull() + press(document, { key: 'Escape' }) + expect(document.getElementById('shortcuts-overlay')).toBeNull() + }) + + it('plain typing in an input never triggers save', () => { + const input = document.createElement('input') + document.body.appendChild(input) + const ev = press(input, { key: 's' }) + expect(ev.defaultPrevented).toBe(false) + expect(fetchCalls.length).toBe(0) + }) +}) diff --git a/scripts/cms-editor-client.js b/scripts/cms-editor-client.js index dc6a50e..0717e63 100644 --- a/scripts/cms-editor-client.js +++ b/scripts/cms-editor-client.js @@ -329,3 +329,59 @@ render(DATA, document.getElementById('editor')); // Auto-dismiss toast const toast = document.querySelector('.toast'); if (toast) setTimeout(() => toast.remove(), 3500); + +// ── Keyboard shortcuts ─────────────────────────────────────────────────────── +// Ctrl/Cmd+S save · Ctrl/Cmd+P publish · Ctrl/Cmd+Shift+V versions · ? help +// Plain typing in inputs never triggers actions — the handler requires the +// modifier key (or, for '?', a non-editing target). + +function showShortcutsOverlay() { + const existing = document.getElementById('shortcuts-overlay'); + if (existing) { existing.remove(); return; } + const overlay = document.createElement('div'); + overlay.id = 'shortcuts-overlay'; + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(15,17,23,.75);z-index:300;display:flex;align-items:center;justify-content:center;padding:24px;'; + overlay.innerHTML = ` +
+

⌨️ Gyorsbillentyűk

+
Ctrl/Cmd + S — Mentés
+
Ctrl/Cmd + P — Publikálás
+
Ctrl/Cmd + Shift + V — Verziók
+
? — ez a súgó (Esc: bezárás)
+
`; + overlay.addEventListener('click', () => overlay.remove()); + document.body.appendChild(overlay); +} + +document.addEventListener('keydown', e => { + // Esc closes the shortcut overlay if open + if (e.key === 'Escape') { + const overlay = document.getElementById('shortcuts-overlay'); + if (overlay) { overlay.remove(); e.preventDefault(); } + return; + } + const mod = e.ctrlKey || e.metaKey; + if (mod && !e.shiftKey && !e.altKey && (e.key === 's' || e.key === 'S')) { + e.preventDefault(); + save(); + return; + } + if (mod && !e.shiftKey && !e.altKey && (e.key === 'p' || e.key === 'P')) { + e.preventDefault(); + publish(); + return; + } + if (mod && e.shiftKey && (e.key === 'v' || e.key === 'V')) { + e.preventDefault(); + window.open('/versions?file=' + encodeURIComponent(FILE), '_blank'); + return; + } + if (!mod && !e.ctrlKey && !e.metaKey && !e.altKey && e.key === '?') { + const target = e.target; + const isEditing = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable); + if (!isEditing) { + e.preventDefault(); + showShortcutsOverlay(); + } + } +}); diff --git a/scripts/test-content-editor-serializer.js b/scripts/test-content-editor-serializer.js index 102d364..73c89bc 100644 --- a/scripts/test-content-editor-serializer.js +++ b/scripts/test-content-editor-serializer.js @@ -45,6 +45,8 @@ const document = { getElementById: id => id === 'page-data' ? { textContent: JSON.stringify(fixture) } : {}, querySelectorAll: selector => selector === '[data-path]' ? fields : [], querySelector: () => null, + // Keyboard-shortcut binding in the client script — not under test here. + addEventListener: () => {}, }; const browserContext = { document, console, setTimeout, fetch: async () => ({ json: async () => ({ ok: true }) }) }; vm.createContext(browserContext);