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.