Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
120 changes: 120 additions & 0 deletions packages/core/src/quickdraw.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
137 changes: 134 additions & 3 deletions packages/core/src/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const ICONS = {
chevronLeft: SVG('<path d="m15 18-6-6 6-6"/>'),
sun: SVG('<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>'),
moon: SVG('<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>'),
close: SVG('<path d="M18 6 6 18"/><path d="m6 6 12 12"/>'),
}
// the action bar wears the same glyphs the menu already uses
ICONS.duplicate = ICONS.copy
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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() }
Expand Down Expand Up @@ -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()

Expand All @@ -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()
},
}
Expand Down
44 changes: 44 additions & 0 deletions packages/core/test/editor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
2 changes: 1 addition & 1 deletion packages/core/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down