@dfosco/storyboard-react 4.0.0-beta.42 → 4.0.0-beta.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,918 @@
1
+ import { useState, useEffect, useCallback, useMemo } from 'react'
2
+ import 'react-cmdk/dist/cmdk.css'
3
+ import * as ReactCmdk from 'react-cmdk'
4
+ const CommandPalette = ReactCmdk.default || ReactCmdk
5
+ const { filterItems, getItemIndex } = ReactCmdk
6
+ import {
7
+ buildPrototypeIndex,
8
+ listStories,
9
+ getStoryData,
10
+ getActionsForMode,
11
+ executeAction,
12
+ getActionChildren,
13
+ getToolbarToolState,
14
+ getCurrentMode,
15
+ getRecent,
16
+ trackRecent,
17
+ getCommandPaletteConfig,
18
+ getToolbarConfig,
19
+ setTheme,
20
+ isExcludedByRoute,
21
+ } from '@dfosco/storyboard-core'
22
+ import CreateDialog from './CreateDialog.jsx'
23
+ import './command-palette.css'
24
+
25
+ /**
26
+ * Check if a tool should be hidden from the command palette on the current route.
27
+ * Uses the same pattern-matching logic as excludeRoutes.
28
+ */
29
+ function isHiddenInPalette(tool, basePath) {
30
+ const val = tool.hideInCommandPalette
31
+ if (val === true) return true
32
+ if (!val || !Array.isArray(val) || val.length === 0) return false
33
+ if (typeof window === 'undefined') return false
34
+ let pathname = window.location.pathname
35
+ const base = (basePath || '/').replace(/\/+$/, '')
36
+ if (base && pathname.startsWith(base)) {
37
+ pathname = pathname.slice(base.length) || '/'
38
+ }
39
+ return val.some(pattern => new RegExp(pattern).test(pathname))
40
+ }
41
+
42
+ /**
43
+ * Build groups from commandPalette.sections config.
44
+ * Returns { groups, toolMenus } where toolMenus are entries with sub-pages.
45
+ *
46
+ * Section types:
47
+ * - Static items: { items: [...] }
48
+ * - Dynamic list: { source: "canvases"|"prototypes"|"stories"|"recent" }
49
+ * - Tool section: { source: "tools", toolIds: ["theme", "flows"] }
50
+ * - Tool-menu: { type: "tool-menu", options: [...] }
51
+ */
52
+ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
53
+ const config = getCommandPaletteConfig()
54
+ const sections = config?.sections || []
55
+ const groups = []
56
+ const toolMenus = []
57
+ const usedToolIds = new Set() // Track tools already listed by source:"tools" sections
58
+ const basePath = prefix || '/'
59
+
60
+ for (const section of sections) {
61
+ // Separator: id starts with "sep"
62
+ if (section.id?.startsWith('sep')) {
63
+ groups.push({ id: `cfg:${section.id}`, items: [{ id: `cfg:${section.id}:sep`, children: '', keywords: ['*'] }] })
64
+ continue
65
+ }
66
+
67
+ if (section.type === 'tool-menu') {
68
+ toolMenus.push(section)
69
+ continue
70
+ }
71
+
72
+ if (section.source) {
73
+ // Defer tool-subpages — needs usedToolIds from all other sections first
74
+ if (section.source === 'tool-subpages') continue
75
+ const result = buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
76
+ if (result?.group) groups.push(result.group)
77
+ if (result?.subPages) toolMenus.push(...result.subPages)
78
+ if (result?.usedToolIds) result.usedToolIds.forEach(id => usedToolIds.add(id))
79
+ continue
80
+ }
81
+
82
+ if (section.items && section.items.length > 0) {
83
+ groups.push({
84
+ heading: section.title,
85
+ id: `cfg:${section.id}`,
86
+ items: section.items.map((item, i) => {
87
+ const id = `cfg:${section.id}:${i}`
88
+ if (item.type === 'link') {
89
+ return {
90
+ id,
91
+ children: item.label,
92
+ keywords: item.keywords || [item.label],
93
+ onClick: () => {
94
+ const url = item.url?.startsWith('/') ? prefix + item.url : item.url
95
+ if (url) window.location.href = url
96
+ },
97
+ }
98
+ }
99
+ if (item.type === 'action') {
100
+ return {
101
+ id,
102
+ children: item.label,
103
+ keywords: item.keywords || [item.label],
104
+ onClick: () => { if (item.action) executeAction(item.action) },
105
+ }
106
+ }
107
+ return { id, children: item.label, keywords: [item.label] }
108
+ }),
109
+ })
110
+ }
111
+ }
112
+
113
+ // Resolve tool-subpages sections (deferred — needs complete usedToolIds)
114
+ for (const section of sections) {
115
+ if (section.source !== 'tool-subpages') continue
116
+
117
+ // Scan all toolbar tools for sub-page candidates not already listed
118
+ const toolbarConfig = getToolbarConfig()
119
+ const allTools = toolbarConfig?.tools || {}
120
+ const mode = getCurrentMode() || 'default'
121
+ const actions = getActionsForMode(mode)
122
+ const remainingItems = []
123
+
124
+ for (const [toolId, tool] of Object.entries(allTools)) {
125
+ if (usedToolIds.has(toolId)) continue
126
+ const state = getToolbarToolState(toolId)
127
+ if (state === 'disabled' || state === 'hidden') continue
128
+ if (tool.disabled) continue
129
+ if (isHiddenInPalette(tool, basePath)) continue
130
+
131
+ const label = tool.label || tool.ariaLabel || toolId
132
+ const excluded = isExcludedByRoute(tool)
133
+
134
+ // Route-excluded tools show as disabled with hint
135
+ if (excluded) {
136
+ remainingItems.push({
137
+ id: `cfg:${section.id}:${toolId}`,
138
+ children: <><span>{label}</span><span style={{ marginLeft: 'auto', fontSize: '12px', opacity: 0.5 }}>Not available on this page</span></>,
139
+ keywords: [label, toolId].filter(Boolean),
140
+ showType: false,
141
+ disabled: true,
142
+ })
143
+ continue
144
+ }
145
+
146
+ // Tools with submenu children
147
+ if (tool.render === 'submenu' || tool.render === 'menu') {
148
+ const action = actions.find(a => a.toolKey === toolId)
149
+ if (action?.type === 'submenu') {
150
+ const children = getActionChildren(action.id)
151
+ if (children.length > 0) {
152
+ const pageId = `tool:${toolId}`
153
+ toolMenus.push({
154
+ id: pageId, label, title: label,
155
+ keywords: [label, toolId].filter(Boolean),
156
+ options: children.map(child => ({ label: child.label, execute: child.execute })),
157
+ })
158
+ remainingItems.push({
159
+ id: `cfg:${section.id}:${toolId}`,
160
+ children: label,
161
+ keywords: [label, toolId].filter(Boolean),
162
+ showType: false,
163
+ onClick: () => onNavigateToPage?.(pageId),
164
+ closeOnSelect: false,
165
+ })
166
+ continue
167
+ }
168
+ }
169
+ // Declarative options
170
+ if (tool.options?.length > 0) {
171
+ const pageId = `tool:${toolId}`
172
+ toolMenus.push({
173
+ id: pageId, label, title: label,
174
+ keywords: [label, toolId].filter(Boolean),
175
+ options: tool.options.map(opt => ({ label: opt.label, toolHandler: tool.handler || `core:${toolId}`, value: opt.value })),
176
+ })
177
+ remainingItems.push({
178
+ id: `cfg:${section.id}:${toolId}`,
179
+ children: label,
180
+ keywords: [label, toolId].filter(Boolean),
181
+ showType: false,
182
+ onClick: () => onNavigateToPage?.(pageId),
183
+ closeOnSelect: false,
184
+ })
185
+ continue
186
+ }
187
+ }
188
+
189
+ // Inline actions (e.g. toggle-chrome for hide toolbars)
190
+ if (tool.inlineAction === 'toggle-chrome') {
191
+ remainingItems.push({
192
+ id: `cfg:${section.id}:${toolId}`,
193
+ children: label,
194
+ keywords: [label, toolId, 'hide', 'show', 'toolbar'].filter(Boolean),
195
+ showType: false,
196
+ onClick: () => {
197
+ document.documentElement.classList.toggle('storyboard-chrome-hidden')
198
+ },
199
+ })
200
+ continue
201
+ }
202
+
203
+ // Any remaining tools (all surfaces)
204
+ if (tool.render === 'link' && tool.url) {
205
+ remainingItems.push({
206
+ id: `cfg:${section.id}:${toolId}`,
207
+ children: label,
208
+ keywords: [label, toolId].filter(Boolean),
209
+ showType: false,
210
+ onClick: () => { window.location.href = tool.url },
211
+ })
212
+ } else {
213
+ // Menu tools: close palette and click the toolbar button to open the menu
214
+ if (tool.render === 'menu') {
215
+ const ariaLabel = tool.ariaLabel || tool.label || toolId
216
+ remainingItems.push({
217
+ id: `cfg:${section.id}:${toolId}`,
218
+ children: label,
219
+ keywords: [label, toolId].filter(Boolean),
220
+ showType: false,
221
+ onClick: () => {
222
+ // Find and click the toolbar button
223
+ setTimeout(() => {
224
+ const btn = document.querySelector(`[aria-label="${ariaLabel}"]`)
225
+ if (btn) btn.click()
226
+ }, 100)
227
+ },
228
+ })
229
+ } else {
230
+ // Fallback: click toolbar button or execute action
231
+ const action = actions.find(a => a.toolKey === toolId)
232
+ const ariaLabel = tool.ariaLabel || tool.label || toolId
233
+ remainingItems.push({
234
+ id: `cfg:${section.id}:${toolId}`,
235
+ children: label,
236
+ keywords: [label, toolId].filter(Boolean),
237
+ showType: false,
238
+ onClick: action
239
+ ? () => executeAction(action.id)
240
+ : () => {
241
+ setTimeout(() => {
242
+ const btn = document.querySelector(`[aria-label="${ariaLabel}"]`)
243
+ if (btn) btn.click()
244
+ }, 100)
245
+ },
246
+ })
247
+ }
248
+ }
249
+ }
250
+
251
+ // Also include any toolMenus sub-pages not yet listed
252
+ for (const menu of toolMenus) {
253
+ const menuToolId = menu.id?.replace('tool:', '')
254
+ if (usedToolIds.has(menuToolId)) continue
255
+ if (remainingItems.some(i => i.id === `cfg:${section.id}:${menuToolId}`)) continue
256
+ remainingItems.push({
257
+ id: `cfg:${section.id}:${menuToolId || menu.id}`,
258
+ children: menu.label || menu.id,
259
+ keywords: menu.keywords || [menu.label || menu.id],
260
+ showType: false,
261
+ onClick: () => onNavigateToPage?.(menu.id),
262
+ closeOnSelect: false,
263
+ })
264
+ }
265
+
266
+ if (remainingItems.length === 0) continue
267
+ groups.push({
268
+ heading: section.title,
269
+ id: `cfg:${section.id}`,
270
+ items: remainingItems,
271
+ })
272
+ }
273
+
274
+ return { groups, toolMenus }
275
+ }
276
+
277
+ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction) {
278
+ if (section.source === 'tools') {
279
+ return buildToolsSection(section, prefix, onNavigateToPage)
280
+ }
281
+
282
+ // --- Create source (dev-only workshop actions) ---
283
+ if (section.source === 'create') {
284
+ const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true
285
+ if (!isLocalDev) return null
286
+ const createItems = [
287
+ { id: 'create:canvas', children: 'New Canvas', keywords: ['create', 'canvas', 'new', 'board'], showType: false, onClick: () => onCreateAction?.('Canvas') },
288
+ { id: 'create:prototype', children: 'New Prototype', keywords: ['create', 'prototype', 'new', 'page'], showType: false, onClick: () => onCreateAction?.('Prototype') },
289
+ { id: 'create:component', children: 'New Component', keywords: ['create', 'component', 'new', 'story'], showType: false, onClick: () => onCreateAction?.('Component') },
290
+ { id: 'create:flow', children: 'New Prototype Flow', keywords: ['create', 'flow', 'new', 'data'], showType: false, onClick: () => onCreateAction?.('Flow') },
291
+ { id: 'create:page', children: 'New Prototype Page', keywords: ['create', 'page', 'new'], showType: false, onClick: () => onCreateAction?.('Page') },
292
+ ]
293
+ return { group: { heading: section.title, id: `cfg:${section.id}`, items: createItems } }
294
+ }
295
+
296
+ // --- Commands source (all registered toolbar actions) ---
297
+ if (section.source === 'commands') {
298
+ const mode = getCurrentMode() || 'default'
299
+ const actions = getActionsForMode(mode)
300
+ const commandItems = []
301
+ for (const action of actions) {
302
+ if (action.type === 'header' || action.type === 'separator' || action.type === 'footer') continue
303
+ if (action.toolKey) {
304
+ const state = getToolbarToolState(action.toolKey)
305
+ if (state === 'disabled' || state === 'hidden') continue
306
+ }
307
+ if (action.type === 'submenu') {
308
+ const children = getActionChildren(action.id)
309
+ for (const child of children) {
310
+ commandItems.push({
311
+ id: `cmd:${action.id}/${child.id || child.label}`,
312
+ children: child.label,
313
+ keywords: [action.label, child.label],
314
+ onClick: () => { if (child.execute) child.execute() },
315
+ })
316
+ }
317
+ } else if (action.type === 'link' && action.url) {
318
+ commandItems.push({
319
+ id: `cmd:${action.id}`,
320
+ children: action.label,
321
+ keywords: [action.label],
322
+ onClick: () => {
323
+ const url = action.url.startsWith('/') && !action.url.startsWith('//') ? prefix + action.url : action.url
324
+ window.location.href = url
325
+ },
326
+ })
327
+ } else {
328
+ commandItems.push({
329
+ id: `cmd:${action.id}`,
330
+ children: action.label,
331
+ keywords: [action.label],
332
+ onClick: () => executeAction(action.id),
333
+ })
334
+ }
335
+ }
336
+ if (commandItems.length === 0) return null
337
+ return { group: { heading: section.title, id: `cfg:${section.id}`, items: commandItems } }
338
+ }
339
+
340
+ // --- Recent source: all artifact types from getRecent() ---
341
+ if (section.source === 'recent') {
342
+ const recent = getRecent()
343
+ if (recent.length === 0) return null
344
+ let items = recent
345
+ if (section.limit) items = items.slice(0, section.limit)
346
+ return {
347
+ group: {
348
+ heading: section.title,
349
+ id: `cfg:${section.id}`,
350
+ items: items.map(entry => ({
351
+ id: `cfg:${section.id}:${entry.type}:${entry.key}`,
352
+ children: entry.label,
353
+ keywords: [entry.type, entry.key, entry.label],
354
+ onClick: () => {
355
+ trackRecent(entry.type, entry.key, entry.label)
356
+ const route = resolveRecentRoute(entry, prefix)
357
+ if (route) window.location.href = route
358
+ },
359
+ })),
360
+ },
361
+ }
362
+ }
363
+
364
+ // --- Artifact sources: canvases, prototypes, stories ---
365
+ const index = buildPrototypeIndex()
366
+ let sourceItems = []
367
+
368
+ if (section.source === 'canvases') {
369
+ for (const c of index.canvases) sourceItems.push({ name: c.name, route: `${prefix}${c.route}`, id: c.dirName, type: 'canvas' })
370
+ for (const f of index.folders) {
371
+ if (f.canvases) for (const c of f.canvases) sourceItems.push({ name: c.name, route: `${prefix}${c.route}`, id: c.dirName, type: 'canvas' })
372
+ }
373
+ } else if (section.source === 'prototypes') {
374
+ for (const p of index.prototypes) sourceItems.push({ name: p.name, route: `${prefix}/${p.dirName}`, id: p.dirName, type: 'prototype' })
375
+ for (const f of index.folders) {
376
+ for (const p of f.prototypes) sourceItems.push({ name: p.name, route: `${prefix}/${p.dirName}`, id: p.dirName, type: 'prototype' })
377
+ }
378
+ } else if (section.source === 'stories') {
379
+ for (const name of listStories()) {
380
+ const data = getStoryData(name)
381
+ const route = data?._route || `/components/${name}`
382
+ sourceItems.push({ name, route: `${prefix}${route}`, id: name, type: 'story' })
383
+ }
384
+ }
385
+
386
+ if (sourceItems.length === 0) return null
387
+
388
+ if (section.order === 'recent') {
389
+ const recent = getRecent()
390
+ const recentKeys = recent.map(r => r.key)
391
+ sourceItems.sort((a, b) => {
392
+ const ai = recentKeys.indexOf(a.id)
393
+ const bi = recentKeys.indexOf(b.id)
394
+ if (ai === -1 && bi === -1) return 0
395
+ if (ai === -1) return 1
396
+ if (bi === -1) return -1
397
+ return ai - bi
398
+ })
399
+ } else if (section.order === 'alphabetical') {
400
+ sourceItems.sort((a, b) => a.name.localeCompare(b.name))
401
+ }
402
+
403
+ if (section.limit) sourceItems = sourceItems.slice(0, section.limit)
404
+
405
+ return {
406
+ group: {
407
+ heading: section.title,
408
+ id: `cfg:${section.id}`,
409
+ items: sourceItems.map(item => ({
410
+ id: `cfg:${section.id}:${item.id}`,
411
+ children: item.name,
412
+ keywords: [item.name, item.id, item.type],
413
+ onClick: () => {
414
+ trackRecent(item.type, item.id, item.name)
415
+ window.location.href = item.route
416
+ },
417
+ })),
418
+ },
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Build a section from toolbar.config.json tools.
424
+ * If toolIds is provided, only include those tools in that order (with optional custom labels).
425
+ * Otherwise include all command-list tools.
426
+ *
427
+ * toolIds format: ["theme", "flows"] or [{ id: "theme", label: "Change theme" }]
428
+ */
429
+ function buildToolsSection(section, prefix, onNavigateToPage) {
430
+ const toolbarConfig = getToolbarConfig()
431
+ const tools = toolbarConfig?.tools || {}
432
+ const mode = getCurrentMode() || 'default'
433
+ const actions = getActionsForMode(mode)
434
+ const basePath = prefix || '/'
435
+
436
+ let entries = []
437
+
438
+ if (section.toolIds && section.toolIds.length > 0) {
439
+ for (const entry of section.toolIds) {
440
+ const toolId = typeof entry === 'string' ? entry : entry.id
441
+ const customLabel = typeof entry === 'object' ? entry.label : null
442
+ const tool = tools[toolId]
443
+ if (!tool) continue
444
+ const state = getToolbarToolState(toolId)
445
+ if (state === 'disabled' || state === 'hidden') continue
446
+ if (isHiddenInPalette(tool, basePath)) continue
447
+ entries.push({ toolId, tool, label: customLabel || tool.label || toolId })
448
+ }
449
+ } else {
450
+ for (const [toolId, tool] of Object.entries(tools)) {
451
+ if (tool.surface !== 'command-list') continue
452
+ const state = getToolbarToolState(toolId)
453
+ if (state === 'disabled' || state === 'hidden') continue
454
+ if (isHiddenInPalette(tool, basePath)) continue
455
+ entries.push({ toolId, tool, label: tool.label || toolId })
456
+ }
457
+ }
458
+
459
+ if (entries.length === 0) return null
460
+
461
+ const items = []
462
+ const subPages = []
463
+
464
+ for (const { toolId, tool, label } of entries) {
465
+ // Inline actions
466
+ if (tool.inlineAction === 'toggle-chrome') {
467
+ items.push({
468
+ id: `cfg:${section.id}:${toolId}`,
469
+ children: label,
470
+ keywords: [label, toolId, 'hide', 'show', 'toolbar'].filter(Boolean),
471
+ showType: false,
472
+ onClick: () => {
473
+ document.documentElement.classList.toggle('storyboard-chrome-hidden')
474
+ },
475
+ })
476
+ continue
477
+ }
478
+
479
+ if (tool.render === 'link' && tool.url) {
480
+ items.push({
481
+ id: `cfg:${section.id}:${toolId}`,
482
+ children: label,
483
+ keywords: [label, toolId].filter(Boolean),
484
+ onClick: () => {
485
+ const url = tool.url.startsWith('/') ? prefix + tool.url : tool.url
486
+ window.location.href = url
487
+ },
488
+ })
489
+ continue
490
+ }
491
+
492
+ if (tool.render === 'submenu' || tool.render === 'menu') {
493
+ const action = actions.find(a => a.toolKey === toolId)
494
+ if (action?.type === 'submenu') {
495
+ const children = getActionChildren(action.id)
496
+ if (children.length > 0) {
497
+ const pageId = `tool:${toolId}`
498
+ subPages.push({
499
+ id: pageId,
500
+ label,
501
+ title: label,
502
+ keywords: [label, toolId].filter(Boolean),
503
+ options: children.map(child => ({
504
+ label: child.label,
505
+ execute: child.execute,
506
+ })),
507
+ })
508
+ items.push({
509
+ id: `cfg:${section.id}:${toolId}`,
510
+ children: label,
511
+ keywords: [label, toolId].filter(Boolean),
512
+ onClick: () => onNavigateToPage?.(pageId),
513
+ closeOnSelect: false,
514
+ showType: false,
515
+ })
516
+ continue
517
+ }
518
+ }
519
+
520
+ // Declarative options from toolbar.config.json (e.g. theme options)
521
+ if (tool.options && tool.options.length > 0) {
522
+ const pageId = `tool:${toolId}`
523
+ const handlerId = tool.handler || `core:${toolId}`
524
+ subPages.push({
525
+ id: pageId,
526
+ label,
527
+ title: label,
528
+ keywords: [label, toolId].filter(Boolean),
529
+ options: tool.options.map(opt => ({
530
+ label: opt.label,
531
+ // Lazy-execute via the handler's action system
532
+ toolHandler: handlerId,
533
+ value: opt.value,
534
+ })),
535
+ })
536
+ items.push({
537
+ id: `cfg:${section.id}:${toolId}`,
538
+ children: label,
539
+ keywords: [label, toolId].filter(Boolean),
540
+ onClick: () => onNavigateToPage?.(pageId),
541
+ closeOnSelect: false,
542
+ showType: false,
543
+ })
544
+ continue
545
+ }
546
+
547
+ // Menu tool without sub-items or options — click toolbar button
548
+ const ariaLabel = tool.ariaLabel || tool.label || toolId
549
+ items.push({
550
+ id: `cfg:${section.id}:${toolId}`,
551
+ children: label,
552
+ keywords: [label, toolId].filter(Boolean),
553
+ showType: false,
554
+ onClick: () => {
555
+ setTimeout(() => {
556
+ const btn = document.querySelector(`[aria-label="${ariaLabel}"]`)
557
+ if (btn) btn.click()
558
+ }, 100)
559
+ },
560
+ })
561
+ continue
562
+ }
563
+
564
+ if (tool.render === 'sidepanel' && tool.sidepanel) {
565
+ const action = actions.find(a => a.toolKey === toolId)
566
+ items.push({
567
+ id: `cfg:${section.id}:${toolId}`,
568
+ children: label,
569
+ keywords: [label, toolId].filter(Boolean),
570
+ onClick: () => { if (action) executeAction(action.id) },
571
+ })
572
+ continue
573
+ }
574
+
575
+ items.push({
576
+ id: `cfg:${section.id}:${toolId}`,
577
+ children: label,
578
+ keywords: [label, toolId].filter(Boolean),
579
+ onClick: () => executeAction(toolId),
580
+ })
581
+ }
582
+
583
+ return {
584
+ group: {
585
+ heading: section.title,
586
+ id: `cfg:${section.id}`,
587
+ items,
588
+ },
589
+ subPages,
590
+ usedToolIds: entries.map(e => e.toolId),
591
+ }
592
+ }
593
+
594
+ function resolveRecentRoute(entry, prefix) {
595
+ switch (entry.type) {
596
+ case 'prototype':
597
+ return `${prefix}/${entry.key}`
598
+ case 'canvas':
599
+ return `${prefix}/canvas/${entry.key}`
600
+ case 'story': {
601
+ const data = getStoryData(entry.key)
602
+ const route = data?._route || `/components/${entry.key}`
603
+ return `${prefix}${route}`
604
+ }
605
+ default:
606
+ return null
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Build a map of author → artifacts from the prototype index.
612
+ * Returns { authorIndex: Map<lowercase-author, { author, items[] }> }
613
+ */
614
+ function buildAuthorIndex(prefix) {
615
+ const index = buildPrototypeIndex()
616
+ const authorMap = new Map()
617
+
618
+ function addItem(author, item) {
619
+ const key = author.toLowerCase()
620
+ if (!authorMap.has(key)) authorMap.set(key, { author, items: [] })
621
+ authorMap.get(key).items.push(item)
622
+ }
623
+
624
+ function processAuthors(authors, item) {
625
+ if (!authors) return
626
+ const list = Array.isArray(authors) ? authors : [authors]
627
+ for (const a of list) if (a) addItem(a, item)
628
+ }
629
+
630
+ for (const p of index.prototypes) {
631
+ processAuthors(p.author, { name: p.name, route: `${prefix}/${p.dirName}`, id: p.dirName, type: 'Prototype' })
632
+ }
633
+ for (const f of index.folders) {
634
+ for (const p of f.prototypes) {
635
+ processAuthors(p.author, { name: p.name, route: `${prefix}/${p.dirName}`, id: p.dirName, type: 'Prototype' })
636
+ }
637
+ if (f.canvases) {
638
+ for (const c of f.canvases) {
639
+ processAuthors(c.author, { name: c.name, route: `${prefix}${c.route}`, id: c.dirName, type: 'Canvas' })
640
+ }
641
+ }
642
+ }
643
+ for (const c of index.canvases) {
644
+ processAuthors(c.author, { name: c.name, route: `${prefix}${c.route}`, id: c.dirName, type: 'Canvas' })
645
+ }
646
+
647
+ return authorMap
648
+ }
649
+
650
+ /**
651
+ * Build the JSON structure for react-cmdk from all data providers.
652
+ * Entirely config-driven — all sections come from commandPalette.sections.
653
+ */
654
+ function buildPaletteItems(basePath, onCreateAction, onNavigateToPage) {
655
+ const base = (basePath || '/').replace(/\/+$/, '')
656
+ const prefix = base === '/' ? '' : base
657
+
658
+ const { groups, toolMenus } = buildConfigSections(prefix, onNavigateToPage, onCreateAction)
659
+ const authorIndex = buildAuthorIndex(prefix)
660
+
661
+ return { groups, toolMenus, authorIndex }
662
+ }
663
+
664
+ /**
665
+ * StoryboardCommandPalette — React command palette using react-cmdk.
666
+ * Mounted at app root, listens for custom events from Svelte CoreUIBar.
667
+ */
668
+ export default function StoryboardCommandPalette({ basePath }) {
669
+ const [open, setOpen] = useState(false)
670
+ const [search, setSearch] = useState('')
671
+ const [items, setItems] = useState([])
672
+ const [toolMenus, setToolMenus] = useState([])
673
+ const [authorIndex, setAuthorIndex] = useState(new Map())
674
+ const [activePage, setActivePage] = useState('root')
675
+ const [createType, setCreateType] = useState(null)
676
+
677
+ function handleCreateAction(type) {
678
+ setOpen(false)
679
+ requestAnimationFrame(() => setCreateType(type))
680
+ }
681
+
682
+ function handleNavigateToPage(pageId) {
683
+ setSearch('')
684
+ setActivePage(pageId)
685
+ }
686
+
687
+ // Listen for Cmd+K directly
688
+ // The Svelte CoreUIBar also handles Cmd+K by dispatching
689
+ // 'storyboard:toggle-palette'. We use rAF to detect if Svelte
690
+ // already fired the toggle event and skip to avoid double-toggle.
691
+ useEffect(() => {
692
+ let toggledByEvent = false
693
+
694
+ function handleToggleEvent() {
695
+ toggledByEvent = true
696
+ }
697
+
698
+ function handleKeyDown(e) {
699
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
700
+ e.preventDefault()
701
+ toggledByEvent = false
702
+ requestAnimationFrame(() => {
703
+ if (toggledByEvent) return
704
+ const built = buildPaletteItems(basePath, handleCreateAction, handleNavigateToPage)
705
+ setItems(built.groups)
706
+ setToolMenus(built.toolMenus)
707
+ setAuthorIndex(built.authorIndex)
708
+ setSearch('')
709
+ setActivePage('root')
710
+ setOpen(prev => !prev)
711
+ })
712
+ }
713
+ }
714
+
715
+ document.addEventListener('storyboard:toggle-palette', handleToggleEvent)
716
+ document.addEventListener('keydown', handleKeyDown)
717
+ return () => {
718
+ document.removeEventListener('storyboard:toggle-palette', handleToggleEvent)
719
+ document.removeEventListener('keydown', handleKeyDown)
720
+ }
721
+ }, [basePath])
722
+
723
+ // Listen for toggle events from Svelte CoreUIBar
724
+ useEffect(() => {
725
+ function handleToggle() {
726
+ setOpen(prev => {
727
+ if (!prev) {
728
+ // Use setTimeout to set items after open state is committed
729
+ setTimeout(() => {
730
+ const built = buildPaletteItems(basePath, handleCreateAction, handleNavigateToPage)
731
+ setItems(built.groups)
732
+ setToolMenus(built.toolMenus)
733
+ setAuthorIndex(built.authorIndex)
734
+ setSearch('')
735
+ setActivePage('root')
736
+ }, 0)
737
+ }
738
+ return !prev
739
+ })
740
+ }
741
+
742
+ function handleOpen() {
743
+ const built = buildPaletteItems(basePath, handleCreateAction, handleNavigateToPage)
744
+ setItems(built.groups)
745
+ setToolMenus(built.toolMenus)
746
+ setAuthorIndex(built.authorIndex)
747
+ setSearch('')
748
+ setActivePage('root')
749
+ setOpen(true)
750
+ }
751
+
752
+ document.addEventListener('storyboard:toggle-palette', handleToggle)
753
+ document.addEventListener('storyboard:open-palette', handleOpen)
754
+ return () => {
755
+ document.removeEventListener('storyboard:toggle-palette', handleToggle)
756
+ document.removeEventListener('storyboard:open-palette', handleOpen)
757
+ }
758
+ }, [basePath])
759
+
760
+ const handleChangeOpen = useCallback((value) => {
761
+ if (!value) {
762
+ setOpen(false)
763
+ setActivePage('root')
764
+ }
765
+ }, [])
766
+
767
+ // Flatten sub-page options into searchable groups so they appear in root search
768
+ const subPageGroups = useMemo(() => {
769
+ return toolMenus.map(menu => ({
770
+ heading: menu.label || menu.title || menu.id,
771
+ id: `subpage:${menu.id}`,
772
+ items: (menu.options || []).map((opt, i) => ({
773
+ id: `subpage:${menu.id}:${i}`,
774
+ children: opt.label,
775
+ keywords: [opt.label, menu.label || menu.id],
776
+ showType: false,
777
+ onClick: () => {
778
+ if (opt.execute) {
779
+ opt.execute()
780
+ } else if (opt.toolHandler === 'core:theme' && opt.value) {
781
+ setTheme(opt.value)
782
+ } else if (opt.action) {
783
+ executeAction(opt.action, opt.value)
784
+ }
785
+ setOpen(false)
786
+ setActivePage('root')
787
+ },
788
+ })),
789
+ })).filter(g => g.items.length > 0)
790
+ }, [toolMenus])
791
+
792
+ const filteredItems = useMemo(() => {
793
+ const base = filterItems(items, search)
794
+ if (!search) return base
795
+ const matchingSub = filterItems(subPageGroups, search)
796
+ const result = [...base, ...matchingSub]
797
+
798
+ // Author search: match usernames against author index
799
+ const q = search.toLowerCase()
800
+ for (const [key, { author, items: authorItems }] of authorIndex) {
801
+ if (!key.includes(q)) continue
802
+ // Avoid duplicates with already-shown artifact items
803
+ const shownIds = new Set(result.flatMap(g => g.items.map(i => i.id)))
804
+ const uniqueItems = authorItems.filter(item => !shownIds.has(`author:${item.id}`))
805
+ if (uniqueItems.length === 0) continue
806
+ result.push({
807
+ heading: `Artifacts by @${author}`,
808
+ id: `author:${key}`,
809
+ items: uniqueItems.map(item => ({
810
+ id: `author:${item.id}`,
811
+ children: (
812
+ <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}>
813
+ <span>{item.name}</span>
814
+ <span style={{ fontSize: '12px', color: 'var(--fgColor-muted, #999)' }}>{item.type}</span>
815
+ </span>
816
+ ),
817
+ keywords: [item.name, item.id, item.type, author, `@${author}`],
818
+ showType: false,
819
+ onClick: () => {
820
+ trackRecent(item.type.toLowerCase(), item.id, item.name)
821
+ window.location.href = item.route
822
+ },
823
+ })),
824
+ })
825
+ }
826
+
827
+ return result
828
+ }, [items, search, subPageGroups, authorIndex])
829
+
830
+ // Items without separators — used for keyboard navigation indexing
831
+ const navigableItems = useMemo(
832
+ () => filteredItems.filter(list => !list.id?.startsWith('cfg:sep')),
833
+ [filteredItems]
834
+ )
835
+
836
+ const handleChangeSearch = useCallback((value) => {
837
+ setSearch(value)
838
+ }, [])
839
+
840
+ return (
841
+ <>
842
+ <CommandPalette
843
+ onChangeSearch={handleChangeSearch}
844
+ onChangeOpen={handleChangeOpen}
845
+ search={search}
846
+ isOpen={open}
847
+ page={activePage}
848
+ placeholder={activePage === 'root'
849
+ ? 'Search commands, prototypes, canvases, stories...'
850
+ : `Search ${toolMenus.find(m => m.id === activePage)?.label || ''}...`
851
+ }
852
+ >
853
+ <CommandPalette.Page id="root">
854
+ {filteredItems.length ? (
855
+ filteredItems.map((list) => (
856
+ list.id?.startsWith('cfg:sep') ? (
857
+ !search && <hr key={list.id} style={{ border: 'none', borderTop: '1px solid var(--borderColor-muted, #e5e5e5)', margin: '4px 14px' }} />
858
+ ) : (
859
+ <CommandPalette.List key={list.id} heading={list.heading}>
860
+ {list.items.map(({ id, ...rest }) => (
861
+ <CommandPalette.ListItem
862
+ key={id}
863
+ index={getItemIndex(navigableItems, id)}
864
+ {...rest}
865
+ />
866
+ ))}
867
+ </CommandPalette.List>
868
+ )
869
+ ))
870
+ ) : (
871
+ <div style={{ padding: '2rem', textAlign: 'center', color: '#6b7280' }}>
872
+ No results for &ldquo;{search}&rdquo;
873
+ </div>
874
+ )}
875
+ </CommandPalette.Page>
876
+
877
+ {/* Tool-menu sub-pages */}
878
+ {toolMenus.map(menu => (
879
+ <CommandPalette.Page
880
+ key={menu.id}
881
+ id={menu.id}
882
+ onEscape={() => { setActivePage('root'); setSearch('') }}
883
+ searchPrefix={[menu.label || menu.id]}
884
+ >
885
+ <CommandPalette.List heading={menu.title || menu.label || menu.id}>
886
+ {(menu.options || []).map((opt, i) => (
887
+ <CommandPalette.ListItem
888
+ key={`${menu.id}:${i}`}
889
+ index={i}
890
+ showType={false}
891
+ onClick={() => {
892
+ if (opt.execute) {
893
+ opt.execute()
894
+ } else if (opt.toolHandler === 'core:theme' && opt.value) {
895
+ setTheme(opt.value)
896
+ } else if (opt.action) {
897
+ executeAction(opt.action, opt.value)
898
+ }
899
+ setOpen(false)
900
+ setActivePage('root')
901
+ }}
902
+ >
903
+ {opt.label}
904
+ </CommandPalette.ListItem>
905
+ ))}
906
+ </CommandPalette.List>
907
+ </CommandPalette.Page>
908
+ ))}
909
+ </CommandPalette>
910
+
911
+ <CreateDialog
912
+ type={createType}
913
+ basePath={basePath}
914
+ onClose={() => setCreateType(null)}
915
+ />
916
+ </>
917
+ )
918
+ }