diff --git a/packages/core/src/editor.js b/packages/core/src/editor.js index 6a29560..14a08e1 100644 --- a/packages/core/src/editor.js +++ b/packages/core/src/editor.js @@ -1291,6 +1291,7 @@ export class Editor { return } if (!meta) { + if (e.key === '?') { e.preventDefault(); this.emit('shortcuts'); return } if (k === ']') { this.bringToFront(); return } if (k === '[') { this.sendToBack(); return } const toolKeys = { diff --git a/packages/core/src/quickdraw.css b/packages/core/src/quickdraw.css index 6c4b93c..477e1a6 100644 --- a/packages/core/src/quickdraw.css +++ b/packages/core/src/quickdraw.css @@ -209,6 +209,126 @@ .qd-popover { animation: none; } } +/* ---- keyboard shortcuts overlay ---- */ +.qd-shortcuts { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 12px; + background: rgba(28, 27, 24, 0.32); + pointer-events: auto; + animation: qd-fade-in 160ms ease; +} +.qd-root[data-qd-theme='dark'] .qd-shortcuts { background: rgba(8, 6, 3, 0.44); } +@keyframes qd-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} +@media (prefers-reduced-motion: reduce) { + .qd-shortcuts { animation: none; } +} + +.qd-shortcuts-panel { + width: min(640px, 100%); + max-height: calc(100% - 24px); + overflow: auto; + box-sizing: border-box; + padding: 14px 16px 12px; + border-radius: 13px; + background: var(--qd-pop-bg); + border: 1px solid var(--qd-border); + box-shadow: var(--qd-pop-shadow); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + color: var(--qd-ink-strong); + font: 500 12.5px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + transition: background 320ms ease, border-color 320ms ease, box-shadow 320ms ease; + animation: qd-pop-in 160ms cubic-bezier(0.2, 0.9, 0.3, 1.15); +} +@media (prefers-reduced-motion: reduce) { + .qd-shortcuts-panel { animation: none; } +} + +.qd-shortcuts-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; +} +.qd-shortcuts-title { + margin: 0; + font-size: 14px; + line-height: 1.2; + color: var(--qd-ink-strong); +} +.qd-shortcuts-close { + width: 28px; + height: 28px; + flex: none; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 8px; + background: transparent; + color: var(--qd-ink-soft); + cursor: pointer; + padding: 0; + transition: background 140ms ease, color 140ms ease; +} +.qd-shortcuts-close:hover { background: var(--qd-hover); color: var(--qd-ink-strong); } +.qd-shortcuts-close svg { width: 16px; height: 16px; display: block; } + +.qd-shortcuts-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 2px 20px; +} +.qd-shortcuts-group { min-width: 0; } +.qd-shortcuts-group-title { + margin: 8px 0 3px; + font-size: 11px; + font-weight: 700; + color: var(--qd-ink-faint); +} +.qd-shortcuts-list { list-style: none; margin: 0; padding: 0; } +.qd-shortcuts-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + min-width: 0; + padding: 4px 0; +} +.qd-shortcuts-keys { + flex: 0 0 auto; + padding: 2px 6px; + border-radius: 6px; + background: var(--qd-seg-bg); + border: 1px solid var(--qd-border); + color: var(--qd-ink); + font: 600 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + white-space: nowrap; +} +.qd-shortcuts-action { + flex: 1 1 auto; + min-width: 0; + text-align: right; + color: var(--qd-ink-soft); +} +.qd-shortcuts-note { + margin: 10px 0 0; + font-size: 11px; + color: var(--qd-ink-faint); +} +/* narrow boards drop the cheat sheet to one column */ +@container (max-width: 540px) { + .qd-shortcuts-grid { grid-template-columns: 1fr; } +} + /* every popover tool icon needs the explicit size — an unsized svg falls back to its intrinsic 300×150 and overflows the 32px button (invisible on iOS) */ .qd-popover .qd-tool svg { width: 19px; height: 19px; display: block; } diff --git a/packages/core/src/ui.js b/packages/core/src/ui.js index 3878623..19b80ba 100644 --- a/packages/core/src/ui.js +++ b/packages/core/src/ui.js @@ -49,6 +49,7 @@ const ICONS = { chevronLeft: SVG(''), sun: SVG(''), moon: SVG(''), + close: SVG(''), } // the action bar wears the same glyphs the menu already uses ICONS.duplicate = ICONS.copy @@ -90,6 +91,60 @@ const TIPS = { tools: 'Tools', duplicate: 'Duplicate — ⌘D', delete: 'Delete — ⌫', } +// the same shortcuts the README publishes, surfaced in-app +const SHORTCUTS = [ + { + title: 'Tools', + rows: [ + ['V / 1', 'Select'], + ['H', 'Hand (or hold Space)'], + ['D / P / B', 'Draw'], + ['I', 'Highlight'], + ['E', 'Eraser'], + ['K', 'Laser'], + ['A', 'Arrow'], + ['L', 'Line'], + ['G', 'Shape'], + ['R / O', 'Rectangle / Ellipse'], + ['T', 'Text'], + ['N', 'Sticky note'], + ], + }, + { + title: 'Editing', + rows: [ + ['⌘Z / ⇧⌘Z', 'Undo / redo'], + ['⌘A', 'Select all'], + ['⌘C / ⌘X / ⌘V', 'Copy / cut / paste'], + ['⌘D', 'Duplicate'], + ['⌫', 'Delete selection'], + ['⇧⌘⌫', 'Clear board (undoable)'], + ['Enter', 'Edit selected text'], + ['Esc', 'Done / clear selection'], + ], + }, + { + title: 'Arranging', + rows: [ + [']', 'Bring to front'], + ['[', 'Send to back'], + ['Arrows', 'Nudge selection'], + ['Shift + Arrows', 'Nudge farther'], + ], + }, + { + title: 'Camera', + rows: [ + ['⇧1', 'Zoom to fit'], + ['⇧0', 'Reset zoom to 100%'], + ['⌘+ / ⌘−', 'Zoom in / out'], + ['Scroll', 'Pan (trackpad)'], + ['⌘ + Wheel', 'Zoom'], + ['Pinch', 'Zoom'], + ], + }, +] + // dock buttons in visual order (styles/more/menu ride at the end, always) const DOCK_NAMES = ['select', 'hand', 'draw', 'highlight', 'eraser', 'laser', 'line', 'arrow', 'geo', 'text', 'note', 'image'] // what gives way first as the frame narrows (select and draw never yield) @@ -110,6 +165,7 @@ export function buildUI(editor, { hidden = false, onSave, themeToggle = true, gr const openPopover = (name, build, anchor) => { if (popover?.name === name) return closePopover() closePopover() + closeShortcuts() const p = el('div', 'qd-popover') build(p) ui.appendChild(p) @@ -126,6 +182,63 @@ export function buildUI(editor, { hidden = false, onSave, themeToggle = true, gr refresh() } + // ---- keyboard shortcuts overlay ---------------------------------------- + // `?` is a board command, not a document key: the editor emits `shortcuts` + // and this overlay owns discoverability. It lives inside qd-ui so hosts + // that hide the chrome never see it. + let shortcutsEl = null + const closeShortcuts = () => { + if (!shortcutsEl) return + shortcutsEl.remove() + shortcutsEl = null + refresh() + } + const toggleShortcuts = () => { + if (shortcutsEl) return closeShortcuts() + if (ui.classList.contains('qd-hidden')) return + closePopover() + const p = el('div', 'qd-shortcuts') + p.setAttribute('role', 'dialog') + p.setAttribute('aria-modal', 'true') + p.setAttribute('aria-label', 'Keyboard shortcuts') + const panel = el('div', 'qd-shortcuts-panel') + const head = el('div', 'qd-shortcuts-head') + const title = el('h2', 'qd-shortcuts-title') + title.textContent = 'Keyboard shortcuts' + const closeBtn = el('button', 'qd-shortcuts-close') + closeBtn.innerHTML = ICONS.close + closeBtn.setAttribute('aria-label', 'Close keyboard shortcuts') + closeBtn.addEventListener('click', (e) => { e.stopPropagation(); closeShortcuts() }) + head.append(title, closeBtn) + const grid = el('div', 'qd-shortcuts-grid') + for (const group of SHORTCUTS) { + const g = el('section', 'qd-shortcuts-group') + const h = el('h3', 'qd-shortcuts-group-title') + h.textContent = group.title + g.appendChild(h) + const list = el('ul', 'qd-shortcuts-list') + for (const [keys, action] of group.rows) { + const li = el('li', 'qd-shortcuts-row') + const k = el('kbd', 'qd-shortcuts-keys') + k.textContent = keys + const a = el('span', 'qd-shortcuts-action') + a.textContent = action + li.append(k, a) + list.appendChild(li) + } + g.appendChild(list) + grid.appendChild(g) + } + const foot = el('p', 'qd-shortcuts-note') + foot.textContent = 'On Windows and Linux, ⌘ is Ctrl.' + panel.append(head, grid, foot) + p.appendChild(panel) + ui.appendChild(p) + shortcutsEl = p + closeBtn.focus() + refresh() + } + // ---- actions ------------------------------------------------------------- const run = (name, b) => { if (name === 'image') { closePopover(); return editor.pickImage() } @@ -494,13 +607,30 @@ export function buildUI(editor, { hidden = false, onSave, themeToggle = true, gr editor.on('selection', refresh), editor.on('theme', refresh), editor.on('grid', refresh), + editor.on('shortcuts', toggleShortcuts), ] - // popovers close when the pointer goes to the canvas - const closeOnCanvas = (e) => { if (!ui.contains(e.target)) closePopover() } + // popovers close when the pointer goes to the canvas; the cheat sheet also + // closes on its dimmed backdrop so clicking outside the panel dismisses it + const closeOnCanvas = (e) => { + if (shortcutsEl && (e.target === shortcutsEl || !shortcutsEl.contains(e.target))) closeShortcuts() + if (!ui.contains(e.target)) closePopover() + } root.addEventListener('pointerdown', closeOnCanvas, { capture: true }) + const onKeyDown = (e) => { + if (shortcutsEl && e.key === 'Escape') { + e.preventDefault() + closeShortcuts() + } + } + // document-level so Escape still dismisses when focus sits outside the + // board (host chrome, the theme toggle, etc.) + document.addEventListener('keydown', onKeyDown) - const setHidden = (h) => ui.classList.toggle('qd-hidden', !!h) + const setHidden = (h) => { + ui.classList.toggle('qd-hidden', !!h) + if (h) closeShortcuts() + } setHidden(hidden) refresh() @@ -516,6 +646,7 @@ export function buildUI(editor, { hidden = false, onSave, themeToggle = true, gr offs.forEach((f) => f()) ro.disconnect() root.removeEventListener('pointerdown', closeOnCanvas, { capture: true }) + document.removeEventListener('keydown', onKeyDown) ui.remove() }, } diff --git a/packages/core/test/editor.test.js b/packages/core/test/editor.test.js index a8b3872..1e992f7 100644 --- a/packages/core/test/editor.test.js +++ b/packages/core/test/editor.test.js @@ -651,4 +651,48 @@ describe('createQuickdraw UI', () => { board.destroy() c2.remove() }) + + it('? toggles the shortcut overlay; Esc, close, and ? close it', () => { + const c2 = document.createElement('div') + document.body.appendChild(c2) + const board = createQuickdraw({ container: c2 }) + const key = (k) => + c2.dispatchEvent(new window.KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })) + + expect(c2.querySelector('.qd-shortcuts')).toBeNull() + key('?') + const overlay = c2.querySelector('.qd-shortcuts') + expect(overlay).toBeTruthy() + expect(overlay.textContent).toContain('Undo / redo') + expect(overlay.textContent).toContain('Zoom to fit') + expect(overlay.getAttribute('role')).toBe('dialog') + + key('?') + expect(c2.querySelector('.qd-shortcuts')).toBeNull() + key('?') + expect(c2.querySelector('.qd-shortcuts')).toBeTruthy() + key('Escape') + expect(c2.querySelector('.qd-shortcuts')).toBeNull() + + key('?') + c2.querySelector('.qd-shortcuts-close').click() + expect(c2.querySelector('.qd-shortcuts')).toBeNull() + + key('?') + const overlay2 = c2.querySelector('.qd-shortcuts') + overlay2.dispatchEvent(new window.MouseEvent('pointerdown', { bubbles: true, cancelable: true })) + expect(c2.querySelector('.qd-shortcuts')).toBeNull() + board.destroy() + c2.remove() + }) + + it('hideUi hosts never see the shortcut overlay', () => { + const c2 = document.createElement('div') + document.body.appendChild(c2) + const board = createQuickdraw({ container: c2, hideUi: true }) + c2.dispatchEvent(new window.KeyboardEvent('keydown', { key: '?', bubbles: true, cancelable: true })) + expect(c2.querySelector('.qd-shortcuts')).toBeNull() + board.destroy() + c2.remove() + }) }) diff --git a/packages/core/types/index.d.ts b/packages/core/types/index.d.ts index 595f061..5684c7a 100644 --- a/packages/core/types/index.d.ts +++ b/packages/core/types/index.d.ts @@ -194,7 +194,7 @@ export interface EditorOptions { export type EditorEvent = | 'change' | 'history' | 'camera' | 'tool' | 'styles' | 'selection' - | 'theme' | 'grid' | 'edit' | 'scribbles' | 'penmode' + | 'theme' | 'grid' | 'edit' | 'scribbles' | 'penmode' | 'shortcuts' /** * The editor: camera, tools, selection, input and rendering over a Store.