feat(site-builder): Cmd+K command palette
Refs #49 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5430f13877
commit
1512cb7645
6 files changed
+628
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user