diff --git a/craft/src/editor/CanvasOverlays.tsx b/craft/src/editor/CanvasOverlays.tsx index f7e1a50..265090c 100644 --- a/craft/src/editor/CanvasOverlays.tsx +++ b/craft/src/editor/CanvasOverlays.tsx @@ -3,6 +3,7 @@ import { NoticeHost } from './NoticeHost'; import { EmptyContainerAdd } from './EmptyContainerAdd'; import { DropIndicator } from './DropIndicator'; import { RichTextToolbar } from './RichTextToolbar'; +import { CommandPalette } from '../panels/command-palette/CommandPalette'; /** * Single mount point for editor-wide overlays rendered next to the shell. @@ -15,5 +16,6 @@ export const CanvasOverlays: React.FC = () => ( + ); diff --git a/craft/src/panels/command-palette/CommandPalette.test.tsx b/craft/src/panels/command-palette/CommandPalette.test.tsx new file mode 100644 index 0000000..f333707 --- /dev/null +++ b/craft/src/panels/command-palette/CommandPalette.test.tsx @@ -0,0 +1,142 @@ +import { describe, test, expect, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness'; +import { PageProvider, usePages } from '../../state/PageContext'; +import { useCommandShortcuts, useCommandContext, __resetCommandsForTests, registerCommand, type CommandContext } from '../../editor/commands'; +import { Heading } from '../../components/basic/Heading'; +import { CommandPalette } from './CommandPalette'; +import { getRecentIds } from './palette-sources'; + +let h: EditorHarness; +let pagesRef: { current: ReturnType | null }; +let ctxRef: { current: CommandContext | null }; + +const Probe: React.FC = () => { + useCommandShortcuts(); + pagesRef.current = usePages(); + ctxRef.current = useCommandContext(); + return null; +}; + +function key(init: KeyboardEventInit, target: EventTarget = document.body) { + const e = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }); + h.act(() => { + target.dispatchEvent(e); + }); + return e; +} +const input = () => document.querySelector('[role="combobox"]'); +function type(value: string) { + const el = input()!; + h.act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!; + setter.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); + }); +} +const options = () => Array.from(document.querySelectorAll('[role="option"]')).map((o) => o.textContent || ''); + +beforeEach(() => { + __resetCommandsForTests(); + window.localStorage.clear(); + pagesRef = { current: null }; + ctxRef = { current: null }; + h = renderEditorHarness(); + h.mountChild( + + + + , + ); +}); +afterEach(() => { + h.unmount(); + __resetCommandsForTests(); + document.body.innerHTML = ''; +}); + +describe('CommandPalette', () => { + test('mod+k opens with a focused combobox; Escape closes without reaching the document', () => { + expect(input()).toBeNull(); + key({ key: 'k', ctrlKey: true }); + expect(input()).not.toBeNull(); + expect(document.activeElement).toBe(input()); + let reached = false; + const spy = () => { reached = true; }; + document.addEventListener('keydown', spy); + key({ key: 'Escape' }, input()!); + document.removeEventListener('keydown', spy); + expect(input()).toBeNull(); + expect(reached).toBe(false); + }); + + test('typing "pric" lists Pricing first; Enter inserts a PricingTable after the selection and selects it', () => { + const rootChildren = () => JSON.parse(h.getSerialized()).ROOT.nodes as string[]; + h.act(() => { + h.actions.addNodeTree(h.query.parseReactElement().toNodeTree(), 'ROOT'); + h.actions.addNodeTree(h.query.parseReactElement().toNodeTree(), 'ROOT'); + }); + const before = rootChildren(); + h.act(() => h.actions.selectNode(before[0])); + key({ key: 'k', ctrlKey: true }); + type('pric'); + expect(options()[0]).toContain('Pricing'); + key({ key: 'Enter' }, input()!); + const after = rootChildren(); + expect(after).toHaveLength(before.length + 1); + const newId = after[1]; + expect(JSON.parse(h.getSerialized())[newId].type.resolvedName).toBe('PricingTable'); + expect(h.query.getEvent('selected').all()).toEqual([newId]); + expect(input()).toBeNull(); + expect(getRecentIds()).toEqual(['block:pricing-table']); + }); + + test('"contact" offers Go to page: Contact; Enter switches page', () => { + h.act(() => pagesRef.current!.addPage('Contact', 'contact')); + h.act(() => pagesRef.current!.switchPage(pagesRef.current!.pages[0].id)); + key({ key: 'k', ctrlKey: true }); + type('contact'); + expect(options().some((t) => t.includes('Go to page: Contact'))).toBe(true); + expect(options()[0]).toContain('Go to page: Contact'); + key({ key: 'Enter' }, input()!); + const contact = pagesRef.current!.pages.find((p) => p.slug === 'contact')!; + expect(pagesRef.current!.activePageId).toBe(contact.id); + }); + + test('"publ" lists site.publish only while its when() passes', () => { + let allowed = false; + registerCommand({ id: 'site.publish', title: 'Publish', group: 'site', when: () => allowed, run: () => {} }); + key({ key: 'k', ctrlKey: true }); + type('publ'); + expect(options().some((t) => t.includes('Publish'))).toBe(false); + key({ key: 'Escape' }, input()!); + allowed = true; + key({ key: 'k', ctrlKey: true }); + type('publ'); + expect(options().some((t) => t.includes('Publish'))).toBe(true); + }); + + test('arrow keys move aria-activedescendant; click runs and closes; recents show when reopened', () => { + let ran = 0; + registerCommand({ id: 'x.one', title: 'Alpha one', group: 'edit', run: () => { ran++; } }); + registerCommand({ id: 'x.two', title: 'Alpha two', group: 'edit', run: () => { ran += 10; } }); + key({ key: 'k', ctrlKey: true }); + type('alpha'); + expect(input()!.getAttribute('aria-activedescendant')).toBe('wsb-palette-opt-0'); + key({ key: 'ArrowDown' }, input()!); + expect(input()!.getAttribute('aria-activedescendant')).toBe('wsb-palette-opt-1'); + h.act(() => document.getElementById('wsb-palette-opt-1')!.click()); + expect(ran).toBe(10); + expect(input()).toBeNull(); + key({ key: 'k', ctrlKey: true }); + expect(options()[0]).toContain('Alpha two'); + }); + + test('digit shortcuts do not fire while the palette input is focused', () => { + key({ key: 'k', ctrlKey: true }); + let ran = false; + registerCommand({ id: 'v.one', title: 'One', group: 'view', shortcut: '1', run: () => { ran = true; } }); + key({ key: '1' }, input()!); + expect(ran).toBe(false); + }); +}); diff --git a/craft/src/panels/command-palette/CommandPalette.tsx b/craft/src/panels/command-palette/CommandPalette.tsx new file mode 100644 index 0000000..e34fc2f --- /dev/null +++ b/craft/src/panels/command-palette/CommandPalette.tsx @@ -0,0 +1,149 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Modal } from '../../ui/Modal'; +import { registerCommand, subscribeCommands, useCommandContext } from '../../editor/commands'; +import { matchShortcut } from '../../utils/shortcuts'; +import { buildPaletteItems, recordRecent } from './palette-sources'; + +/* Open channel: the `help.palette` command (Cmd+K) lives in the registry and + reaches the mounted palette through this module-level listener set, the same + shape core-commands uses for the shortcuts modal. */ +const openers = new Set<() => void>(); +export function openCommandPalette(): void { + openers.forEach((cb) => cb()); +} + +const LISTBOX_ID = 'wsb-palette-list'; +const optId = (i: number) => `wsb-palette-opt-${i}`; + +export const CommandPalette: React.FC = () => { + const ctx = useCommandContext(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [active, setActive] = useState(0); + const [registryTick, setRegistryTick] = useState(0); + const inputRef = useRef(null); + + useEffect( + () => + registerCommand({ + id: 'help.palette', + title: 'Command palette', + group: 'help', + shortcut: 'mod+k', + allowWhileTyping: false, + run: () => openCommandPalette(), + }), + [], + ); + useEffect(() => { + openers.add(openIt); + return () => { + openers.delete(openIt); + }; + }, []); + useEffect(() => subscribeCommands(() => setRegistryTick((n) => n + 1)), []); + + function openIt() { + setQuery(''); + setActive(0); + setOpen(true); + } + const close = useCallback(() => setOpen(false), []); + + useEffect(() => { + if (open) inputRef.current?.focus(); + }, [open]); + + const items = useMemo( + () => (open ? buildPaletteItems(query, ctx) : []), + // eslint-disable-next-line react-hooks/exhaustive-deps + [open, query, ctx, registryTick], + ); + const current = Math.min(active, Math.max(items.length - 1, 0)); + const highlighted = items[current]; + + const choose = (i: number) => { + const item = items[i]; + if (!item) return; + recordRecent(item.id); + setOpen(false); + item.run(); + }; + + const onKeyDownCapture = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + // Close the palette only; the canvas' Escape (select parent) must not see it. + e.preventDefault(); + e.stopPropagation(); + close(); + } else if (matchShortcut(e.nativeEvent, 'mod+k')) { + e.preventDefault(); + e.stopPropagation(); + close(); + } + }; + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActive(items.length ? (current + 1) % items.length : 0); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActive(items.length ? (current - 1 + items.length) % items.length : 0); + } else if (e.key === 'Enter') { + e.preventDefault(); + choose(current); + } + }; + + return ( + } + backdropStyle={{ alignItems: 'flex-start', paddingTop: '14vh' }} + > +
+ { + setQuery(e.target.value); + setActive(0); + }} + onKeyDownCapture={onKeyDownCapture} + onKeyDown={onKeyDown} + /> +
    + {items.map((it, i) => ( +
  • setActive(i)} + onClick={() => choose(i)} + > + {it.group} + {it.title} + {it.subtitle && {it.subtitle}} + {it.shortcut && {it.shortcut}} +
  • + ))} + {items.length === 0 &&
  • No matches
  • } +
+
+ {highlighted?.shortcut ? `Did you know? ${highlighted.title} is ${highlighted.shortcut}` : 'Up/Down to move, Enter to run, Esc to close'} +
+
+
+ ); +}; diff --git a/craft/src/panels/command-palette/palette-sources.test.ts b/craft/src/panels/command-palette/palette-sources.test.ts new file mode 100644 index 0000000..74f5dea --- /dev/null +++ b/craft/src/panels/command-palette/palette-sources.test.ts @@ -0,0 +1,91 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { registerCommand, __resetCommandsForTests, type CommandContext } from '../../editor/commands'; +import { buildPaletteItems, recordRecent, getRecentIds, PALETTE_MAX_ITEMS } from './palette-sources'; + +const switchPage = vi.fn(); +const ctx = { + query: { getEvent: () => ({ all: () => [] }) }, + actions: {}, + selectedIds: [], + pages: { + activePageId: 'home', + pages: [ + { id: 'home', name: 'Home', slug: 'index', craftState: null }, + { id: 'p2', name: 'Contact', slug: 'contact', craftState: null }, + ], + switchPage, + editHeader: vi.fn(), + editFooter: vi.fn(), + }, +} as unknown as CommandContext; + +const reg = (id: string, title: string, extra: object = {}) => + registerCommand({ id, title, group: 'edit', run: () => {}, ...extra } as any); + +beforeEach(() => { + __resetCommandsForTests(); + window.localStorage.clear(); + switchPage.mockClear(); +}); +afterEach(() => __resetCommandsForTests()); + +describe('buildPaletteItems ranking', () => { + test('rank order table', () => { + reg('a', 'Reparent node'); // substring + reg('b', 'Open parent settings'); // word prefix + reg('c', 'Parent select'); // prefix + expect(buildPaletteItems('par', ctx).filter((i) => i.id.startsWith('cmd:')).map((i) => i.title)).toEqual(['Parent select', 'Open parent settings', 'Reparent node']); + }); + + test('same rank: Insert block > Go to page > Commands > Site', () => { + reg('x', 'Pricing review'); + reg('s', 'Pricing sync', { group: 'site' }); + const items = buildPaletteItems('pric', ctx); + // Block "Pricing" (prefix, Insert) first, then prefix commands in group order. + expect(items[0]).toMatchObject({ group: 'Insert block', title: 'Pricing' }); + const groups = items.filter((i) => i.title.startsWith('Pricing')).map((i) => i.group); + expect(groups.indexOf('Commands')).toBeLessThan(groups.indexOf('Site')); + }); + + test('page match and when() gate', () => { + reg('site.publish', 'Publish', { group: 'site', when: () => false }); + expect(buildPaletteItems('publ', ctx)).toEqual([]); + reg('site.publish', 'Publish', { group: 'site', when: () => true }); + expect(buildPaletteItems('publ', ctx).map((i) => i.id)).toEqual(['cmd:site.publish']); + const contact = buildPaletteItems('contact', ctx); + expect(contact[0]).toMatchObject({ id: 'page:p2', title: 'Go to page: Contact', group: 'Go to page' }); + contact[0].run(); + expect(switchPage).toHaveBeenCalledWith('p2'); + }); + + test('hidden commands and the palette command are excluded; keywords match last', () => { + reg('h', 'Hidden thing', { hidden: true }); + reg('help.palette', 'Command palette', { group: 'help' }); + reg('k', 'Zoom', { keywords: ['magnify'] }); + expect(buildPaletteItems('hidden', ctx)).toEqual([]); + expect(buildPaletteItems('palette', ctx)).toEqual([]); + expect(buildPaletteItems('magni', ctx).map((i) => i.id)).toEqual(['cmd:k']); + }); + + test('cap', () => { + for (let i = 0; i < 30; i++) reg('c' + i, 'Thing ' + i); + expect(buildPaletteItems('thing', ctx)).toHaveLength(PALETTE_MAX_ITEMS); + expect(buildPaletteItems('', ctx)).toHaveLength(PALETTE_MAX_ITEMS); + }); +}); + +describe('empty query and recents', () => { + test('recent first (most recent first, max 5, deduped), then commands', () => { + reg('a', 'Alpha'); + reg('b', 'Beta'); + reg('c', 'Gamma'); + ['cmd:a', 'cmd:b', 'cmd:c', 'cmd:a', 'cmd:x1', 'cmd:x2', 'cmd:x3'].forEach(recordRecent); + expect(getRecentIds()).toEqual(['cmd:x3', 'cmd:x2', 'cmd:x1', 'cmd:a', 'cmd:c']); + expect(buildPaletteItems('', ctx).map((i) => i.id)).toEqual(['cmd:a', 'cmd:c', 'cmd:b']); + }); + + test('no recents: top commands, never blocks or pages', () => { + reg('a', 'Alpha'); + expect(buildPaletteItems('', ctx).map((i) => i.id)).toEqual(['cmd:a']); + }); +}); diff --git a/craft/src/panels/command-palette/palette-sources.ts b/craft/src/panels/command-palette/palette-sources.ts new file mode 100644 index 0000000..12bc447 --- /dev/null +++ b/craft/src/panels/command-palette/palette-sources.ts @@ -0,0 +1,156 @@ +import { getCommands, type CommandContext } from '../../editor/commands'; +import { formatShortcut } from '../../utils/shortcuts'; +import { searchBlocks, insertBlock } from '../left/blocks-registry'; +import { getPref, setPref } from '../../utils/local-prefs'; + +/** + * Item sources for the Cmd+K palette (#49): registered commands, insertable + * blocks and page navigation, merged into one ranked list. Pure of React so the + * ranking is testable as a table. + */ +export type PaletteGroup = 'Commands' | 'Insert block' | 'Go to page' | 'Site'; + +export interface PaletteItem { + /** Stable across sessions (stored in the recent list): `cmd:`, `block:`, `page:`. */ + id: string; + group: PaletteGroup; + title: string; + subtitle?: string; + /** Display string (already formatted for the platform). */ + shortcut?: string; + run(): void; +} + +export const PALETTE_MAX_ITEMS = 12; +export const PALETTE_RECENT_MAX = 5; +const RECENT_KEY = 'palette:recent'; +/** Never offered in the palette itself. */ +const SELF_ID = 'help.palette'; +const GROUP_ORDER: PaletteGroup[] = ['Insert block', 'Go to page', 'Commands', 'Site']; + +/** 0 whole-text match, 1 prefix, 2 word prefix, 3 substring, -1 no match. */ +function textRank(text: string, q: string): number { + const t = text.toLowerCase(); + if (t === q) return 0; + if (t.startsWith(q)) return 1; + if (t.split(/[^a-z0-9]+/).some((w) => w.startsWith(q))) return 2; + if (t.includes(q)) return 3; + return -1; +} + +/** Best rank over the title, then keywords (a keyword hit ranks below any title hit). */ +function rankOf(title: string, keywords: string[], q: string, names: string[] = []): number { + const hits = [title, ...names].map((t) => textRank(t, q)).filter((r) => r >= 0); + if (hits.length) return Math.min(...hits); + return keywords.some((k) => textRank(k, q) >= 0) ? 4 : -1; +} + +function liveSelection(ctx: CommandContext): CommandContext { + try { + return { ...ctx, selectedIds: ctx.query.getEvent('selected').all() }; + } catch { + return ctx; + } +} + +function allItems(ctx: CommandContext, q: string): Array<{ item: PaletteItem; keywords: string[]; names?: string[] }> { + const out: Array<{ item: PaletteItem; keywords: string[]; names?: string[] }> = []; + + for (const cmd of getCommands()) { + if (cmd.hidden || cmd.id === SELF_ID) continue; + let enabled = true; + try { + enabled = cmd.when ? !!cmd.when(ctx) : true; + } catch { + enabled = false; + } + if (!enabled) continue; + out.push({ + keywords: cmd.keywords ?? [], + item: { + id: `cmd:${cmd.id}`, + group: cmd.group === 'site' ? 'Site' : 'Commands', + title: cmd.title, + shortcut: cmd.shortcut ? formatShortcut(cmd.shortcut) : undefined, + run: () => { + void Promise.resolve(cmd.run(liveSelection(ctx))).catch((e) => console.error(`Command ${cmd.id} failed:`, e)); + }, + }, + }); + } + + // Blocks only on a query: the full catalogue would drown the empty state. + if (q) { + for (const b of searchBlocks(q)) { + out.push({ + keywords: b.keywords, + item: { + id: `block:${b.id}`, + group: 'Insert block', + title: b.label, + subtitle: 'Insert after selection', + run: () => { + insertBlock(ctx.query, ctx.actions, b.id, { afterSelection: true }); + }, + }, + }); + } + } + + const pages = ctx.pages; + if (pages) { + for (const p of pages.pages ?? []) { + out.push({ + keywords: [p.slug], + names: [p.name], // 'contact' matches the page exactly, not just the 'Go to page:' title + item: { + id: `page:${p.id}`, + group: 'Go to page', + title: `Go to page: ${p.name}`, + subtitle: p.id === pages.activePageId ? 'Current page' : `/${p.slug}`, + run: () => pages.switchPage(p.id), + }, + }); + } + out.push( + { keywords: ['header', 'nav'], item: { id: 'page:__header', group: 'Go to page', title: 'Go to page: Header', run: () => pages.editHeader() } }, + { keywords: ['footer'], item: { id: 'page:__footer', group: 'Go to page', title: 'Go to page: Footer', run: () => pages.editFooter() } }, + ); + } + return out; +} + +export function getRecentIds(): string[] { + const v = getPref(RECENT_KEY, []); + return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string').slice(0, PALETTE_RECENT_MAX) : []; +} + +export function recordRecent(id: string): void { + setPref(RECENT_KEY, [id, ...getRecentIds().filter((x) => x !== id)].slice(0, PALETTE_RECENT_MAX)); +} + +/** + * Ranked palette list. Order: match quality (exact prefix, word prefix, + * substring, keyword), then Insert block > Go to page > Commands > Site, then + * source order. An empty query shows the recently run items, then the top + * commands. Capped at PALETTE_MAX_ITEMS. + */ +export function buildPaletteItems(query: string, ctx: CommandContext): PaletteItem[] { + const q = query.trim().toLowerCase(); + const all = allItems(ctx, q); + + if (!q) { + const byId = new Map(all.map((a) => [a.item.id, a.item])); + const recent = getRecentIds().map((id) => byId.get(id)).filter((i): i is PaletteItem => !!i); + const seen = new Set(recent.map((i) => i.id)); + const rest = all.map((a) => a.item).filter((i) => !seen.has(i.id) && (i.group === 'Commands' || i.group === 'Site')); + return [...recent, ...rest].slice(0, PALETTE_MAX_ITEMS); + } + + return all + .map((a, order) => ({ item: a.item, order, rank: rankOf(a.item.title, a.keywords, q, a.names) })) + .filter((r) => r.rank >= 0) + .sort((a, b) => a.rank - b.rank || GROUP_ORDER.indexOf(a.item.group) - GROUP_ORDER.indexOf(b.item.group) || a.order - b.order) + .slice(0, PALETTE_MAX_ITEMS) + .map((r) => r.item); +} diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index e87c626..0f00e27 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -2810,3 +2810,91 @@ body { border: 0; cursor: pointer; } + +/* #49 palette */ +.wsb-palette { + width: 100%; + background: var(--color-surface, #1e1e24); + color: var(--color-text); + border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12)); + border-radius: var(--radius-md); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5); + overflow: hidden; +} + +.wsb-palette-input { + width: 100%; + box-sizing: border-box; + padding: 14px 16px; + font: inherit; + font-size: 15px; + color: inherit; + background: transparent; + border: 0; + border-bottom: 1px solid var(--color-border, rgba(255, 255, 255, 0.12)); + outline: none; +} + +.wsb-palette-list { + max-height: 340px; + margin: 0; + padding: 4px; + overflow-y: auto; + list-style: none; +} + +.wsb-palette-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + font-size: 13px; + border-radius: var(--radius-sm, 4px); + cursor: pointer; +} + +.wsb-palette-item.is-active { + background: var(--color-accent-subtle); +} + +.wsb-palette-group { + flex: 0 0 84px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.6; +} + +.wsb-palette-title { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wsb-palette-sub { + font-size: 11px; + opacity: 0.6; +} + +.wsb-palette-kbd { + font: inherit; + font-size: 11px; + padding: 1px 6px; + border: 1px solid var(--color-border, rgba(255, 255, 255, 0.2)); + border-radius: 4px; +} + +.wsb-palette-empty { + padding: 12px; + font-size: 13px; + opacity: 0.6; +} + +.wsb-palette-tip { + padding: 8px 14px; + font-size: 11px; + opacity: 0.7; + border-top: 1px solid var(--color-border, rgba(255, 255, 255, 0.12)); +}