{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "command-bar",
  "title": "Command bar",
  "description": "A shorter toolbar and a ⌘K search box. The toolbar keeps ten everyday buttons plus a More button; everything else, including tools from other mods, is a keystroke away. The search box also jumps to named landmarks on any page, flips editor preferences, and switches light and dark mode.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "workspace/src/mods/command-bar.tsx",
      "content": "import { ChevronUp, Monitor, Moon, Sun } from 'lucide-react'\nimport { ArrowToolbarItem, AssetToolbarItem, DefaultContextMenu, DefaultContextMenuContent, DefaultToolbar, DrawToolbarItem, EraserToolbarItem, HandToolbarItem, NoteToolbarItem, RectangleToolbarItem, SelectToolbarItem, TextToolbarItem, TldrawUiMenuGroup, TldrawUiMenuItem, useDialogs, useEditor, type TLUiContextMenuProps } from 'tldraw'\nimport { installed, type ModConfig } from '@/mod'\nimport { CommandBar, toggleCommandBar, type Palette, type PaletteCommand } from '@/command-bar'\n\n// A short toolbar and a ⌘K command bar. The toolbar keeps ten everyday buttons and a \"More tools\" button; every other\n// tool, including the ones other mods add, is reached through the command bar. It also lists named landmarks on every\n// page, editor preferences, and a light/dark/system switch, and mirrors mod commands into the right-click menu.\n\nconst preferenceCommands: PaletteCommand[] = [\n\t{ id: 'theme-light', label: 'Set theme to Light', icon: <Sun size={18} aria-hidden />, readonlyOk: true, run(editor, trackEvent) { editor.user.updateUserPreferences({ colorScheme: 'light' }); trackEvent?.('color-scheme', { source: 'dialog', value: 'light' }) } },\n\t{ id: 'theme-dark', label: 'Set theme to Dark', icon: <Moon size={18} aria-hidden />, readonlyOk: true, run(editor, trackEvent) { editor.user.updateUserPreferences({ colorScheme: 'dark' }); trackEvent?.('color-scheme', { source: 'dialog', value: 'dark' }) } },\n\t{ id: 'theme-system', label: 'Set theme to System', icon: <Monitor size={18} aria-hidden />, readonlyOk: true, run(editor, trackEvent) { editor.user.updateUserPreferences({ colorScheme: 'system' }); trackEvent?.('color-scheme', { source: 'dialog', value: 'system' }) } },\n]\nconst preferenceActionIds = ['toggle-snap-mode', 'toggle-tool-lock', 'toggle-grid', 'toggle-wrap-mode', 'toggle-focus-mode', 'toggle-edge-scrolling', 'toggle-dynamic-size-mode', 'toggle-paste-at-cursor', 'toggle-debug-mode']\n\n// Built once, after config.tsx has filled `installed`; identity must stay stable for the ⌘K listener.\nconst palette: Palette = {\n\tpersonalTools: installed.tools,\n\tcommands: installed.commands,\n\tpreferenceCommands,\n\tpreferenceActionIds,\n}\n\nfunction MoreToolsItem() {\n\tconst dialogs = useDialogs()\n\treturn <TldrawUiMenuItem id=\"more\" label=\"More tools\" kbd=\"$k\" icon={<ChevronUp size={24} aria-hidden />} readonlyOk onSelect={() => toggleCommandBar(dialogs, palette)} />\n}\n\nconst toolbarItems = [SelectToolbarItem, HandToolbarItem, DrawToolbarItem, EraserToolbarItem, ArrowToolbarItem, TextToolbarItem, NoteToolbarItem, AssetToolbarItem, RectangleToolbarItem, MoreToolsItem]\n\nfunction Toolbar() {\n\treturn (\n\t\t<DefaultToolbar maxItems={toolbarItems.length} maxSizePx={toolbarItems.length * 50}>\n\t\t\t{toolbarItems.map(Item => <Item key={Item.name} />)}\n\t\t</DefaultToolbar>\n\t)\n}\n\nfunction ContextMenu(props: TLUiContextMenuProps) {\n\tconst editor = useEditor()\n\tfunction run(command: PaletteCommand) {\n\t\t// tldraw does not expose Radix's onCloseAutoFocus; cancel the menu's focus return so the command can focus a shape input.\n\t\teditor.getContainer().querySelector('[data-testid=\"context-menu\"]')?.addEventListener('focusScope.autoFocusOnUnmount', event => event.preventDefault(), { once: true })\n\t\tcommand.run(editor)\n\t}\n\treturn (\n\t\t<DefaultContextMenu {...props}>\n\t\t\t{palette.commands.length > 0 && (\n\t\t\t\t<TldrawUiMenuGroup id=\"mods\">\n\t\t\t\t\t{palette.commands.map(command => <TldrawUiMenuItem key={command.id} id={command.id} label={command.label} onSelect={() => run(command)} />)}\n\t\t\t\t</TldrawUiMenuGroup>\n\t\t\t)}\n\t\t\t<DefaultContextMenuContent />\n\t\t</DefaultContextMenu>\n\t)\n}\n\nexport default (({ config }) => {\n\tconst Previous = config.components.InFrontOfTheCanvas\n\tconfig.components = {\n\t\t...config.components,\n\t\tToolbar,\n\t\tContextMenu,\n\t\tInFrontOfTheCanvas: () => <>{Previous ? <Previous /> : null}<CommandBar palette={palette} /></>,\n\t}\n}) satisfies ModConfig\n",
      "type": "registry:component",
      "target": "~/src/mods/command-bar.tsx"
    },
    {
      "path": "workspace/src/command-bar.tsx",
      "content": "import { useEffect, useId, useRef, useState, type ComponentPropsWithoutRef, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react'\nimport { MapPin, Search, ToggleRight } from 'lucide-react'\nimport { TldrawUiDialogTitle, TldrawUiIcon, TldrawUiKbd, unwrapLabel, useActions, useDialogs, useEditor, useIsToolSelected, useTools, useTranslation, useUiEvents, useValue, type Editor, type TLPageId, type TLShapeId, type TLUiActionItem, type TLUiDialogsContextType, type TLUiEventHandler, type TLUiToolItem } from 'tldraw'\n\nimport type { ModCommand, ModTool } from '@/mod'\n\n/** A tool a mod adds; `icon` is a rendered element rather than a tldraw icon name. */\nexport type PersonalTool = ModTool & { readonlyOk?: boolean }\n/** An editor action offered in the palette. */\nexport interface PaletteCommand extends ModCommand { run(editor: Editor, trackEvent?: TLUiEventHandler): void }\nexport interface Palette { personalTools: PersonalTool[]; commands: PaletteCommand[]; preferenceCommands: PaletteCommand[]; preferenceActionIds: string[] }\n\ninterface Landmark { entryType: 'landmark'; id: TLShapeId; pageId: TLPageId; label: string; page: string; sort: string; readonlyOk: true }\ntype ToolEntry = (TLUiToolItem | (PersonalTool & { onSelect(source: string): void })) & { entryType: 'tool' }\ntype ActionEntry = Omit<TLUiActionItem, 'label'> & { entryType: 'action'; label: string }\ntype Entry = PaletteCommand | Landmark | ToolEntry | ActionEntry\nconst isLandmark = (entry: Entry): entry is Landmark => 'entryType' in entry && entry.entryType === 'landmark'\nconst isCommand = (entry: Entry): entry is PaletteCommand => 'run' in entry\nconst isAction = (entry: Entry): entry is ActionEntry => 'entryType' in entry && entry.entryType === 'action'\n\nconst dialogId = 'personal-tool-search'\n// Only tldraw's dialog frame is addressed by selector; everything of ours is Tailwind.\nconst css = `\n.tlui-dialog__content:has(.tool-search) { padding:0; overflow:hidden; max-width:calc(100vw - 40px); }\n.tlui-dialog__positioner:has(.tool-search) { align-items:start; padding-top:min(18vh,160px); }\n`\nconst landmarkIcon = <MapPin size={18} aria-hidden />\n\nfunction Option({ command, hint, ...props }: { command: Entry; hint?: string } & Omit<ComponentPropsWithoutRef<'li'>, 'children'>) {\n\treturn (\n\t\t<li role=\"option\" className=\"ui-option group\" {...props}>\n\t\t\t<span className=\"flex size-5 shrink-0 items-center justify-center text-muted-foreground group-aria-selected:text-foreground [&_svg]:size-4\">\n\t\t\t\t{isLandmark(command) ? landmarkIcon : typeof command.icon === 'string' ? <TldrawUiIcon icon={command.icon} label={command.label} /> : command.icon}\n\t\t\t</span>\n\t\t\t<span className=\"flex-1 truncate\">{command.label}</span>\n\t\t\t{hint ? <span className=\"ui-label inline-flex items-center gap-1.5 before:size-1 before:rounded-full before:bg-current before:content-['']\">{hint}</span> : null}\n\t\t\t{'kbd' in command && command.kbd ? <TldrawUiKbd>{command.kbd}</TldrawUiKbd> : null}\n\t\t</li>\n\t)\n}\n\nfunction ToolOption({ command, ...props }: { command: ToolEntry } & Omit<ComponentPropsWithoutRef<'li'>, 'children'>) {\n\tconst selected = useIsToolSelected(command as TLUiToolItem)\n\treturn <Option command={command} hint={selected ? 'Active' : ''} {...props} />\n}\n\n// Named landmarks on every page; the current page lists first so nearby landmarks stay at the top.\nfunction useLandmarks(editor: Editor): Landmark[] {\n\treturn useValue('command bar landmarks', () => {\n\t\tconst currentPageId = editor.getCurrentPageId()\n\t\tconst landmarks: Landmark[] = []\n\t\tfor (const page of editor.getPages()) {\n\t\t\tconst onCurrentPage = page.id === currentPageId\n\t\t\tfor (const id of editor.getPageShapeIds(page)) {\n\t\t\t\tconst shape = editor.getShape(id)\n\t\t\t\tif (!shape || shape.type !== 'landmark') continue\n\t\t\t\tconst name = shape.props.label.trim()\n\t\t\t\tlandmarks.push({ entryType:'landmark', id, pageId:page.id, label:name || 'Landmark', page:onCurrentPage ? '' : page.name, sort:`${onCurrentPage ? 0 : 1}${name || '\\uffff'}`, readonlyOk:true })\n\t\t\t}\n\t\t}\n\t\treturn landmarks.sort((a, b) => a.sort.localeCompare(b.sort))\n\t}, [editor])\n}\n\nfunction goToLandmark(editor: Editor, landmark: Landmark) {\n\tif (landmark.pageId !== editor.getCurrentPageId()) editor.setCurrentPage(landmark.pageId)\n\tconst bounds = editor.getShapePageBounds(landmark.id)\n\tif (!bounds) return\n\teditor.select(landmark.id)\n\teditor.zoomToBounds(bounds, { inset:64, targetZoom:1, animation:{ duration:320 } })\n}\n\nfunction ToolSearch({ personalTools, commands: personalCommands, preferenceCommands, preferenceActionIds, onClose }: Palette & { onClose(): void }) {\n\tconst editor = useEditor()\n\tconst actions = useActions()\n\tconst tools = useTools()\n\tconst msg = useTranslation()\n\tconst trackEvent = useUiEvents()\n\tconst readonly = useValue('command bar readonly', () => editor.getIsReadonly(), [editor])\n\tconst landmarks = useLandmarks(editor)\n\tconst [query, setQuery] = useState('')\n\tconst [activeId, setActiveId] = useState<string | null>(null)\n\tconst input = useRef<HTMLInputElement>(null)\n\tconst list = useRef<HTMLDivElement>(null)\n\tconst listId = useId()\n\tconst term = query.trim().toLocaleLowerCase()\n\tconst matches = (command: Entry) => (!readonly || command.readonlyOk) && `${command.label} ${isLandmark(command) ? command.page : ''} ${command.id}`.toLocaleLowerCase().includes(term)\n\tconst preferenceActions = preferenceActionIds.flatMap((id): ActionEntry[] => {\n\t\tconst action = actions[id]\n\t\tif (!action) return []\n\t\tconst labelKey = unwrapLabel(action.label, 'default') ?? unwrapLabel(action.label, 'menu')\n\t\treturn [{ ...action, entryType:'action', label:labelKey ? msg(labelKey) : id.replaceAll('-', ' '), icon:action.icon ?? <ToggleRight size={18} aria-hidden /> }]\n\t})\n\tconst groups: { heading: string; items: Entry[] }[] = [\n\t\t{ heading:'Commands', items:[...personalCommands, ...preferenceActions, ...preferenceCommands].filter(matches) },\n\t\t{ heading:'Landmarks', items:landmarks.filter(matches) },\n\t\t{ heading:'Tools', items:[\n\t\t\t...personalTools.map((tool): ToolEntry => ({ ...tool, entryType:'tool', onSelect: () => editor.setCurrentTool(tool.id) })),\n\t\t\t...Object.values(tools).map((tool): ToolEntry => {\n\t\t\t\tconst label = msg(tool.label)\n\t\t\t\treturn { ...tool, entryType:'tool', label:label === tool.label ? tool.id[0].toUpperCase() + tool.id.slice(1).replaceAll('-', ' ') : label }\n\t\t\t}),\n\t\t].filter(matches) },\n\t].filter(group => group.items.length)\n\tconst commands = groups.flatMap(group => group.items)\n\tconst activeIndex = Math.max(0, commands.findIndex(command => command.id === activeId))\n\tconst active = commands[activeIndex]\n\tuseEffect(() => { input.current?.focus() }, [])\n\tuseEffect(() => {\n\t\tlist.current?.querySelector('[aria-selected=true]')?.scrollIntoView({ block:'nearest' })\n\t}, [active?.id])\n\n\tfunction choose(command: Entry | undefined) {\n\t\tif (!command) return\n\t\tonClose()\n\t\teditor.complete()\n\t\teditor.setEditingShape(null)\n\t\tif (isCommand(command)) {\n\t\t\t// Focus the canvas before running: a command may hand focus to a shape's input.\n\t\t\teditor.focus()\n\t\t\tcommand.run(editor, trackEvent)\n\t\t\treturn\n\t\t}\n\t\tif (isLandmark(command)) goToLandmark(editor, command)\n\t\t// Use native actions and tools so preference persistence, analytics, and tool defaults stay intact.\n\t\telse command.onSelect('dialog')\n\t\teditor.focus()\n\t}\n\n\tfunction navigate(event: KeyboardEvent<HTMLDivElement>) {\n\t\tif (event.key === 'Tab' || (event.target !== input.current && event.key !== 'Escape')) return\n\t\tevent.stopPropagation()\n\t\tif (event.nativeEvent.isComposing) return\n\t\tif (event.key === 'ArrowDown' || event.key === 'ArrowUp') {\n\t\t\tevent.preventDefault()\n\t\t\tif (commands.length) setActiveId(commands[(activeIndex + (event.key === 'ArrowDown' ? 1 : -1) + commands.length) % commands.length]!.id)\n\t\t} else if (event.key === 'Enter') {\n\t\t\tevent.preventDefault()\n\t\t\tchoose(active)\n\t\t} else if (event.key === 'Escape') {\n\t\t\tevent.preventDefault()\n\t\t\tonClose()\n\t\t}\n\t}\n\n\treturn (\n\t\t<div className=\"tool-search w-[min(560px,calc(100vw-42px))] font-sans text-[13px]/[1.4] antialiased\" onKeyDown={navigate}>\n\t\t\t<style>{css}</style>\n\t\t\t<TldrawUiDialogTitle className=\"sr-only\">Search commands, landmarks, and tools</TldrawUiDialogTitle>\n\t\t\t<div className=\"ui-well m-2 flex h-12 items-center gap-2.5 rounded-md pr-2 pl-3\">\n\t\t\t\t<Search className=\"shrink-0 text-muted-foreground\" size={18} strokeWidth={1.75} aria-hidden />\n\t\t\t\t<input\n\t\t\t\t\tref={input} className=\"h-full min-w-0 flex-1 border-0 bg-transparent p-0 text-[15px] text-inherit caret-foreground outline-0 [font:inherit] placeholder:text-muted-foreground\" role=\"combobox\" aria-label=\"Search commands, landmarks, and tools\" aria-expanded aria-autocomplete=\"list\"\n\t\t\t\t\taria-controls={listId} aria-activedescendant={active ? `${listId}-${activeIndex}` : undefined}\n\t\t\t\t\tplaceholder=\"Search commands, landmarks, and tools…\" value={query} autoComplete=\"off\" spellCheck={false}\n\t\t\t\t\tonChange={event => { setQuery(event.currentTarget.value); setActiveId(null) }}\n\t\t\t\t/>\n\t\t\t\t<button type=\"button\" className=\"ui-key ui-kbd h-5 px-1.5 tracking-[.06em] text-muted-foreground uppercase hover:text-foreground\" aria-label=\"Close command bar\" onClick={onClose}>esc</button>\n\t\t\t</div>\n\t\t\t<div className=\"relative\">\n\t\t\t\t<div ref={list} id={listId} role=\"listbox\" aria-label=\"Commands, landmarks, and tools\" className=\"m-0 h-[min(336px,calc(100dvh-260px))] overflow-y-auto overscroll-contain px-2 pb-2 [scrollbar-color:var(--ui-line)_transparent] [scrollbar-width:thin]\">\n\t\t\t\t\t{groups.map(group => {\n\t\t\t\t\t\tconst offset = commands.indexOf(group.items[0]!)\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<div key={group.heading} role=\"group\" aria-label={group.heading} className=\"[&+&]:mt-1\">\n\t\t\t\t\t\t\t\t<div className=\"ui-label flex h-7 items-center justify-between px-2.5\"><span>{group.heading}</span><span>{group.items.length}</span></div>\n\t\t\t\t\t\t\t\t<ul className=\"m-0 list-none p-0\">\n\t\t\t\t\t\t\t\t\t{group.items.map((command, i) => {\n\t\t\t\t\t\t\t\t\t\tconst index = offset + i\n\t\t\t\t\t\t\t\t\t\tconst shared = {\n\t\t\t\t\t\t\t\t\t\t\tid: `${listId}-${index}`, 'aria-selected': index === activeIndex,\n\t\t\t\t\t\t\t\t\t\t\tonPointerMove: () => setActiveId(command.id), onPointerDown: (event: PointerEvent) => event.preventDefault(), onClick: () => choose(command),\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\treturn isLandmark(command) || isCommand(command) || isAction(command)\n\t\t\t\t\t\t\t\t\t\t\t? <Option key={command.id} command={command} hint={isLandmark(command) ? command.page : undefined} {...shared} />\n\t\t\t\t\t\t\t\t\t\t\t: <ToolOption key={command.id} command={command} {...shared} />\n\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t</ul>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\t\t\t\t</div>\n\t\t\t\t{!commands.length ? <div className=\"ui-label absolute inset-0 grid place-content-center p-5 text-center\" role=\"status\">No matching commands, landmarks, or tools</div> : null}\n\t\t\t</div>\n\t\t\t<div className=\"ui-rule-top flex h-9 items-center justify-between gap-3 px-4 text-[11px] text-muted-foreground\">\n\t\t\t\t<span className=\"flex items-center gap-[5px]\"><kbd className=\"ui-key ui-kbd\">↑</kbd><kbd className=\"ui-key ui-kbd\">↓</kbd>Navigate</span>\n\t\t\t\t<span className=\"flex items-center gap-[5px]\">{active && (isCommand(active) || isAction(active)) ? 'Run command' : active && isLandmark(active) ? 'Go to landmark' : 'Switch tool'}<kbd className=\"ui-key ui-kbd\">↵</kbd></span>\n\t\t\t</div>\n\t\t</div>\n\t)\n}\n\nexport function toggleCommandBar({ addDialog, removeDialog, dialogs }: TLUiDialogsContextType, palette: Palette) {\n\tif (dialogs.get().some(dialog => dialog.id === dialogId)) {\n\t\tremoveDialog(dialogId)\n\t} else if (!dialogs.get().length) {\n\t\taddDialog({ id: dialogId, component: props => <ToolSearch {...props} {...palette} /> })\n\t}\n}\n\n// Keep `palette` identity stable so the shortcut listener is not re-bound.\nexport function CommandBar({ palette }: { palette: Palette }) {\n\tconst dialogApi = useDialogs()\n\tconst { removeDialog, dialogs } = dialogApi\n\tuseEffect(() => {\n\t\tfunction handleShortcut(event: globalThis.KeyboardEvent) {\n\t\t\tif (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey || event.code !== 'KeyK' || event.isComposing) return\n\t\t\tevent.preventDefault()\n\t\t\tevent.stopImmediatePropagation()\n\t\t\tif (event.repeat) return\n\t\t\ttoggleCommandBar(dialogApi, palette)\n\t\t}\n\t\twindow.addEventListener('keydown', handleShortcut, true)\n\t\treturn () => {\n\t\t\twindow.removeEventListener('keydown', handleShortcut, true)\n\t\t\tif (dialogs.get().some(dialog => dialog.id === dialogId)) removeDialog(dialogId)\n\t\t}\n\t}, [dialogApi, removeDialog, dialogs, palette])\n\treturn null\n}\n",
      "type": "registry:component",
      "target": "~/src/command-bar.tsx"
    }
  ],
  "docs": "Added to src/mods/. Press ⌘K (Ctrl+K) or the toolbar's last button. If the drawing did not reload on its own, run: npm run apply",
  "categories": [
    "ui"
  ],
  "type": "registry:component"
}