feat(site-builder): Cmd+K command palette

Refs #49

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-10-07 12:03:07 -07:00
1 parent 5430f13877
commit 1512cb7645
6 files changed
+628

No files matched your search

+2
View File
@@ -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 = () => (
<EmptyContainerAdd />
<DropIndicator />
<RichTextToolbar />
<CommandPalette />
</>
);
@@ -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<typeof usePages> | 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<HTMLInputElement>('[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(
<PageProvider>
<Probe />
<CommandPalette />
</PageProvider>,
);
});
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(<Heading />).toNodeTree(), 'ROOT');
h.actions.addNodeTree(h.query.parseReactElement(<Heading />).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);
});
});
@@ -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<HTMLInputElement>(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 (
<Modal
open={open}
onClose={close}
width={560}
backdropProps={{ role: 'dialog', 'aria-modal': true, 'aria-label': 'Command palette' } as React.HTMLAttributes<HTMLDivElement>}
backdropStyle={{ alignItems: 'flex-start', paddingTop: '14vh' }}
>
<div className="wsb-palette">
<input
ref={inputRef}
className="wsb-palette-input"
role="combobox"
aria-expanded="true"
aria-controls={LISTBOX_ID}
aria-autocomplete="list"
aria-activedescendant={highlighted ? optId(current) : undefined}
placeholder="Type a command, block or page..."
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActive(0);
}}
onKeyDownCapture={onKeyDownCapture}
onKeyDown={onKeyDown}
/>
<ul id={LISTBOX_ID} className="wsb-palette-list" role="listbox" aria-label="Results">
{items.map((it, i) => (
<li
key={it.id}
id={optId(i)}
role="option"
aria-selected={i === current}
className={'wsb-palette-item' + (i === current ? ' is-active' : '')}
onMouseEnter={() => setActive(i)}
onClick={() => choose(i)}
>
<span className="wsb-palette-group">{it.group}</span>
<span className="wsb-palette-title">{it.title}</span>
{it.subtitle && <span className="wsb-palette-sub">{it.subtitle}</span>}
{it.shortcut && <kbd className="wsb-palette-kbd">{it.shortcut}</kbd>}
</li>
))}
{items.length === 0 && <li className="wsb-palette-empty" role="presentation">No matches</li>}
</ul>
<div className="wsb-palette-tip">
{highlighted?.shortcut ? `Did you know? ${highlighted.title} is ${highlighted.shortcut}` : 'Up/Down to move, Enter to run, Esc to close'}
</div>
</div>
</Modal>
);
};
@@ -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']);
});
});
@@ -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:<id>`, `block:<id>`, `page:<id>`. */
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<unknown>(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);
}
+88
View File
@@ -2731,3 +2731,91 @@ body {
color: var(--color-text);
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));
}