@dfosco/storyboard-core 3.3.2 → 3.5.0
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.
- package/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +14899 -11508
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +1 -1
- package/scaffold/toolbar.config.json +2 -2
- package/src/CanvasCreateMenu.svelte +1 -1
- package/src/CanvasZoomControl.svelte +105 -0
- package/src/CommandMenu.svelte +87 -25
- package/src/CoreUIBar.svelte +350 -347
- package/src/CreateMenuButton.svelte +6 -2
- package/src/InspectorPanel.svelte +123 -59
- package/src/SidePanel.svelte +1 -1
- package/src/ThemeMenuButton.svelte +35 -3
- package/src/commandActions.js +14 -0
- package/src/core-ui-colors.css +30 -2
- package/src/devtools.js +7 -1
- package/src/index.js +10 -1
- package/src/inspector/fiberWalker.js +49 -6
- package/src/inspector/highlighter.js +257 -33
- package/src/lib/components/ui/button/button.svelte +1 -1
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte +1 -1
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +1 -1
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +1 -1
- package/src/lib/components/ui/trigger-button/trigger-button.svelte +31 -3
- package/src/modes.css +8 -0
- package/src/mountStoryboardCore.js +15 -1
- package/src/sidepanel.css +2 -2
- package/src/stores/themeStore.ts +66 -0
- package/src/svelte-plugin-ui/components/Viewfinder.svelte +16 -11
- package/src/toolRegistry.js +226 -0
- package/src/toolStateStore.js +180 -0
- package/src/toolStateStore.test.js +204 -0
- package/src/toolbarConfigStore.js +135 -0
- package/src/tools/handlers/canvasAddWidget.js +11 -0
- package/src/tools/handlers/canvasZoom.js +34 -0
- package/src/tools/handlers/comments.js +16 -0
- package/src/tools/handlers/create.js +39 -0
- package/src/tools/handlers/devtools.js +80 -0
- package/src/tools/handlers/docs.js +11 -0
- package/src/tools/handlers/featureFlags.js +21 -0
- package/src/tools/handlers/flows.js +62 -0
- package/src/tools/handlers/inspector.js +19 -0
- package/src/tools/handlers/theme.js +9 -0
- package/src/tools/registry.js +21 -0
- package/src/tools/surfaces/canvasToolbar.js +10 -0
- package/src/tools/surfaces/commandList.js +10 -0
- package/src/tools/surfaces/mainToolbar.js +11 -0
- package/src/tools/surfaces/registry.js +19 -0
- package/src/vite/server-plugin.js +36 -6
- package/toolbar.config.json +101 -48
package/src/CoreUIBar.svelte
CHANGED
|
@@ -10,69 +10,184 @@
|
|
|
10
10
|
-->
|
|
11
11
|
|
|
12
12
|
<script lang="ts">
|
|
13
|
-
import { onMount, onDestroy } from 'svelte'
|
|
13
|
+
import { onMount, onDestroy, untrack } from 'svelte'
|
|
14
14
|
import './core-ui-colors.css'
|
|
15
15
|
import CommandMenu from './CommandMenu.svelte'
|
|
16
16
|
import { TriggerButton } from './lib/components/ui/trigger-button/index.js'
|
|
17
17
|
import * as Tooltip from './lib/components/ui/tooltip/index.js'
|
|
18
18
|
import Icon from './svelte-plugin-ui/components/Icon.svelte'
|
|
19
19
|
import { modeState } from './svelte-plugin-ui/stores/modeStore.js'
|
|
20
|
-
import { sidePanelState, togglePanel
|
|
21
|
-
import { initCommandActions, registerCommandAction, getActionChildren, isExcludedByRoute, setRoutingBasePath } from './commandActions.js'
|
|
20
|
+
import { sidePanelState, togglePanel } from './stores/sidePanelStore.js'
|
|
21
|
+
import { initCommandActions, registerCommandAction, getActionChildren, hasChildrenProvider, isExcludedByRoute, setRoutingBasePath } from './commandActions.js'
|
|
22
22
|
import { isMenuHidden } from './uiConfig.js'
|
|
23
|
+
import { subscribeToToolbarConfig, getToolbarConfig } from './toolbarConfigStore.js'
|
|
24
|
+
import { initToolbarToolStates, getToolbarToolState, isToolbarToolLocalOnly, subscribeToToolbarToolStates } from './toolStateStore.js'
|
|
23
25
|
import defaultToolbarConfig from '../toolbar.config.json'
|
|
24
26
|
|
|
25
|
-
interface Props { basePath?: string; toolbarConfig?: any }
|
|
26
|
-
let { basePath = '/', toolbarConfig }: Props = $props()
|
|
27
|
+
interface Props { basePath?: string; toolbarConfig?: any; customHandlers?: Record<string, () => Promise<any>> }
|
|
28
|
+
let { basePath = '/', toolbarConfig, customHandlers = {} }: Props = $props()
|
|
27
29
|
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
+
// Reactive toolbar config — subscribes to the config store for prototype overrides.
|
|
31
|
+
// Falls back to the prop (for backward compat) or the bundled defaults.
|
|
32
|
+
let storeConfig = $state(getToolbarConfig())
|
|
33
|
+
let unsubConfig: (() => void) | null = null
|
|
34
|
+
|
|
35
|
+
$effect(() => {
|
|
36
|
+
unsubConfig = subscribeToToolbarConfig((cfg: any) => { storeConfig = cfg })
|
|
37
|
+
return () => { if (unsubConfig) unsubConfig() }
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
$effect(() => {
|
|
41
|
+
const unsub = subscribeToToolbarToolStates(() => { toolStateVersion++ })
|
|
42
|
+
return unsub
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// Use store config if available, otherwise fall back to prop or defaults
|
|
46
|
+
const config = $derived(
|
|
47
|
+
(storeConfig && Object.keys(storeConfig).length > 0)
|
|
48
|
+
? storeConfig
|
|
49
|
+
: (toolbarConfig || defaultToolbarConfig)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
// Re-seed tool states whenever config changes (e.g. prototype override on navigation)
|
|
53
|
+
$effect(() => {
|
|
54
|
+
const tools = config.tools || {}
|
|
55
|
+
// untrack so the synchronous _notify() → toolStateVersion++ inside
|
|
56
|
+
// initToolbarToolStates doesn't get tracked as a dependency of this effect
|
|
57
|
+
untrack(() => initToolbarToolStates(tools, { isLocalDev }))
|
|
58
|
+
})
|
|
30
59
|
|
|
31
60
|
let visible = $state(true)
|
|
32
61
|
// Hide the entire toolbar when loaded inside a prototype embed iframe
|
|
33
62
|
const isEmbed = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('_sb_embed')
|
|
34
63
|
let commandMenuOpen = $state(false)
|
|
35
|
-
let
|
|
64
|
+
let toolComponents: Record<string, any> = $state({})
|
|
65
|
+
let toolData: Record<string, any> = $state({})
|
|
36
66
|
let navVersion = $state(0)
|
|
37
67
|
let origPushState: typeof history.pushState
|
|
38
68
|
let origReplaceState: typeof history.replaceState
|
|
39
69
|
let bumpNav: () => void
|
|
40
|
-
let CreateMenuButton: any = $state(null)
|
|
41
|
-
let createMenuFeatures: any[] = $state([])
|
|
42
|
-
let CommentsMenuButton: any = $state(null)
|
|
43
|
-
let ThemeMenuButton: any = $state(null)
|
|
44
|
-
let commentsEnabled = $state(false)
|
|
45
70
|
let SidePanel: any = $state(null)
|
|
46
71
|
let toolbarEl: HTMLElement | null = $state(null)
|
|
47
|
-
let CanvasCreateMenu: any = $state(null)
|
|
48
72
|
let canvasActive = $state(false)
|
|
49
73
|
let activeCanvasName = $state('')
|
|
50
74
|
let canvasZoom = $state(100)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const ZOOM_STEP = 10
|
|
54
|
-
const ZOOM_MIN = 25
|
|
55
|
-
const ZOOM_MAX = 200
|
|
75
|
+
let toolStateVersion = $state(0)
|
|
56
76
|
|
|
57
77
|
// Roving tabindex: only one button in the toolbar is tabbable at a time
|
|
58
78
|
let activeToolbarIndex = $state(-1)
|
|
59
79
|
|
|
60
80
|
const isLocalDev = typeof window !== 'undefined' && (window as any).__SB_LOCAL_DEV__ === true
|
|
61
81
|
|
|
62
|
-
|
|
63
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Resolve a handler reference to a module loader function.
|
|
84
|
+
* Format: "core:name" → core registry, "custom:name" → client handlers
|
|
85
|
+
*/
|
|
86
|
+
function resolveHandlerModule(
|
|
87
|
+
ref: string,
|
|
88
|
+
coreModules: Record<string, Function>,
|
|
89
|
+
custom: Record<string, () => Promise<any>>
|
|
90
|
+
): Function | null {
|
|
91
|
+
const colonIdx = ref.indexOf(':')
|
|
92
|
+
if (colonIdx === -1) return coreModules[ref] || null
|
|
93
|
+
const prefix = ref.slice(0, colonIdx)
|
|
94
|
+
const name = ref.slice(colonIdx + 1)
|
|
95
|
+
if (prefix === 'core') return coreModules[name] || null
|
|
96
|
+
if (prefix === 'custom') return custom[name] || null
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Resolve tools → menus compatibility layer.
|
|
101
|
+
// New config uses `tools` (flat map with toolbar target); legacy uses `menus`.
|
|
102
|
+
// When `tools` exists, derive the menus-compatible structures from it.
|
|
103
|
+
function resolveMenus(cfg: any): Record<string, any> {
|
|
104
|
+
if (cfg.tools) {
|
|
105
|
+
const result: Record<string, any> = {}
|
|
106
|
+
for (const [key, tool] of Object.entries(cfg.tools as Record<string, any>)) {
|
|
107
|
+
if (tool.surface === 'command-list' || tool.surface === 'canvas-toolbar') continue
|
|
108
|
+
// Map new render/toolbar fields to legacy menu fields for rendering compat
|
|
109
|
+
const menu: any = { ...tool }
|
|
110
|
+
if (tool.render === 'menu' && tool.handler) {
|
|
111
|
+
menu.action = tool.handler
|
|
112
|
+
}
|
|
113
|
+
result[key] = { ...menu, _toolId: key }
|
|
114
|
+
}
|
|
115
|
+
return result
|
|
116
|
+
}
|
|
117
|
+
return cfg.menus || {}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resolveCommandConfig(cfg: any): any {
|
|
121
|
+
if (cfg.command) {
|
|
122
|
+
// Build command menu config from new schema
|
|
123
|
+
const actions: any[] = []
|
|
124
|
+
actions.push({ type: 'header', label: 'Command Menu' })
|
|
125
|
+
|
|
126
|
+
// Add command-list tools as actions
|
|
127
|
+
if (cfg.tools) {
|
|
128
|
+
for (const [toolKey, tool] of Object.entries(cfg.tools as Record<string, any>)) {
|
|
129
|
+
if (tool.surface !== 'command-list') continue
|
|
130
|
+
if (tool.render === 'separator') {
|
|
131
|
+
actions.push({ type: 'separator' })
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
actions.push({
|
|
135
|
+
id: tool.handler || `core/${tool.label?.toLowerCase().replace(/\s+/g, '-')}`,
|
|
136
|
+
label: tool.label || tool.ariaLabel,
|
|
137
|
+
type: tool.render || 'default',
|
|
138
|
+
url: tool.url || null,
|
|
139
|
+
modes: tool.modes || ['*'],
|
|
140
|
+
toolKey,
|
|
141
|
+
localOnly: tool.localOnly || false,
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
ariaLabel: 'Command Menu',
|
|
148
|
+
trigger: 'command',
|
|
149
|
+
icon: cfg.command.icon,
|
|
150
|
+
meta: cfg.command.meta,
|
|
151
|
+
default: true,
|
|
152
|
+
modes: ['*'],
|
|
153
|
+
actions,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return cfg.menus?.command || null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const commandMenuConfig = $derived(
|
|
160
|
+
isMenuHidden('command') ? null : resolveCommandConfig(config)
|
|
161
|
+
)
|
|
162
|
+
const shortcutsConfig = $derived({
|
|
163
|
+
...((config as any).shortcuts || {}),
|
|
164
|
+
...(config.command?.shortcut ? { openCommandMenu: config.command.shortcut } : {}),
|
|
165
|
+
})
|
|
64
166
|
|
|
65
167
|
// Build ordered menu list from JSON key order (excluding command, which is always rightmost)
|
|
66
|
-
const allMenus = $derived((config
|
|
168
|
+
const allMenus = $derived(resolveMenus(config))
|
|
67
169
|
const orderedMenus = $derived(Object.entries(allMenus)
|
|
68
170
|
.filter(([key]) => key !== 'command')
|
|
69
171
|
.filter(([key]) => !isMenuHidden(key))
|
|
70
|
-
.filter(([
|
|
172
|
+
.filter(([key]) => {
|
|
173
|
+
void toolStateVersion
|
|
174
|
+
return getToolbarToolState(key) !== 'disabled'
|
|
175
|
+
})
|
|
71
176
|
.map(([key, menu]) => ({ key, ...menu })))
|
|
72
177
|
|
|
73
178
|
// Discover menus with sidepanel property
|
|
74
179
|
const sidepanelMenus = $derived(orderedMenus.filter(menu => menu.sidepanel))
|
|
75
180
|
|
|
181
|
+
// Canvas toolbar tools — only visible when a canvas page is active
|
|
182
|
+
const canvasMenus = $derived(
|
|
183
|
+
config.tools
|
|
184
|
+
? Object.entries(config.tools as Record<string, any>)
|
|
185
|
+
.filter(([, tool]) => tool.surface === 'canvas-toolbar')
|
|
186
|
+
.filter(([, tool]) => !tool.localOnly || isLocalDev)
|
|
187
|
+
.map(([key, tool]) => ({ key, ...tool }))
|
|
188
|
+
: []
|
|
189
|
+
)
|
|
190
|
+
|
|
76
191
|
function menuVisibleInMode(menu: any, mode: string): boolean {
|
|
77
192
|
if (!menu?.modes) return false
|
|
78
193
|
if (isExcludedByRoute(menu)) return false
|
|
@@ -84,21 +199,51 @@
|
|
|
84
199
|
orderedMenus
|
|
85
200
|
.filter(menu => {
|
|
86
201
|
void navVersion
|
|
202
|
+
void toolStateVersion
|
|
203
|
+
const toolState = getToolbarToolState(menu.key)
|
|
204
|
+
if (toolState === 'hidden') return false
|
|
205
|
+
if (menu.render === 'separator') return true
|
|
87
206
|
if (!menuVisibleInMode(menu, $modeState.mode)) return false
|
|
88
|
-
if (menu.
|
|
89
|
-
|
|
90
|
-
if (menu.key
|
|
91
|
-
|
|
207
|
+
if (menu.render === 'sidepanel') return true
|
|
208
|
+
// For tools with components, check if loaded
|
|
209
|
+
if (!toolComponents[menu.key]) return false
|
|
210
|
+
// For action-menu tools (those with a getChildren handler), hide when empty.
|
|
211
|
+
// Custom-component menus (e.g. ThemeMenuButton) render their own content.
|
|
212
|
+
const actionId = menu.handler || menu.action
|
|
213
|
+
if (actionId && menu.render === 'menu' && hasChildrenProvider(actionId)) {
|
|
214
|
+
return getActionChildren(actionId).length > 0
|
|
215
|
+
}
|
|
92
216
|
return true
|
|
93
217
|
})
|
|
94
218
|
.reverse()
|
|
95
219
|
)
|
|
96
220
|
|
|
221
|
+
// Clean separators: remove leading, trailing, and consecutive
|
|
222
|
+
const cleanedMenus = $derived.by(() => {
|
|
223
|
+
const result: typeof visibleMenus = []
|
|
224
|
+
for (const item of visibleMenus) {
|
|
225
|
+
if (item.render === 'separator') {
|
|
226
|
+
// Skip if first item or previous was also a separator
|
|
227
|
+
if (result.length === 0 || result[result.length - 1].render === 'separator') continue
|
|
228
|
+
result.push(item)
|
|
229
|
+
} else {
|
|
230
|
+
result.push(item)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// Remove trailing separator
|
|
234
|
+
while (result.length > 0 && result[result.length - 1].render === 'separator') result.pop()
|
|
235
|
+
return result
|
|
236
|
+
})
|
|
237
|
+
|
|
97
238
|
// Total toolbar item count (visible menus + command menu if present)
|
|
98
|
-
const toolbarItemCount = $derived(
|
|
239
|
+
const toolbarItemCount = $derived(
|
|
240
|
+
cleanedMenus.filter(m => m.render !== 'separator').length + (commandMenuConfig ? 1 : 0)
|
|
241
|
+
)
|
|
99
242
|
|
|
100
243
|
// Command menu is always the last item (rightmost)
|
|
101
|
-
const commandMenuIndex = $derived(
|
|
244
|
+
const commandMenuIndex = $derived(
|
|
245
|
+
commandMenuConfig ? cleanedMenus.filter(m => m.render !== 'separator').length : -1
|
|
246
|
+
)
|
|
102
247
|
|
|
103
248
|
function getTabindex(index: number): number {
|
|
104
249
|
if (activeToolbarIndex < 0) {
|
|
@@ -174,20 +319,19 @@
|
|
|
174
319
|
e.preventDefault()
|
|
175
320
|
commandMenuOpen = !commandMenuOpen
|
|
176
321
|
}
|
|
177
|
-
// Cmd+D
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
if (
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
togglePanel('inspector')
|
|
322
|
+
// Config-driven tool shortcuts (e.g. Cmd+D for docs, Cmd+I for inspector)
|
|
323
|
+
for (const menu of cleanedMenus) {
|
|
324
|
+
const shortcut = menu.shortcut
|
|
325
|
+
if (!shortcut?.key) continue
|
|
326
|
+
if (e.key === shortcut.key && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
|
|
327
|
+
const toolState = getToolbarToolState(menu.key)
|
|
328
|
+
// Inactive and disabled tools don't respond to shortcuts
|
|
329
|
+
if (toolState === 'inactive' || toolState === 'disabled') break
|
|
330
|
+
if (menu.sidepanel) {
|
|
331
|
+
e.preventDefault()
|
|
332
|
+
togglePanel(menu.sidepanel)
|
|
333
|
+
}
|
|
334
|
+
break
|
|
191
335
|
}
|
|
192
336
|
}
|
|
193
337
|
}
|
|
@@ -196,210 +340,102 @@
|
|
|
196
340
|
window.addEventListener('keydown', handleKeydown)
|
|
197
341
|
setRoutingBasePath(basePath)
|
|
198
342
|
|
|
199
|
-
// Re-evaluate action menus on SPA navigation
|
|
200
|
-
|
|
343
|
+
// Re-evaluate action menus and prototype toolbar config on SPA navigation
|
|
344
|
+
const { getPrototypeMetadata } = await import('./loader.js')
|
|
345
|
+
const { setPrototypeToolbarConfig, clearPrototypeToolbarConfig } = await import('./toolbarConfigStore.js')
|
|
346
|
+
|
|
347
|
+
function syncPrototypeToolbar() {
|
|
348
|
+
let pathname = window.location.pathname
|
|
349
|
+
const base = basePath.replace(/\/+$/, '')
|
|
350
|
+
if (base && pathname.startsWith(base)) pathname = pathname.slice(base.length)
|
|
351
|
+
const firstSegment = pathname.replace(/^\//, '').split('/')[0] || null
|
|
352
|
+
if (firstSegment) {
|
|
353
|
+
const meta = getPrototypeMetadata(firstSegment)
|
|
354
|
+
if (meta?.toolbarConfig) {
|
|
355
|
+
setPrototypeToolbarConfig(meta.toolbarConfig)
|
|
356
|
+
} else {
|
|
357
|
+
clearPrototypeToolbarConfig()
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
clearPrototypeToolbarConfig()
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
bumpNav = () => { navVersion++; syncPrototypeToolbar() }
|
|
201
365
|
window.addEventListener('popstate', bumpNav)
|
|
202
366
|
origPushState = history.pushState.bind(history)
|
|
203
367
|
history.pushState = (...args: any[]) => { origPushState(...args); bumpNav() }
|
|
204
368
|
origReplaceState = history.replaceState.bind(history)
|
|
205
369
|
history.replaceState = (...args: any[]) => { origReplaceState(...args); bumpNav() }
|
|
206
370
|
|
|
371
|
+
// Apply prototype toolbar config for the initial route
|
|
372
|
+
syncPrototypeToolbar()
|
|
373
|
+
|
|
207
374
|
// Seed the command action registry from config
|
|
208
375
|
if (commandMenuConfig) {
|
|
209
376
|
initCommandActions(commandMenuConfig)
|
|
210
377
|
}
|
|
211
378
|
|
|
212
|
-
// Register core action handlers
|
|
213
|
-
registerCommandAction('core/viewfinder', () => {
|
|
214
|
-
window.location.href = basePath + 'viewfinder'
|
|
215
|
-
})
|
|
216
|
-
|
|
217
379
|
// Register sidepanel toggle actions
|
|
218
380
|
for (const menu of sidepanelMenus) {
|
|
219
|
-
registerCommandAction(`core
|
|
381
|
+
registerCommandAction(`core:${menu.key}`, () => {
|
|
220
382
|
togglePanel(menu.sidepanel)
|
|
221
383
|
})
|
|
222
384
|
}
|
|
223
385
|
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
openPanel('inspector')
|
|
229
|
-
}
|
|
230
|
-
} catch {}
|
|
386
|
+
// Load all tool modules from the registry
|
|
387
|
+
const { coreHandlers } = await import('./tools/registry.js')
|
|
388
|
+
const toolConfigs = config.tools || {}
|
|
389
|
+
const ctx = { basePath, showFlowInfoDialog }
|
|
231
390
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
let hm: any = null
|
|
236
|
-
let commentsAuth: any = null
|
|
237
|
-
try { loader = await import('./loader.js') } catch {}
|
|
238
|
-
try { hm = await import('./hideMode.js') } catch {}
|
|
239
|
-
try { commentsAuth = await import('./comments/auth.js') } catch {}
|
|
240
|
-
|
|
241
|
-
registerCommandAction('core/devtools', {
|
|
242
|
-
getChildren: () => {
|
|
243
|
-
const children: any[] = []
|
|
244
|
-
if (loader) {
|
|
245
|
-
children.push({
|
|
246
|
-
id: 'core/show-flow-info',
|
|
247
|
-
label: 'Show flow info',
|
|
248
|
-
type: 'default',
|
|
249
|
-
execute: () => {
|
|
250
|
-
const p = new URLSearchParams(window.location.search)
|
|
251
|
-
const name = p.get('flow') || p.get('scene') || 'default'
|
|
252
|
-
try {
|
|
253
|
-
const data = loader.loadFlow(name)
|
|
254
|
-
showFlowInfoDialog(name, JSON.stringify(data, null, 2), null)
|
|
255
|
-
} catch (e: any) {
|
|
256
|
-
showFlowInfoDialog(name, '', e.message)
|
|
257
|
-
}
|
|
258
|
-
},
|
|
259
|
-
})
|
|
260
|
-
}
|
|
261
|
-
children.push({
|
|
262
|
-
id: 'core/reset-params',
|
|
263
|
-
label: 'Reset all params',
|
|
264
|
-
type: 'default',
|
|
265
|
-
execute: () => { window.location.hash = '' },
|
|
266
|
-
})
|
|
267
|
-
if (hm) {
|
|
268
|
-
children.push({
|
|
269
|
-
id: 'core/hide-mode',
|
|
270
|
-
label: 'Hide mode',
|
|
271
|
-
type: 'toggle',
|
|
272
|
-
active: hm.isHideMode(),
|
|
273
|
-
execute: () => {
|
|
274
|
-
if (hm.isHideMode()) hm.deactivateHideMode()
|
|
275
|
-
else hm.activateHideMode()
|
|
276
|
-
},
|
|
277
|
-
})
|
|
278
|
-
}
|
|
279
|
-
if (commentsAuth?.isAuthenticated()) {
|
|
280
|
-
children.push({
|
|
281
|
-
id: 'core/logout',
|
|
282
|
-
label: 'Logout (remove token)',
|
|
283
|
-
type: 'default',
|
|
284
|
-
execute: () => {
|
|
285
|
-
commentsAuth.clearToken()
|
|
286
|
-
console.log('[storyboard] Token removed')
|
|
287
|
-
},
|
|
288
|
-
})
|
|
289
|
-
}
|
|
290
|
-
return children
|
|
291
|
-
},
|
|
292
|
-
})
|
|
293
|
-
}
|
|
391
|
+
for (const [toolId, toolConfig] of Object.entries(toolConfigs as Record<string, any>)) {
|
|
392
|
+
// Skip non-tool entries (separators have no handler)
|
|
393
|
+
if (toolConfig.render === 'separator') continue
|
|
294
394
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
registerCommandAction('core/feature-flags', {
|
|
298
|
-
getChildren: () =>
|
|
299
|
-
ff.getFlagKeys().map((key: string) => ({
|
|
300
|
-
id: `flags/${key}`,
|
|
301
|
-
label: key,
|
|
302
|
-
type: 'toggle' as const,
|
|
303
|
-
active: ff.getFlag(key),
|
|
304
|
-
execute: () => ff.toggleFlag(key),
|
|
305
|
-
})),
|
|
306
|
-
})
|
|
307
|
-
} catch {}
|
|
395
|
+
// Skip disabled tools — don't load their modules at all
|
|
396
|
+
if (getToolbarToolState(toolId) === 'disabled') continue
|
|
308
397
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
registerCommandAction('core/flows', {
|
|
315
|
-
getChildren: () => {
|
|
316
|
-
let path = window.location.pathname
|
|
317
|
-
const base = basePath.replace(/\/+$/, '')
|
|
318
|
-
if (base && path.startsWith(base)) path = path.slice(base.length)
|
|
319
|
-
path = path.replace(/\/+$/, '') || '/'
|
|
320
|
-
const segments = path.split('/').filter(Boolean)
|
|
321
|
-
const proto = segments[0] || null
|
|
322
|
-
if (!proto) return []
|
|
323
|
-
|
|
324
|
-
// Detect active flow
|
|
325
|
-
const params = new URLSearchParams(window.location.search)
|
|
326
|
-
const explicit = params.get('flow') || params.get('scene')
|
|
327
|
-
let active: string
|
|
328
|
-
if (explicit) {
|
|
329
|
-
active = loader.resolveFlowName(proto, explicit)
|
|
330
|
-
} else {
|
|
331
|
-
const pageFlow = path === '/' ? 'index' : (path.split('/').pop() || 'index')
|
|
332
|
-
const scoped = loader.resolveFlowName(proto, pageFlow)
|
|
333
|
-
if (loader.flowExists(scoped)) active = scoped
|
|
334
|
-
else {
|
|
335
|
-
const protoFlow = loader.resolveFlowName(proto, proto)
|
|
336
|
-
active = loader.flowExists(protoFlow) ? protoFlow : 'default'
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
return loader.getFlowsForPrototype(proto).map((f: any) => {
|
|
341
|
-
const meta = vf.getFlowMeta(f.key)
|
|
342
|
-
return {
|
|
343
|
-
id: f.key,
|
|
344
|
-
label: meta?.title || f.name,
|
|
345
|
-
type: 'radio' as const,
|
|
346
|
-
active: f.key === active,
|
|
347
|
-
execute: () => { window.location.href = vf.resolveFlowRoute(f.key) },
|
|
348
|
-
}
|
|
349
|
-
})
|
|
350
|
-
},
|
|
351
|
-
})
|
|
352
|
-
} catch {}
|
|
398
|
+
// Resolve handler module via core:/custom: prefix
|
|
399
|
+
const handlerRef = toolConfig.handler || `core:${toolId}`
|
|
400
|
+
const loadModule = resolveHandlerModule(handlerRef, coreHandlers, customHandlers)
|
|
401
|
+
if (!loadModule) continue
|
|
353
402
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
ActionMenuButton = mod.default
|
|
358
|
-
} catch {}
|
|
403
|
+
try {
|
|
404
|
+
const mod = await loadModule()
|
|
405
|
+
const toolCtx = { ...ctx, config: toolConfig }
|
|
359
406
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
407
|
+
// Run guard — skip if guard returns false
|
|
408
|
+
if (mod.guard) {
|
|
409
|
+
const ok = await mod.guard(toolCtx)
|
|
410
|
+
if (!ok) continue
|
|
411
|
+
}
|
|
365
412
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
}
|
|
374
|
-
} catch {}
|
|
413
|
+
// Run setup
|
|
414
|
+
if (mod.setup) {
|
|
415
|
+
const setupResult = await mod.setup(toolCtx)
|
|
416
|
+
if (setupResult) {
|
|
417
|
+
toolData[toolId] = setupResult
|
|
418
|
+
}
|
|
419
|
+
}
|
|
375
420
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
const feat = (features as Record<string, any>)[a.feature]
|
|
387
|
-
if (!feat || !feat.overlayId || !feat.overlay) return null
|
|
388
|
-
return {
|
|
389
|
-
name: feat.name,
|
|
390
|
-
label: a.label || feat.label,
|
|
391
|
-
overlayId: feat.overlayId,
|
|
392
|
-
overlay: feat.overlay,
|
|
393
|
-
}
|
|
394
|
-
})
|
|
395
|
-
.filter(Boolean)
|
|
421
|
+
// Register handler as command action
|
|
422
|
+
if (mod.handler) {
|
|
423
|
+
const handlerResult = await mod.handler(toolCtx)
|
|
424
|
+
const actionId = toolConfig.handler || `core:${toolId}`
|
|
425
|
+
// Store handler result in toolData for component access
|
|
426
|
+
if (handlerResult && !handlerResult.getChildren) {
|
|
427
|
+
toolData[toolId] = { ...(toolData[toolId] || {}), ...handlerResult }
|
|
428
|
+
}
|
|
429
|
+
registerCommandAction(actionId, handlerResult)
|
|
430
|
+
}
|
|
396
431
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
432
|
+
// Load component
|
|
433
|
+
if (mod.component) {
|
|
434
|
+
const component = await mod.component()
|
|
435
|
+
toolComponents[toolId] = component
|
|
400
436
|
}
|
|
401
|
-
}
|
|
402
|
-
}
|
|
437
|
+
} catch { /* tool failed to load — skip gracefully */ }
|
|
438
|
+
}
|
|
403
439
|
|
|
404
440
|
// Load side panel component
|
|
405
441
|
try {
|
|
@@ -409,12 +445,6 @@
|
|
|
409
445
|
}
|
|
410
446
|
} catch {}
|
|
411
447
|
|
|
412
|
-
// Load canvas create menu
|
|
413
|
-
try {
|
|
414
|
-
const mod = await import('./CanvasCreateMenu.svelte')
|
|
415
|
-
CanvasCreateMenu = mod.default
|
|
416
|
-
} catch {}
|
|
417
|
-
|
|
418
448
|
// Listen for canvas mount/unmount events (React↔Svelte bridge)
|
|
419
449
|
document.addEventListener('storyboard:canvas:mounted', handleCanvasMounted)
|
|
420
450
|
document.addEventListener('storyboard:canvas:unmounted', handleCanvasUnmounted)
|
|
@@ -448,20 +478,6 @@
|
|
|
448
478
|
canvasZoom = (e as CustomEvent).detail?.zoom ?? canvasZoom
|
|
449
479
|
}
|
|
450
480
|
|
|
451
|
-
function canvasZoomIn() {
|
|
452
|
-
const next = Math.min(ZOOM_MAX, canvasZoom + ZOOM_STEP)
|
|
453
|
-
document.dispatchEvent(new CustomEvent('storyboard:canvas:set-zoom', { detail: { zoom: next } }))
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
function canvasZoomOut() {
|
|
457
|
-
const next = Math.max(ZOOM_MIN, canvasZoom - ZOOM_STEP)
|
|
458
|
-
document.dispatchEvent(new CustomEvent('storyboard:canvas:set-zoom', { detail: { zoom: next } }))
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
function canvasZoomReset() {
|
|
462
|
-
document.dispatchEvent(new CustomEvent('storyboard:canvas:set-zoom', { detail: { zoom: 100 } }))
|
|
463
|
-
}
|
|
464
|
-
|
|
465
481
|
// Flow info dialog state — driven by core/show-flow-info action
|
|
466
482
|
let flowDialogOpen = $state(false)
|
|
467
483
|
let flowName = $state('default')
|
|
@@ -477,41 +493,29 @@
|
|
|
477
493
|
</script>
|
|
478
494
|
|
|
479
495
|
{#if !isEmbed}
|
|
480
|
-
{#if visible && canvasActive &&
|
|
496
|
+
{#if visible && canvasActive && canvasMenus.length > 0}
|
|
481
497
|
<div
|
|
482
498
|
class="fixed bottom-6 left-6 z-[9999] font-sans flex items-center gap-3"
|
|
483
499
|
role="toolbar"
|
|
484
500
|
aria-label="Canvas toolbar"
|
|
485
501
|
>
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
onclick={canvasZoomReset}
|
|
504
|
-
aria-label="Reset zoom to 100%"
|
|
505
|
-
title="Reset to 100%"
|
|
506
|
-
>{canvasZoom}%</button>
|
|
507
|
-
<button
|
|
508
|
-
class="canvas-zoom-btn"
|
|
509
|
-
onclick={canvasZoomIn}
|
|
510
|
-
disabled={canvasZoom >= ZOOM_MAX}
|
|
511
|
-
aria-label="Zoom in"
|
|
512
|
-
title="Zoom in"
|
|
513
|
-
>+</button>
|
|
514
|
-
</div>
|
|
502
|
+
{#each canvasMenus as canvasTool (canvasTool.key)}
|
|
503
|
+
{#if toolComponents[canvasTool.key]}
|
|
504
|
+
{@const CanvasToolComponent = toolComponents[canvasTool.key]}
|
|
505
|
+
<Tooltip.Root>
|
|
506
|
+
<Tooltip.Trigger>
|
|
507
|
+
<CanvasToolComponent
|
|
508
|
+
config={canvasTool}
|
|
509
|
+
data={toolData[canvasTool.key]}
|
|
510
|
+
canvasName={activeCanvasName}
|
|
511
|
+
zoom={canvasZoom}
|
|
512
|
+
tabindex={0}
|
|
513
|
+
/>
|
|
514
|
+
</Tooltip.Trigger>
|
|
515
|
+
<Tooltip.Content side="top">{canvasTool.ariaLabel || canvasTool.key}</Tooltip.Content>
|
|
516
|
+
</Tooltip.Root>
|
|
517
|
+
{/if}
|
|
518
|
+
{/each}
|
|
515
519
|
</div>
|
|
516
520
|
{/if}
|
|
517
521
|
<div
|
|
@@ -525,32 +529,47 @@
|
|
|
525
529
|
bind:this={toolbarEl}
|
|
526
530
|
>
|
|
527
531
|
{#if visible}
|
|
528
|
-
{#each
|
|
529
|
-
|
|
530
|
-
<
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
532
|
+
{#each cleanedMenus as menu, i (menu.key)}
|
|
533
|
+
{#if menu.render === 'separator'}
|
|
534
|
+
<div class="toolbar-separator" aria-hidden="true"></div>
|
|
535
|
+
{:else}
|
|
536
|
+
<Tooltip.Root>
|
|
537
|
+
<Tooltip.Trigger>
|
|
538
|
+
{#if menu.render === 'sidepanel'}
|
|
539
|
+
{@const toolState = getToolbarToolState(menu.key)}
|
|
540
|
+
<TriggerButton
|
|
541
|
+
active={$sidePanelState.open && $sidePanelState.activeTab === menu.sidepanel}
|
|
542
|
+
inactive={toolState === 'inactive'}
|
|
543
|
+
dimmed={toolState === 'dimmed'}
|
|
544
|
+
localOnly={isToolbarToolLocalOnly(menu.key)}
|
|
545
|
+
size="icon-xl"
|
|
546
|
+
aria-label={menu.ariaLabel || menu.key}
|
|
547
|
+
tabindex={getTabindex(i)}
|
|
548
|
+
onfocus={() => { activeToolbarIndex = i }}
|
|
549
|
+
onclick={() => togglePanel(menu.sidepanel)}
|
|
550
|
+
>
|
|
551
|
+
<Icon name={menu.icon || menu.key} size={16} {...(menu.meta || {})} />
|
|
552
|
+
</TriggerButton>
|
|
553
|
+
{:else if toolComponents[menu.key]}
|
|
554
|
+
{@const toolState = getToolbarToolState(menu.key)}
|
|
555
|
+
{@const ToolComponent = toolComponents[menu.key]}
|
|
556
|
+
<span
|
|
557
|
+
data-tool-state={toolState}
|
|
558
|
+
data-local-only={isToolbarToolLocalOnly(menu.key) || undefined}
|
|
559
|
+
class={toolState === 'inactive' ? 'tool-inactive' : toolState === 'dimmed' ? 'tool-dimmed' : ''}
|
|
560
|
+
>
|
|
561
|
+
<ToolComponent
|
|
562
|
+
config={menu}
|
|
563
|
+
data={toolData[menu.key]}
|
|
564
|
+
tabindex={getTabindex(i)}
|
|
565
|
+
localOnly={isToolbarToolLocalOnly(menu.key)}
|
|
566
|
+
/>
|
|
567
|
+
</span>
|
|
568
|
+
{/if}
|
|
569
|
+
</Tooltip.Trigger>
|
|
570
|
+
<Tooltip.Content side="top">{menu.ariaLabel || menu.key}</Tooltip.Content>
|
|
571
|
+
</Tooltip.Root>
|
|
572
|
+
{/if}
|
|
554
573
|
{/each}
|
|
555
574
|
{/if}
|
|
556
575
|
{#if commandMenuConfig}
|
|
@@ -571,68 +590,52 @@
|
|
|
571
590
|
{/if}
|
|
572
591
|
|
|
573
592
|
<style>
|
|
574
|
-
.
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
border-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
overflow: hidden;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
.canvas-zoom-btn {
|
|
584
|
-
all: unset;
|
|
585
|
-
cursor: pointer;
|
|
586
|
-
display: flex;
|
|
587
|
-
align-items: center;
|
|
588
|
-
justify-content: center;
|
|
589
|
-
width: 36px;
|
|
590
|
-
height: 32px;
|
|
591
|
-
font-size: 16px;
|
|
592
|
-
font-weight: 600;
|
|
593
|
-
color: var(--trigger-text, var(--color-slate-600));
|
|
594
|
-
transition: background 120ms;
|
|
593
|
+
.toolbar-separator {
|
|
594
|
+
width: 1px;
|
|
595
|
+
height: 20px;
|
|
596
|
+
background: var(--trigger-border, var(--color-slate-400));
|
|
597
|
+
opacity: 0.4;
|
|
598
|
+
flex-shrink: 0;
|
|
595
599
|
}
|
|
596
600
|
|
|
597
|
-
.
|
|
598
|
-
background: var(--trigger-bg-hover, var(--color-slate-300));
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
.canvas-zoom-btn:disabled {
|
|
601
|
+
.default-button-dimmed {
|
|
602
602
|
opacity: 0.3;
|
|
603
|
-
|
|
603
|
+
transition: opacity 200ms;
|
|
604
604
|
}
|
|
605
605
|
|
|
606
|
-
.
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
display: flex;
|
|
610
|
-
align-items: center;
|
|
611
|
-
justify-content: center;
|
|
612
|
-
min-width: 48px;
|
|
613
|
-
height: 32px;
|
|
614
|
-
padding: 0 4px;
|
|
615
|
-
font-size: 11px;
|
|
616
|
-
font-weight: 600;
|
|
617
|
-
font-variant-numeric: tabular-nums;
|
|
618
|
-
color: var(--trigger-text, var(--color-slate-600));
|
|
619
|
-
border-left: 1.5px solid var(--trigger-border, var(--color-slate-400));
|
|
620
|
-
border-right: 1.5px solid var(--trigger-border, var(--color-slate-400));
|
|
621
|
-
transition: background 120ms;
|
|
606
|
+
.default-button-dimmed:hover,
|
|
607
|
+
.default-button-dimmed:focus-within {
|
|
608
|
+
opacity: 1;
|
|
622
609
|
}
|
|
623
610
|
|
|
624
|
-
.
|
|
625
|
-
|
|
611
|
+
.tool-inactive {
|
|
612
|
+
opacity: 0.45;
|
|
613
|
+
pointer-events: none;
|
|
626
614
|
}
|
|
627
|
-
|
|
628
|
-
.default-button-dimmed {
|
|
615
|
+
.tool-dimmed {
|
|
629
616
|
opacity: 0.3;
|
|
630
617
|
transition: opacity 200ms;
|
|
631
618
|
}
|
|
632
|
-
|
|
633
|
-
.
|
|
634
|
-
.default-button-dimmed:focus-within {
|
|
619
|
+
.tool-dimmed:hover,
|
|
620
|
+
.tool-dimmed:focus-within {
|
|
635
621
|
opacity: 1;
|
|
636
622
|
}
|
|
623
|
+
[data-local-only] {
|
|
624
|
+
position: relative;
|
|
625
|
+
}
|
|
626
|
+
[data-local-only]::after {
|
|
627
|
+
content: '';
|
|
628
|
+
position: absolute;
|
|
629
|
+
top: -1px;
|
|
630
|
+
right: -1px;
|
|
631
|
+
width: 8px;
|
|
632
|
+
height: 8px;
|
|
633
|
+
background: hsl(137, 66%, 30%);
|
|
634
|
+
border-radius: 50%;
|
|
635
|
+
border: 2px solid var(--sc-border-color, transparent);
|
|
636
|
+
box-sizing: content-box;
|
|
637
|
+
pointer-events: none;
|
|
638
|
+
z-index: 1;
|
|
639
|
+
}
|
|
637
640
|
</style>
|
|
638
641
|
|