feat(cms): keyboard shortcuts — Ctrl+S save, Ctrl+P publish, ? help
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions

- 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
This commit is contained in:
Do Siki
2026-08-19 22:52:09 +02:00
parent 8f72b91fb1
commit 6030c48abb
4 changed files with 168 additions and 0 deletions
+9
View File
@@ -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).
@@ -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 = '<div id="editor"></div>'
;(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 = '<div id="editor"></div><span id="saveStatus" style="display:none"></span><button id="publishBtn">pub</button>'
})
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)
})
})
+56
View File
@@ -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 = `
<div style="background:#1a2035;border:1px solid #2d3748;border-radius:14px;padding:28px 32px;max-width:420px;width:100%;font-size:14px;line-height:2;color:#e2e8f0;">
<h2 style="font-size:16px;color:#93c5fd;margin-bottom:12px;">⌨️ Gyorsbillentyűk</h2>
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">Ctrl/Cmd + S</kbd> — Mentés</div>
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">Ctrl/Cmd + P</kbd> — Publikálás</div>
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">Ctrl/Cmd + Shift + V</kbd> — Verziók</div>
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">?</kbd> — ez a súgó (Esc: bezárás)</div>
</div>`;
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();
}
}
});
@@ -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);