@dfosco/storyboard-core 2.0.0 → 2.2.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/modes.config.json +17 -0
- package/package.json +8 -3
- package/src/bodyClasses.js +11 -8
- package/src/bodyClasses.test.js +26 -12
- package/src/devtools.js +9 -8
- package/src/devtools.test.js +2 -2
- package/src/index.js +20 -5
- package/src/loader.js +116 -35
- package/src/loader.test.js +189 -48
- package/src/modes.js +170 -0
- package/src/modes.test.js +216 -0
- package/src/sceneDebug.js +17 -13
- package/src/sceneDebug.test.js +42 -29
- package/src/svelte-plugin-ui/__tests__/ToolbarShell.test.ts +77 -19
- package/src/svelte-plugin-ui/__tests__/designModes.test.ts +1 -1
- package/src/svelte-plugin-ui/components/ToolbarShell.svelte +59 -19
- package/src/svelte-plugin-ui/components/Viewfinder.svelte +573 -0
- package/src/svelte-plugin-ui/index.ts +3 -0
- package/src/svelte-plugin-ui/stores/toolStore.ts +71 -0
- package/src/svelte-plugin-ui/stores/types.ts +22 -8
- package/src/{svelte-plugin-ui/plugins → ui}/design-modes.ts +9 -7
- package/src/ui/viewfinder.ts +58 -0
- package/src/viewfinder.js +100 -20
- package/src/viewfinder.test.js +64 -42
- package/src/workshop/features/createPage/server.js +8 -8
package/src/loader.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { init, loadScene, listScenes, sceneExists, loadRecord, findRecord, loadObject, deepMerge } from './loader.js'
|
|
1
|
+
import { init, loadFlow, listFlows, flowExists, loadScene, listScenes, sceneExists, loadRecord, findRecord, loadObject, deepMerge, resolveFlowName, resolveRecordName } from './loader.js'
|
|
2
2
|
|
|
3
3
|
const makeIndex = () => ({
|
|
4
|
-
|
|
4
|
+
flows: {
|
|
5
5
|
default: {
|
|
6
6
|
title: 'Default Scene',
|
|
7
7
|
user: { $ref: 'jane-doe' },
|
|
@@ -72,111 +72,149 @@ describe('init', () => {
|
|
|
72
72
|
expect(() => init('string')).toThrow()
|
|
73
73
|
})
|
|
74
74
|
|
|
75
|
-
it('stores data so
|
|
75
|
+
it('stores data so loadFlow works', () => {
|
|
76
76
|
init(makeIndex())
|
|
77
|
-
const
|
|
78
|
-
expect(
|
|
77
|
+
const flow = loadFlow('default')
|
|
78
|
+
expect(flow.title).toBe('Default Scene')
|
|
79
79
|
})
|
|
80
80
|
|
|
81
81
|
it('handles missing properties gracefully', () => {
|
|
82
82
|
init({})
|
|
83
|
-
expect(
|
|
83
|
+
expect(flowExists('anything')).toBe(false)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('accepts { scenes } for backward compat', () => {
|
|
87
|
+
init({ scenes: { legacy: { title: 'Legacy' } }, objects: {}, records: {} })
|
|
88
|
+
expect(flowExists('legacy')).toBe(true)
|
|
84
89
|
})
|
|
85
90
|
})
|
|
86
91
|
|
|
87
|
-
describe('
|
|
88
|
-
it('loads
|
|
89
|
-
const
|
|
90
|
-
expect(
|
|
92
|
+
describe('loadFlow', () => {
|
|
93
|
+
it('loads flow by name', () => {
|
|
94
|
+
const flow = loadFlow('empty')
|
|
95
|
+
expect(flow).toEqual({})
|
|
91
96
|
})
|
|
92
97
|
|
|
93
98
|
it('resolves $ref to objects', () => {
|
|
94
|
-
const
|
|
95
|
-
expect(
|
|
99
|
+
const flow = loadFlow('default')
|
|
100
|
+
expect(flow.user).toEqual({ name: 'Jane Doe', role: 'admin' })
|
|
96
101
|
})
|
|
97
102
|
|
|
98
103
|
it('resolves nested $ref', () => {
|
|
99
|
-
const
|
|
100
|
-
expect(
|
|
104
|
+
const flow = loadFlow('with-nested-ref')
|
|
105
|
+
expect(flow.team.lead).toEqual({ name: 'Jane Doe', role: 'admin' })
|
|
101
106
|
})
|
|
102
107
|
|
|
103
|
-
it('resolves $global and merges into root,
|
|
104
|
-
const
|
|
105
|
-
expect(
|
|
106
|
-
expect(
|
|
107
|
-
//
|
|
108
|
-
expect(
|
|
108
|
+
it('resolves $global and merges into root, flow wins conflicts', () => {
|
|
109
|
+
const flow = loadFlow('Dashboard')
|
|
110
|
+
expect(flow.links).toEqual(['home', 'about'])
|
|
111
|
+
expect(flow.heading).toBe('Dashboard')
|
|
112
|
+
// flow value should win over global value
|
|
113
|
+
expect(flow.nav).toBe('scene-wins')
|
|
109
114
|
})
|
|
110
115
|
|
|
111
|
-
it('throws for missing
|
|
112
|
-
expect(() =>
|
|
116
|
+
it('throws for missing flow', () => {
|
|
117
|
+
expect(() => loadFlow('nonexistent')).toThrow()
|
|
113
118
|
})
|
|
114
119
|
|
|
115
120
|
it('case-insensitive lookup', () => {
|
|
116
|
-
const
|
|
117
|
-
expect(
|
|
121
|
+
const flow = loadFlow('dashboard')
|
|
122
|
+
expect(flow.heading).toBe('Dashboard')
|
|
118
123
|
})
|
|
119
124
|
|
|
120
125
|
it('returns deep clone (mutations do not affect index)', () => {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
expect(
|
|
126
|
+
const flow1 = loadFlow('empty')
|
|
127
|
+
flow1.injected = true
|
|
128
|
+
const flow2 = loadFlow('empty')
|
|
129
|
+
expect(flow2.injected).toBeUndefined()
|
|
125
130
|
})
|
|
126
131
|
|
|
127
132
|
it('resolves $global on repeated calls (no index mutation)', () => {
|
|
128
|
-
const first =
|
|
133
|
+
const first = loadFlow('Dashboard')
|
|
129
134
|
expect(first.links).toEqual(['home', 'about'])
|
|
130
135
|
expect(first.heading).toBe('Dashboard')
|
|
131
136
|
|
|
132
137
|
// Second call must return the same resolved data — $global must not
|
|
133
138
|
// be deleted from the index by the first call
|
|
134
|
-
const second =
|
|
139
|
+
const second = loadFlow('Dashboard')
|
|
135
140
|
expect(second.links).toEqual(['home', 'about'])
|
|
136
141
|
expect(second.heading).toBe('Dashboard')
|
|
137
142
|
expect(second.nav).toBe('scene-wins')
|
|
138
143
|
})
|
|
139
144
|
|
|
140
|
-
it('default param loads "default"
|
|
141
|
-
const
|
|
142
|
-
expect(
|
|
145
|
+
it('default param loads "default" flow', () => {
|
|
146
|
+
const flow = loadFlow()
|
|
147
|
+
expect(flow.title).toBe('Default Scene')
|
|
143
148
|
})
|
|
144
149
|
|
|
145
150
|
it('detects circular $ref and throws', () => {
|
|
146
|
-
expect(() =>
|
|
151
|
+
expect(() => loadFlow('circular-a')).toThrow(/circular/i)
|
|
147
152
|
})
|
|
148
153
|
})
|
|
149
154
|
|
|
150
|
-
describe('
|
|
151
|
-
it('returns true for existing
|
|
152
|
-
expect(
|
|
155
|
+
describe('flowExists', () => {
|
|
156
|
+
it('returns true for existing flow', () => {
|
|
157
|
+
expect(flowExists('default')).toBe(true)
|
|
153
158
|
})
|
|
154
159
|
|
|
155
|
-
it('returns false for missing
|
|
156
|
-
expect(
|
|
160
|
+
it('returns false for missing flow', () => {
|
|
161
|
+
expect(flowExists('nope')).toBe(false)
|
|
157
162
|
})
|
|
158
163
|
|
|
159
164
|
it('is case-insensitive', () => {
|
|
160
|
-
expect(
|
|
161
|
-
expect(
|
|
165
|
+
expect(flowExists('dashboard')).toBe(true)
|
|
166
|
+
expect(flowExists('DASHBOARD')).toBe(true)
|
|
162
167
|
})
|
|
163
168
|
})
|
|
164
169
|
|
|
165
|
-
describe('
|
|
166
|
-
it('returns all
|
|
167
|
-
const names =
|
|
170
|
+
describe('listFlows', () => {
|
|
171
|
+
it('returns all flow names', () => {
|
|
172
|
+
const names = listFlows()
|
|
168
173
|
expect(names).toContain('default')
|
|
169
174
|
expect(names).toContain('Dashboard')
|
|
170
175
|
expect(names).toContain('empty')
|
|
171
176
|
})
|
|
172
177
|
|
|
173
178
|
it('returns an array', () => {
|
|
174
|
-
expect(Array.isArray(
|
|
179
|
+
expect(Array.isArray(listFlows())).toBe(true)
|
|
175
180
|
})
|
|
176
181
|
|
|
177
|
-
it('returns empty array when no
|
|
178
|
-
init({
|
|
179
|
-
expect(
|
|
182
|
+
it('returns empty array when no flows registered', () => {
|
|
183
|
+
init({ flows: {}, objects: {}, records: {} })
|
|
184
|
+
expect(listFlows()).toEqual([])
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// ── Deprecated aliases ──
|
|
189
|
+
|
|
190
|
+
describe('loadScene (deprecated alias)', () => {
|
|
191
|
+
it('is the same function as loadFlow', () => {
|
|
192
|
+
expect(loadScene).toBe(loadFlow)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('loads flow data', () => {
|
|
196
|
+
const scene = loadScene('default')
|
|
197
|
+
expect(scene.title).toBe('Default Scene')
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
describe('sceneExists (deprecated alias)', () => {
|
|
202
|
+
it('is the same function as flowExists', () => {
|
|
203
|
+
expect(sceneExists).toBe(flowExists)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('returns true for existing flow', () => {
|
|
207
|
+
expect(sceneExists('default')).toBe(true)
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
describe('listScenes (deprecated alias)', () => {
|
|
212
|
+
it('is the same function as listFlows', () => {
|
|
213
|
+
expect(listScenes).toBe(listFlows)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('returns all flow names', () => {
|
|
217
|
+
expect(listScenes()).toContain('default')
|
|
180
218
|
})
|
|
181
219
|
})
|
|
182
220
|
|
|
@@ -275,3 +313,106 @@ describe('loadObject', () => {
|
|
|
275
313
|
expect(() => loadObject('circular-obj-a')).toThrow(/circular/i)
|
|
276
314
|
})
|
|
277
315
|
})
|
|
316
|
+
|
|
317
|
+
describe('resolveFlowName', () => {
|
|
318
|
+
beforeEach(() => {
|
|
319
|
+
init({
|
|
320
|
+
flows: {
|
|
321
|
+
default: { title: 'Global Default' },
|
|
322
|
+
signup: { title: 'Global Signup' },
|
|
323
|
+
'Dashboard/default': { title: 'Dashboard Default' },
|
|
324
|
+
'Dashboard/signup': { title: 'Dashboard Signup' },
|
|
325
|
+
'Blog/default': { title: 'Blog Default' },
|
|
326
|
+
},
|
|
327
|
+
objects: {},
|
|
328
|
+
records: {},
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('returns scoped name when it exists', () => {
|
|
333
|
+
expect(resolveFlowName('Dashboard', 'default')).toBe('Dashboard/default')
|
|
334
|
+
expect(resolveFlowName('Dashboard', 'signup')).toBe('Dashboard/signup')
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('falls back to global name when scoped does not exist', () => {
|
|
338
|
+
expect(resolveFlowName('Blog', 'signup')).toBe('signup')
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('returns global name when scope is null', () => {
|
|
342
|
+
expect(resolveFlowName(null, 'default')).toBe('default')
|
|
343
|
+
expect(resolveFlowName(null, 'signup')).toBe('signup')
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('returns scoped name for error messages when neither exists', () => {
|
|
347
|
+
expect(resolveFlowName('Dashboard', 'nonexistent')).toBe('Dashboard/nonexistent')
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('returns plain name for error messages when scope is null and name does not exist', () => {
|
|
351
|
+
expect(resolveFlowName(null, 'nonexistent')).toBe('nonexistent')
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('handles already-scoped names (explicit cross-prototype)', () => {
|
|
355
|
+
expect(resolveFlowName('Blog', 'Dashboard/signup')).toBe('Dashboard/signup')
|
|
356
|
+
})
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
describe('resolveRecordName', () => {
|
|
360
|
+
beforeEach(() => {
|
|
361
|
+
init({
|
|
362
|
+
flows: {},
|
|
363
|
+
objects: {},
|
|
364
|
+
records: {
|
|
365
|
+
posts: [{ id: '1' }],
|
|
366
|
+
'Dashboard/metrics': [{ id: 'm1' }],
|
|
367
|
+
'Dashboard/posts': [{ id: 'd1' }],
|
|
368
|
+
},
|
|
369
|
+
})
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('returns scoped name when it exists', () => {
|
|
373
|
+
expect(resolveRecordName('Dashboard', 'metrics')).toBe('Dashboard/metrics')
|
|
374
|
+
expect(resolveRecordName('Dashboard', 'posts')).toBe('Dashboard/posts')
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it('falls back to global when scoped does not exist', () => {
|
|
378
|
+
expect(resolveRecordName('Blog', 'posts')).toBe('posts')
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('returns global when scope is null', () => {
|
|
382
|
+
expect(resolveRecordName(null, 'posts')).toBe('posts')
|
|
383
|
+
})
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
describe('error hints for scoped data', () => {
|
|
387
|
+
beforeEach(() => {
|
|
388
|
+
init({
|
|
389
|
+
flows: {
|
|
390
|
+
'Dashboard/signup': { title: 'Dashboard Signup' },
|
|
391
|
+
default: { title: 'Global Default' },
|
|
392
|
+
},
|
|
393
|
+
objects: {},
|
|
394
|
+
records: {
|
|
395
|
+
'Blog/posts': [{ id: '1' }],
|
|
396
|
+
tags: [{ id: 'js' }],
|
|
397
|
+
},
|
|
398
|
+
})
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('loadFlow error suggests scoped alternatives', () => {
|
|
402
|
+
expect(() => loadFlow('signup')).toThrow(/Did you mean: Dashboard\/signup/)
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
it('loadRecord error suggests scoped alternatives', () => {
|
|
406
|
+
expect(() => loadRecord('posts')).toThrow(/Did you mean: Blog\/posts/)
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it('loadFlow error for truly missing name has no hint', () => {
|
|
410
|
+
expect(() => loadFlow('xyz')).toThrow(/Failed to load flow: xyz/)
|
|
411
|
+
expect(() => loadFlow('xyz')).not.toThrow(/Did you mean/)
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it('loadRecord error for truly missing name has no hint', () => {
|
|
415
|
+
expect(() => loadRecord('xyz')).toThrow(/Record not found: xyz/)
|
|
416
|
+
expect(() => loadRecord('xyz')).not.toThrow(/Did you mean/)
|
|
417
|
+
})
|
|
418
|
+
})
|
package/src/modes.js
CHANGED
|
@@ -17,6 +17,12 @@ const DEFAULT_MODE = 'prototype'
|
|
|
17
17
|
|
|
18
18
|
let _modesEnabled = false
|
|
19
19
|
|
|
20
|
+
// Tool registry — seeded from modes.config.json, state managed at runtime
|
|
21
|
+
const _tools = new Map() // id → { id, label, group, modes[] }
|
|
22
|
+
const _toolState = new Map() // id → { enabled, active, busy, hidden, badge }
|
|
23
|
+
const _toolActions = new Map() // id → action function
|
|
24
|
+
const _toolListeners = new Set() // subscribers to tool state/action changes
|
|
25
|
+
|
|
20
26
|
// ---------------------------------------------------------------------------
|
|
21
27
|
// Registry
|
|
22
28
|
// ---------------------------------------------------------------------------
|
|
@@ -281,6 +287,166 @@ export function isModesEnabled() {
|
|
|
281
287
|
return _modesEnabled
|
|
282
288
|
}
|
|
283
289
|
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Tool registry
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
const DEFAULT_TOOL_STATE = Object.freeze({
|
|
295
|
+
enabled: true,
|
|
296
|
+
active: false,
|
|
297
|
+
busy: false,
|
|
298
|
+
hidden: false,
|
|
299
|
+
badge: null,
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Seed the tool registry from modes.config.json.
|
|
304
|
+
* Called by the Vite data plugin's generated virtual module.
|
|
305
|
+
*
|
|
306
|
+
* @param {Record<string, Array<{ id: string, label: string, group: string }>>} config
|
|
307
|
+
* Keys are mode names or '*' (all modes). Values are tool declarations.
|
|
308
|
+
*/
|
|
309
|
+
export function initTools(config = {}) {
|
|
310
|
+
for (const [modeKey, tools] of Object.entries(config)) {
|
|
311
|
+
if (!Array.isArray(tools)) continue
|
|
312
|
+
for (const tool of tools) {
|
|
313
|
+
if (!tool.id) continue
|
|
314
|
+
const existing = _tools.get(tool.id)
|
|
315
|
+
if (existing) {
|
|
316
|
+
// Merge mode assignments (tool declared in multiple mode keys)
|
|
317
|
+
if (!existing.modes.includes(modeKey)) {
|
|
318
|
+
existing.modes.push(modeKey)
|
|
319
|
+
}
|
|
320
|
+
} else {
|
|
321
|
+
_tools.set(tool.id, {
|
|
322
|
+
id: tool.id,
|
|
323
|
+
label: tool.label ?? tool.id,
|
|
324
|
+
group: tool.group ?? 'tools',
|
|
325
|
+
icon: tool.icon ?? null,
|
|
326
|
+
order: tool.order ?? 100,
|
|
327
|
+
modes: [modeKey],
|
|
328
|
+
})
|
|
329
|
+
_toolState.set(tool.id, { ...DEFAULT_TOOL_STATE })
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
_notifyTools()
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Wire up a click handler for a declared tool.
|
|
338
|
+
* Plugins call this to provide the action callback.
|
|
339
|
+
*
|
|
340
|
+
* @param {string} id Tool id (must exist in registry)
|
|
341
|
+
* @param {Function} action Click handler
|
|
342
|
+
*/
|
|
343
|
+
export function setToolAction(id, action) {
|
|
344
|
+
if (!_tools.has(id)) {
|
|
345
|
+
console.warn(`[storyboard] Tool "${id}" is not declared in modes.config.json.`)
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
_toolActions.set(id, action)
|
|
349
|
+
_notifyTools()
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Update the runtime state of a tool.
|
|
354
|
+
* Merges the update into existing state — only the provided keys change.
|
|
355
|
+
*
|
|
356
|
+
* @param {string} id Tool id (must exist in registry)
|
|
357
|
+
* @param {object} state Partial state update
|
|
358
|
+
* @param {boolean} [state.enabled] Whether the tool can be interacted with
|
|
359
|
+
* @param {boolean} [state.active] Whether the tool is currently "on" (highlighted)
|
|
360
|
+
* @param {boolean} [state.busy] Whether the tool is in use / unavailable
|
|
361
|
+
* @param {boolean} [state.hidden] Whether the tool should be hidden entirely
|
|
362
|
+
* @param {string|number|null} [state.badge] Notification badge
|
|
363
|
+
*/
|
|
364
|
+
export function setToolState(id, state = {}) {
|
|
365
|
+
if (!_tools.has(id)) {
|
|
366
|
+
console.warn(`[storyboard] Tool "${id}" is not declared in modes.config.json.`)
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
const current = _toolState.get(id) ?? { ...DEFAULT_TOOL_STATE }
|
|
370
|
+
_toolState.set(id, { ...current, ...state })
|
|
371
|
+
_notifyTools()
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Get the current runtime state of a tool.
|
|
376
|
+
*
|
|
377
|
+
* @param {string} id Tool id
|
|
378
|
+
* @returns {{ enabled: boolean, active: boolean, busy: boolean, hidden: boolean, badge: string|number|null } | null}
|
|
379
|
+
*/
|
|
380
|
+
export function getToolState(id) {
|
|
381
|
+
return _toolState.get(id) ?? null
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Get all tools for a given mode, merged with '*' wildcard tools.
|
|
386
|
+
* Returns tool declarations with their current state and action.
|
|
387
|
+
* Sorted by group (tools first, dev second), then by order.
|
|
388
|
+
*
|
|
389
|
+
* @param {string} modeName
|
|
390
|
+
* @returns {Array<{ id, label, group, icon, order, modes, state, action }>}
|
|
391
|
+
*/
|
|
392
|
+
export function getToolsForMode(modeName) {
|
|
393
|
+
const result = []
|
|
394
|
+
for (const [id, tool] of _tools) {
|
|
395
|
+
if (!tool.modes.includes(modeName) && !tool.modes.includes('*')) continue
|
|
396
|
+
const state = _toolState.get(id) ?? { ...DEFAULT_TOOL_STATE }
|
|
397
|
+
if (state.hidden) continue
|
|
398
|
+
result.push({
|
|
399
|
+
...tool,
|
|
400
|
+
state,
|
|
401
|
+
action: _toolActions.get(id) ?? null,
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
// Sort: 'tools' group before 'dev', then by order
|
|
405
|
+
const groupOrder = { tools: 0, dev: 1 }
|
|
406
|
+
result.sort((a, b) => {
|
|
407
|
+
const ga = groupOrder[a.group] ?? 0
|
|
408
|
+
const gb = groupOrder[b.group] ?? 0
|
|
409
|
+
if (ga !== gb) return ga - gb
|
|
410
|
+
return (a.order ?? 100) - (b.order ?? 100)
|
|
411
|
+
})
|
|
412
|
+
return result
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Subscribe to tool state/action changes.
|
|
417
|
+
* Compatible with React's useSyncExternalStore.
|
|
418
|
+
*
|
|
419
|
+
* @param {Function} callback Called on any tool change
|
|
420
|
+
* @returns {Function} Unsubscribe function
|
|
421
|
+
*/
|
|
422
|
+
export function subscribeToTools(callback) {
|
|
423
|
+
_toolListeners.add(callback)
|
|
424
|
+
return () => _toolListeners.delete(callback)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Snapshot for useSyncExternalStore.
|
|
429
|
+
* Returns a serialised string that changes when tool state/actions change.
|
|
430
|
+
*/
|
|
431
|
+
export function getToolsSnapshot() {
|
|
432
|
+
const entries = []
|
|
433
|
+
for (const [id, state] of _toolState) {
|
|
434
|
+
const hasAction = _toolActions.has(id) ? '1' : '0'
|
|
435
|
+
entries.push(`${id}:${state.enabled}:${state.active}:${state.busy}:${state.hidden}:${state.badge}:${hasAction}`)
|
|
436
|
+
}
|
|
437
|
+
return entries.join('|')
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function _notifyTools() {
|
|
441
|
+
for (const cb of _toolListeners) {
|
|
442
|
+
try {
|
|
443
|
+
cb()
|
|
444
|
+
} catch (err) {
|
|
445
|
+
console.error('[storyboard] Error in tool subscriber:', err)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
284
450
|
// ---------------------------------------------------------------------------
|
|
285
451
|
// Test helpers
|
|
286
452
|
// ---------------------------------------------------------------------------
|
|
@@ -292,5 +458,9 @@ export function _resetModes() {
|
|
|
292
458
|
_modes.clear()
|
|
293
459
|
_listeners.clear()
|
|
294
460
|
_eventListeners.clear()
|
|
461
|
+
_tools.clear()
|
|
462
|
+
_toolState.clear()
|
|
463
|
+
_toolActions.clear()
|
|
464
|
+
_toolListeners.clear()
|
|
295
465
|
_modesEnabled = false
|
|
296
466
|
}
|