@dfosco/storyboard-core 3.3.1 → 3.4.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 +9 -1
- package/dist/storyboard-ui.js +14701 -11431
- 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 +352 -346
- package/src/CreateMenuButton.svelte +6 -2
- package/src/InspectorPanel.svelte +87 -37
- 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 +145 -29
- 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/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 +59 -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 +54 -5
- package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +2 -2
- package/src/workshop/features/createPrototype/server.js +10 -15
- package/toolbar.config.json +107 -48
package/src/CoreUIBar.svelte
CHANGED
|
@@ -10,66 +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
|
-
const
|
|
61
|
-
|
|
80
|
+
const isLocalDev = typeof window !== 'undefined' && (window as any).__SB_LOCAL_DEV__ === true
|
|
81
|
+
|
|
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
|
+
})
|
|
62
166
|
|
|
63
167
|
// Build ordered menu list from JSON key order (excluding command, which is always rightmost)
|
|
64
|
-
const allMenus = $derived((config
|
|
168
|
+
const allMenus = $derived(resolveMenus(config))
|
|
65
169
|
const orderedMenus = $derived(Object.entries(allMenus)
|
|
66
170
|
.filter(([key]) => key !== 'command')
|
|
67
171
|
.filter(([key]) => !isMenuHidden(key))
|
|
172
|
+
.filter(([key]) => {
|
|
173
|
+
void toolStateVersion
|
|
174
|
+
return getToolbarToolState(key) !== 'disabled'
|
|
175
|
+
})
|
|
68
176
|
.map(([key, menu]) => ({ key, ...menu })))
|
|
69
177
|
|
|
70
178
|
// Discover menus with sidepanel property
|
|
71
179
|
const sidepanelMenus = $derived(orderedMenus.filter(menu => menu.sidepanel))
|
|
72
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
|
+
|
|
73
191
|
function menuVisibleInMode(menu: any, mode: string): boolean {
|
|
74
192
|
if (!menu?.modes) return false
|
|
75
193
|
if (isExcludedByRoute(menu)) return false
|
|
@@ -81,21 +199,51 @@
|
|
|
81
199
|
orderedMenus
|
|
82
200
|
.filter(menu => {
|
|
83
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
|
|
84
206
|
if (!menuVisibleInMode(menu, $modeState.mode)) return false
|
|
85
|
-
if (menu.
|
|
86
|
-
|
|
87
|
-
if (menu.key
|
|
88
|
-
|
|
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
|
+
}
|
|
89
216
|
return true
|
|
90
217
|
})
|
|
91
218
|
.reverse()
|
|
92
219
|
)
|
|
93
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
|
+
|
|
94
238
|
// Total toolbar item count (visible menus + command menu if present)
|
|
95
|
-
const toolbarItemCount = $derived(
|
|
239
|
+
const toolbarItemCount = $derived(
|
|
240
|
+
cleanedMenus.filter(m => m.render !== 'separator').length + (commandMenuConfig ? 1 : 0)
|
|
241
|
+
)
|
|
96
242
|
|
|
97
243
|
// Command menu is always the last item (rightmost)
|
|
98
|
-
const commandMenuIndex = $derived(
|
|
244
|
+
const commandMenuIndex = $derived(
|
|
245
|
+
commandMenuConfig ? cleanedMenus.filter(m => m.render !== 'separator').length : -1
|
|
246
|
+
)
|
|
99
247
|
|
|
100
248
|
function getTabindex(index: number): number {
|
|
101
249
|
if (activeToolbarIndex < 0) {
|
|
@@ -171,20 +319,19 @@
|
|
|
171
319
|
e.preventDefault()
|
|
172
320
|
commandMenuOpen = !commandMenuOpen
|
|
173
321
|
}
|
|
174
|
-
// Cmd+D
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
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
|
|
188
335
|
}
|
|
189
336
|
}
|
|
190
337
|
}
|
|
@@ -193,210 +340,102 @@
|
|
|
193
340
|
window.addEventListener('keydown', handleKeydown)
|
|
194
341
|
setRoutingBasePath(basePath)
|
|
195
342
|
|
|
196
|
-
// Re-evaluate action menus on SPA navigation
|
|
197
|
-
|
|
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() }
|
|
198
365
|
window.addEventListener('popstate', bumpNav)
|
|
199
366
|
origPushState = history.pushState.bind(history)
|
|
200
367
|
history.pushState = (...args: any[]) => { origPushState(...args); bumpNav() }
|
|
201
368
|
origReplaceState = history.replaceState.bind(history)
|
|
202
369
|
history.replaceState = (...args: any[]) => { origReplaceState(...args); bumpNav() }
|
|
203
370
|
|
|
371
|
+
// Apply prototype toolbar config for the initial route
|
|
372
|
+
syncPrototypeToolbar()
|
|
373
|
+
|
|
204
374
|
// Seed the command action registry from config
|
|
205
375
|
if (commandMenuConfig) {
|
|
206
376
|
initCommandActions(commandMenuConfig)
|
|
207
377
|
}
|
|
208
378
|
|
|
209
|
-
// Register core action handlers
|
|
210
|
-
registerCommandAction('core/viewfinder', () => {
|
|
211
|
-
window.location.href = basePath + 'viewfinder'
|
|
212
|
-
})
|
|
213
|
-
|
|
214
379
|
// Register sidepanel toggle actions
|
|
215
380
|
for (const menu of sidepanelMenus) {
|
|
216
|
-
registerCommandAction(`core
|
|
381
|
+
registerCommandAction(`core:${menu.key}`, () => {
|
|
217
382
|
togglePanel(menu.sidepanel)
|
|
218
383
|
})
|
|
219
384
|
}
|
|
220
385
|
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
openPanel('inspector')
|
|
226
|
-
}
|
|
227
|
-
} 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 }
|
|
228
390
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
let hm: any = null
|
|
233
|
-
let commentsAuth: any = null
|
|
234
|
-
try { loader = await import('./loader.js') } catch {}
|
|
235
|
-
try { hm = await import('./hideMode.js') } catch {}
|
|
236
|
-
try { commentsAuth = await import('./comments/auth.js') } catch {}
|
|
237
|
-
|
|
238
|
-
registerCommandAction('core/devtools', {
|
|
239
|
-
getChildren: () => {
|
|
240
|
-
const children: any[] = []
|
|
241
|
-
if (loader) {
|
|
242
|
-
children.push({
|
|
243
|
-
id: 'core/show-flow-info',
|
|
244
|
-
label: 'Show flow info',
|
|
245
|
-
type: 'default',
|
|
246
|
-
execute: () => {
|
|
247
|
-
const p = new URLSearchParams(window.location.search)
|
|
248
|
-
const name = p.get('flow') || p.get('scene') || 'default'
|
|
249
|
-
try {
|
|
250
|
-
const data = loader.loadFlow(name)
|
|
251
|
-
showFlowInfoDialog(name, JSON.stringify(data, null, 2), null)
|
|
252
|
-
} catch (e: any) {
|
|
253
|
-
showFlowInfoDialog(name, '', e.message)
|
|
254
|
-
}
|
|
255
|
-
},
|
|
256
|
-
})
|
|
257
|
-
}
|
|
258
|
-
children.push({
|
|
259
|
-
id: 'core/reset-params',
|
|
260
|
-
label: 'Reset all params',
|
|
261
|
-
type: 'default',
|
|
262
|
-
execute: () => { window.location.hash = '' },
|
|
263
|
-
})
|
|
264
|
-
if (hm) {
|
|
265
|
-
children.push({
|
|
266
|
-
id: 'core/hide-mode',
|
|
267
|
-
label: 'Hide mode',
|
|
268
|
-
type: 'toggle',
|
|
269
|
-
active: hm.isHideMode(),
|
|
270
|
-
execute: () => {
|
|
271
|
-
if (hm.isHideMode()) hm.deactivateHideMode()
|
|
272
|
-
else hm.activateHideMode()
|
|
273
|
-
},
|
|
274
|
-
})
|
|
275
|
-
}
|
|
276
|
-
if (commentsAuth?.isAuthenticated()) {
|
|
277
|
-
children.push({
|
|
278
|
-
id: 'core/logout',
|
|
279
|
-
label: 'Logout (remove token)',
|
|
280
|
-
type: 'default',
|
|
281
|
-
execute: () => {
|
|
282
|
-
commentsAuth.clearToken()
|
|
283
|
-
console.log('[storyboard] Token removed')
|
|
284
|
-
},
|
|
285
|
-
})
|
|
286
|
-
}
|
|
287
|
-
return children
|
|
288
|
-
},
|
|
289
|
-
})
|
|
290
|
-
}
|
|
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
|
|
291
394
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
registerCommandAction('core/feature-flags', {
|
|
295
|
-
getChildren: () =>
|
|
296
|
-
ff.getFlagKeys().map((key: string) => ({
|
|
297
|
-
id: `flags/${key}`,
|
|
298
|
-
label: key,
|
|
299
|
-
type: 'toggle' as const,
|
|
300
|
-
active: ff.getFlag(key),
|
|
301
|
-
execute: () => ff.toggleFlag(key),
|
|
302
|
-
})),
|
|
303
|
-
})
|
|
304
|
-
} catch {}
|
|
395
|
+
// Skip disabled tools — don't load their modules at all
|
|
396
|
+
if (getToolbarToolState(toolId) === 'disabled') continue
|
|
305
397
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
registerCommandAction('core/flows', {
|
|
312
|
-
getChildren: () => {
|
|
313
|
-
let path = window.location.pathname
|
|
314
|
-
const base = basePath.replace(/\/+$/, '')
|
|
315
|
-
if (base && path.startsWith(base)) path = path.slice(base.length)
|
|
316
|
-
path = path.replace(/\/+$/, '') || '/'
|
|
317
|
-
const segments = path.split('/').filter(Boolean)
|
|
318
|
-
const proto = segments[0] || null
|
|
319
|
-
if (!proto) return []
|
|
320
|
-
|
|
321
|
-
// Detect active flow
|
|
322
|
-
const params = new URLSearchParams(window.location.search)
|
|
323
|
-
const explicit = params.get('flow') || params.get('scene')
|
|
324
|
-
let active: string
|
|
325
|
-
if (explicit) {
|
|
326
|
-
active = loader.resolveFlowName(proto, explicit)
|
|
327
|
-
} else {
|
|
328
|
-
const pageFlow = path === '/' ? 'index' : (path.split('/').pop() || 'index')
|
|
329
|
-
const scoped = loader.resolveFlowName(proto, pageFlow)
|
|
330
|
-
if (loader.flowExists(scoped)) active = scoped
|
|
331
|
-
else {
|
|
332
|
-
const protoFlow = loader.resolveFlowName(proto, proto)
|
|
333
|
-
active = loader.flowExists(protoFlow) ? protoFlow : 'default'
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
return loader.getFlowsForPrototype(proto).map((f: any) => {
|
|
338
|
-
const meta = vf.getFlowMeta(f.key)
|
|
339
|
-
return {
|
|
340
|
-
id: f.key,
|
|
341
|
-
label: meta?.title || f.name,
|
|
342
|
-
type: 'radio' as const,
|
|
343
|
-
active: f.key === active,
|
|
344
|
-
execute: () => { window.location.href = vf.resolveFlowRoute(f.key) },
|
|
345
|
-
}
|
|
346
|
-
})
|
|
347
|
-
},
|
|
348
|
-
})
|
|
349
|
-
} 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
|
|
350
402
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
ActionMenuButton = mod.default
|
|
355
|
-
} catch {}
|
|
403
|
+
try {
|
|
404
|
+
const mod = await loadModule()
|
|
405
|
+
const toolCtx = { ...ctx, config: toolConfig }
|
|
356
406
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
407
|
+
// Run guard — skip if guard returns false
|
|
408
|
+
if (mod.guard) {
|
|
409
|
+
const ok = await mod.guard(toolCtx)
|
|
410
|
+
if (!ok) continue
|
|
411
|
+
}
|
|
362
412
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
371
|
-
} catch {}
|
|
413
|
+
// Run setup
|
|
414
|
+
if (mod.setup) {
|
|
415
|
+
const setupResult = await mod.setup(toolCtx)
|
|
416
|
+
if (setupResult) {
|
|
417
|
+
toolData[toolId] = setupResult
|
|
418
|
+
}
|
|
419
|
+
}
|
|
372
420
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
const feat = (features as Record<string, any>)[a.feature]
|
|
384
|
-
if (!feat || !feat.overlayId || !feat.overlay) return null
|
|
385
|
-
return {
|
|
386
|
-
name: feat.name,
|
|
387
|
-
label: a.label || feat.label,
|
|
388
|
-
overlayId: feat.overlayId,
|
|
389
|
-
overlay: feat.overlay,
|
|
390
|
-
}
|
|
391
|
-
})
|
|
392
|
-
.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
|
+
}
|
|
393
431
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
432
|
+
// Load component
|
|
433
|
+
if (mod.component) {
|
|
434
|
+
const component = await mod.component()
|
|
435
|
+
toolComponents[toolId] = component
|
|
397
436
|
}
|
|
398
|
-
}
|
|
399
|
-
}
|
|
437
|
+
} catch { /* tool failed to load — skip gracefully */ }
|
|
438
|
+
}
|
|
400
439
|
|
|
401
440
|
// Load side panel component
|
|
402
441
|
try {
|
|
@@ -406,12 +445,6 @@
|
|
|
406
445
|
}
|
|
407
446
|
} catch {}
|
|
408
447
|
|
|
409
|
-
// Load canvas create menu
|
|
410
|
-
try {
|
|
411
|
-
const mod = await import('./CanvasCreateMenu.svelte')
|
|
412
|
-
CanvasCreateMenu = mod.default
|
|
413
|
-
} catch {}
|
|
414
|
-
|
|
415
448
|
// Listen for canvas mount/unmount events (React↔Svelte bridge)
|
|
416
449
|
document.addEventListener('storyboard:canvas:mounted', handleCanvasMounted)
|
|
417
450
|
document.addEventListener('storyboard:canvas:unmounted', handleCanvasUnmounted)
|
|
@@ -445,20 +478,6 @@
|
|
|
445
478
|
canvasZoom = (e as CustomEvent).detail?.zoom ?? canvasZoom
|
|
446
479
|
}
|
|
447
480
|
|
|
448
|
-
function canvasZoomIn() {
|
|
449
|
-
const next = Math.min(ZOOM_MAX, canvasZoom + ZOOM_STEP)
|
|
450
|
-
document.dispatchEvent(new CustomEvent('storyboard:canvas:set-zoom', { detail: { zoom: next } }))
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
function canvasZoomOut() {
|
|
454
|
-
const next = Math.max(ZOOM_MIN, canvasZoom - ZOOM_STEP)
|
|
455
|
-
document.dispatchEvent(new CustomEvent('storyboard:canvas:set-zoom', { detail: { zoom: next } }))
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
function canvasZoomReset() {
|
|
459
|
-
document.dispatchEvent(new CustomEvent('storyboard:canvas:set-zoom', { detail: { zoom: 100 } }))
|
|
460
|
-
}
|
|
461
|
-
|
|
462
481
|
// Flow info dialog state — driven by core/show-flow-info action
|
|
463
482
|
let flowDialogOpen = $state(false)
|
|
464
483
|
let flowName = $state('default')
|
|
@@ -474,41 +493,29 @@
|
|
|
474
493
|
</script>
|
|
475
494
|
|
|
476
495
|
{#if !isEmbed}
|
|
477
|
-
{#if visible && canvasActive &&
|
|
496
|
+
{#if visible && canvasActive && canvasMenus.length > 0}
|
|
478
497
|
<div
|
|
479
498
|
class="fixed bottom-6 left-6 z-[9999] font-sans flex items-center gap-3"
|
|
480
499
|
role="toolbar"
|
|
481
500
|
aria-label="Canvas toolbar"
|
|
482
501
|
>
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
onclick={canvasZoomReset}
|
|
501
|
-
aria-label="Reset zoom to 100%"
|
|
502
|
-
title="Reset to 100%"
|
|
503
|
-
>{canvasZoom}%</button>
|
|
504
|
-
<button
|
|
505
|
-
class="canvas-zoom-btn"
|
|
506
|
-
onclick={canvasZoomIn}
|
|
507
|
-
disabled={canvasZoom >= ZOOM_MAX}
|
|
508
|
-
aria-label="Zoom in"
|
|
509
|
-
title="Zoom in"
|
|
510
|
-
>+</button>
|
|
511
|
-
</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}
|
|
512
519
|
</div>
|
|
513
520
|
{/if}
|
|
514
521
|
<div
|
|
@@ -522,32 +529,47 @@
|
|
|
522
529
|
bind:this={toolbarEl}
|
|
523
530
|
>
|
|
524
531
|
{#if visible}
|
|
525
|
-
{#each
|
|
526
|
-
|
|
527
|
-
<
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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}
|
|
551
573
|
{/each}
|
|
552
574
|
{/if}
|
|
553
575
|
{#if commandMenuConfig}
|
|
@@ -568,68 +590,52 @@
|
|
|
568
590
|
{/if}
|
|
569
591
|
|
|
570
592
|
<style>
|
|
571
|
-
.
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
border-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
overflow: hidden;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
.canvas-zoom-btn {
|
|
581
|
-
all: unset;
|
|
582
|
-
cursor: pointer;
|
|
583
|
-
display: flex;
|
|
584
|
-
align-items: center;
|
|
585
|
-
justify-content: center;
|
|
586
|
-
width: 36px;
|
|
587
|
-
height: 32px;
|
|
588
|
-
font-size: 16px;
|
|
589
|
-
font-weight: 600;
|
|
590
|
-
color: var(--trigger-text, var(--color-slate-600));
|
|
591
|
-
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;
|
|
592
599
|
}
|
|
593
600
|
|
|
594
|
-
.
|
|
595
|
-
background: var(--trigger-bg-hover, var(--color-slate-300));
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
.canvas-zoom-btn:disabled {
|
|
601
|
+
.default-button-dimmed {
|
|
599
602
|
opacity: 0.3;
|
|
600
|
-
|
|
603
|
+
transition: opacity 200ms;
|
|
601
604
|
}
|
|
602
605
|
|
|
603
|
-
.
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
display: flex;
|
|
607
|
-
align-items: center;
|
|
608
|
-
justify-content: center;
|
|
609
|
-
min-width: 48px;
|
|
610
|
-
height: 32px;
|
|
611
|
-
padding: 0 4px;
|
|
612
|
-
font-size: 11px;
|
|
613
|
-
font-weight: 600;
|
|
614
|
-
font-variant-numeric: tabular-nums;
|
|
615
|
-
color: var(--trigger-text, var(--color-slate-600));
|
|
616
|
-
border-left: 1.5px solid var(--trigger-border, var(--color-slate-400));
|
|
617
|
-
border-right: 1.5px solid var(--trigger-border, var(--color-slate-400));
|
|
618
|
-
transition: background 120ms;
|
|
606
|
+
.default-button-dimmed:hover,
|
|
607
|
+
.default-button-dimmed:focus-within {
|
|
608
|
+
opacity: 1;
|
|
619
609
|
}
|
|
620
610
|
|
|
621
|
-
.
|
|
622
|
-
|
|
611
|
+
.tool-inactive {
|
|
612
|
+
opacity: 0.45;
|
|
613
|
+
pointer-events: none;
|
|
623
614
|
}
|
|
624
|
-
|
|
625
|
-
.default-button-dimmed {
|
|
615
|
+
.tool-dimmed {
|
|
626
616
|
opacity: 0.3;
|
|
627
617
|
transition: opacity 200ms;
|
|
628
618
|
}
|
|
629
|
-
|
|
630
|
-
.
|
|
631
|
-
.default-button-dimmed:focus-within {
|
|
619
|
+
.tool-dimmed:hover,
|
|
620
|
+
.tool-dimmed:focus-within {
|
|
632
621
|
opacity: 1;
|
|
633
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
|
+
}
|
|
634
640
|
</style>
|
|
635
641
|
|