@dfosco/storyboard 0.9.2 → 0.9.4
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 +1 -10
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +1 -4
- package/scaffold/AGENTS.md +2 -0
- package/scaffold/skills/storyboard-core/SKILL.md +63 -171
- package/scaffold/skills/storyboard-widget/SKILL.md +172 -0
- package/scaffold/skills/tools/SKILL.md +12 -12
- package/src/core/canvas/collision.js +41 -19
- package/src/core/canvas/customWidgets.js +92 -0
- package/src/core/canvas/customWidgets.test.js +69 -0
- package/src/core/canvas/server.js +4 -3
- package/src/core/index.js +13 -0
- package/src/core/mountStoryboardCore.js +10 -0
- package/src/core/stores/configSchema.js +2 -1
- package/src/core/stores/widgetRegistry.js +184 -0
- package/src/core/stores/widgetRegistry.test.js +109 -0
- package/src/core/ui/CoreUIBar.jsx +1 -1
- package/src/core/ui-entry.js +0 -6
- package/src/core/vite/server-plugin.js +12 -0
- package/src/internals/BranchBar/BranchBar.jsx +27 -20
- package/src/internals/canvas/CanvasPage.bridge.test.jsx +2 -0
- package/src/internals/canvas/CanvasPage.dragdrop.test.jsx +2 -0
- package/src/internals/canvas/CanvasPage.jsx +19 -3
- package/src/internals/canvas/CanvasPage.multiselect.test.jsx +2 -0
- package/src/internals/canvas/widgets/WidgetChrome.jsx +50 -5
- package/src/internals/canvas/widgets/WidgetChrome.test.jsx +65 -0
- package/src/internals/canvas/widgets/index.js +18 -4
- package/src/internals/canvas/widgets/widgetConfig.js +202 -45
- package/src/internals/canvas/widgets/widgetConfig.merged.test.js +144 -0
- package/src/internals/context.jsx +1 -17
- package/src/core/ui/design-modes.ts +0 -7
- package/src/core/ui/viewfinder.ts +0 -7
- package/src/core/workshop/ui/mount.ts +0 -6
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget Registry — consumer-supplied custom widget types.
|
|
3
|
+
*
|
|
4
|
+
* Consumers register widgets through `mountStoryboardCore({ widgets })`:
|
|
5
|
+
*
|
|
6
|
+
* mountStoryboardCore(config, {
|
|
7
|
+
* widgets: {
|
|
8
|
+
* 'my-thing': {
|
|
9
|
+
* component: MyThing,
|
|
10
|
+
* label: 'My Thing',
|
|
11
|
+
* icon: 'sparkle',
|
|
12
|
+
* chrome: { enabled: true }, // optional, default true
|
|
13
|
+
* interaction: {
|
|
14
|
+
* selectable: true, // default true
|
|
15
|
+
* movable: true, // default true
|
|
16
|
+
* resize: { enabled: true, prod: false }, // default disabled
|
|
17
|
+
* expandable: false,
|
|
18
|
+
* splitScreen: false,
|
|
19
|
+
* interactGate: false,
|
|
20
|
+
* interactGateLabel: 'Click to interact',
|
|
21
|
+
* },
|
|
22
|
+
* connectors: { anchors: {...}, accept: ['*'], exclude: [] },
|
|
23
|
+
* features: [ ... ], // toolbar actions
|
|
24
|
+
* unlisted: false, // hide from "+ Add widget"
|
|
25
|
+
* props: { width: { type: 'number', default: 400 }, ... },
|
|
26
|
+
* },
|
|
27
|
+
* },
|
|
28
|
+
* })
|
|
29
|
+
*
|
|
30
|
+
* Definitions registered through this store are merged with — and override —
|
|
31
|
+
* the built-in widget configs from `widgets.config.json` on a per-type basis.
|
|
32
|
+
*
|
|
33
|
+
* Built-in core widgets (sticky-note, markdown, image, etc.) ALSO live in
|
|
34
|
+
* this registry once `seedCoreWidgets()` runs at module load. Consumer
|
|
35
|
+
* overrides for those types replace the core entry entirely; if a consumer
|
|
36
|
+
* wants to extend a core widget they should re-declare the merged definition.
|
|
37
|
+
*
|
|
38
|
+
* Framework-agnostic (zero npm dependencies). React typing is structural —
|
|
39
|
+
* the `component` field is opaque from this store's perspective.
|
|
40
|
+
*
|
|
41
|
+
* @typedef {object} WidgetChromeOptions
|
|
42
|
+
* @property {boolean} [enabled] Render component inside WidgetChrome. Default: true.
|
|
43
|
+
* When false, the widget renders raw with no toolbar,
|
|
44
|
+
* no anchor ports, no select handle, no interact gate.
|
|
45
|
+
* Consumer owns 100% of the visible UI.
|
|
46
|
+
*
|
|
47
|
+
* @typedef {object} WidgetInteractionOptions
|
|
48
|
+
* @property {boolean} [selectable] Default: true. False → no select handle, single-click
|
|
49
|
+
* does not select.
|
|
50
|
+
* @property {boolean} [movable] Default: true. False → drag surface class is omitted,
|
|
51
|
+
* widget cannot be dragged.
|
|
52
|
+
* @property {{ enabled: boolean, prod?: boolean }} [resize]
|
|
53
|
+
* Default: disabled. Same shape as widgets.config.json.
|
|
54
|
+
* @property {boolean} [expandable] Default: false. Adds the "Expand" action.
|
|
55
|
+
* @property {boolean} [splitScreen] Default: false. Eligible as a split-screen pane.
|
|
56
|
+
* @property {boolean} [interactGate] Default: false. Overlays "Click to interact" until clicked.
|
|
57
|
+
* @property {string} [interactGateLabel] Default: 'Click to interact'.
|
|
58
|
+
*
|
|
59
|
+
* @typedef {object} WidgetDefinition
|
|
60
|
+
* @property {*} [component] React component or render function.
|
|
61
|
+
* Required for consumer widgets; not stored for
|
|
62
|
+
* core widgets when they fall back to widgets.config.json.
|
|
63
|
+
* @property {string} [label] Display label for menus and selectors.
|
|
64
|
+
* @property {string} [icon] Icon key from ICON_REGISTRY.
|
|
65
|
+
* @property {WidgetChromeOptions} [chrome]
|
|
66
|
+
* @property {WidgetInteractionOptions} [interaction]
|
|
67
|
+
* @property {object} [connectors] { anchors, accept, exclude, defaults }.
|
|
68
|
+
* @property {Array<object>} [features] Toolbar feature list.
|
|
69
|
+
* @property {boolean} [unlisted] Hide from "+ Add widget" menu.
|
|
70
|
+
* @property {Record<string, object>} [props] Prop schema.
|
|
71
|
+
*
|
|
72
|
+
* The following back-compat fields are still read (mirrors widgets.config.json
|
|
73
|
+
* legacy shape so an unchanged JSON entry continues to work after migration):
|
|
74
|
+
* @property {{ enabled: boolean, prod?: boolean }} [resize]
|
|
75
|
+
* @property {boolean} [expandable]
|
|
76
|
+
* @property {boolean} [splitScreen]
|
|
77
|
+
* @property {boolean} [interactGate]
|
|
78
|
+
* @property {string} [interactGateLabel]
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/** @type {Map<string, WidgetDefinition>} */
|
|
82
|
+
let _registry = new Map()
|
|
83
|
+
|
|
84
|
+
/** @type {Set<Function>} */
|
|
85
|
+
const _listeners = new Set()
|
|
86
|
+
|
|
87
|
+
let _snapshotVersion = 0
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Replace the entire registry from a consumer-supplied map.
|
|
91
|
+
* Pass null/undefined to clear.
|
|
92
|
+
* @param {Record<string, WidgetDefinition>|null|undefined} map
|
|
93
|
+
*/
|
|
94
|
+
export function initWidgetRegistry(map) {
|
|
95
|
+
_registry = new Map()
|
|
96
|
+
if (map && typeof map === 'object') {
|
|
97
|
+
for (const [type, def] of Object.entries(map)) {
|
|
98
|
+
if (def && typeof def === 'object') {
|
|
99
|
+
_registry.set(type, def)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
_notify()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Register a single widget type (consumer-side). Replaces any existing entry.
|
|
108
|
+
* @param {string} type
|
|
109
|
+
* @param {WidgetDefinition} definition
|
|
110
|
+
*/
|
|
111
|
+
export function registerWidget(type, definition) {
|
|
112
|
+
if (!type || typeof type !== 'string') {
|
|
113
|
+
throw new Error('[storyboard] registerWidget: type must be a non-empty string')
|
|
114
|
+
}
|
|
115
|
+
if (!definition || typeof definition !== 'object') {
|
|
116
|
+
throw new Error(`[storyboard] registerWidget("${type}"): definition must be an object`)
|
|
117
|
+
}
|
|
118
|
+
_registry.set(type, definition)
|
|
119
|
+
_notify()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Remove a registry entry. Built-in widgets re-seeded via seedCoreWidgets()
|
|
124
|
+
* are unaffected; only the consumer override is removed.
|
|
125
|
+
* @param {string} type
|
|
126
|
+
*/
|
|
127
|
+
export function unregisterWidget(type) {
|
|
128
|
+
if (_registry.delete(type)) _notify()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get the registered definition for a widget type, or null.
|
|
133
|
+
* Consumers should prefer `getMergedWidgetDefinition()` which also folds in
|
|
134
|
+
* the static widgets.config.json shape.
|
|
135
|
+
* @param {string} type
|
|
136
|
+
* @returns {WidgetDefinition|null}
|
|
137
|
+
*/
|
|
138
|
+
export function getWidgetDefinition(type) {
|
|
139
|
+
return _registry.get(type) || null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Snapshot of the entire registry as a plain object.
|
|
144
|
+
* @returns {Record<string, WidgetDefinition>}
|
|
145
|
+
*/
|
|
146
|
+
export function getAllWidgetDefinitions() {
|
|
147
|
+
return Object.fromEntries(_registry)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Subscribe to registry changes. Returns unsubscribe fn.
|
|
152
|
+
* Compatible with `useSyncExternalStore`.
|
|
153
|
+
* @param {Function} callback
|
|
154
|
+
* @returns {Function}
|
|
155
|
+
*/
|
|
156
|
+
export function subscribeToWidgetRegistry(callback) {
|
|
157
|
+
_listeners.add(callback)
|
|
158
|
+
return () => _listeners.delete(callback)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Snapshot version string for change detection.
|
|
163
|
+
* Compatible with `useSyncExternalStore`.
|
|
164
|
+
* @returns {string}
|
|
165
|
+
*/
|
|
166
|
+
export function getWidgetRegistrySnapshot() {
|
|
167
|
+
return String(_snapshotVersion)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function _notify() {
|
|
171
|
+
_snapshotVersion++
|
|
172
|
+
for (const cb of _listeners) {
|
|
173
|
+
try { cb() } catch (err) {
|
|
174
|
+
console.error('[storyboard] Error in widgetRegistry subscriber:', err)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Reset all state. Only for tests. */
|
|
180
|
+
export function _resetWidgetRegistry() {
|
|
181
|
+
_registry = new Map()
|
|
182
|
+
_listeners.clear()
|
|
183
|
+
_snapshotVersion = 0
|
|
184
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
initWidgetRegistry,
|
|
4
|
+
registerWidget,
|
|
5
|
+
unregisterWidget,
|
|
6
|
+
getWidgetDefinition,
|
|
7
|
+
getAllWidgetDefinitions,
|
|
8
|
+
subscribeToWidgetRegistry,
|
|
9
|
+
getWidgetRegistrySnapshot,
|
|
10
|
+
_resetWidgetRegistry,
|
|
11
|
+
} from './widgetRegistry.js'
|
|
12
|
+
|
|
13
|
+
describe('widgetRegistry', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
_resetWidgetRegistry()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('starts empty', () => {
|
|
19
|
+
expect(getAllWidgetDefinitions()).toEqual({})
|
|
20
|
+
expect(getWidgetDefinition('anything')).toBeNull()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('initWidgetRegistry seeds the store with valid entries', () => {
|
|
24
|
+
const Component = () => null
|
|
25
|
+
initWidgetRegistry({
|
|
26
|
+
'my-thing': { component: Component, label: 'My Thing' },
|
|
27
|
+
'other': { label: 'Other' },
|
|
28
|
+
})
|
|
29
|
+
expect(getWidgetDefinition('my-thing')).toEqual({ component: Component, label: 'My Thing' })
|
|
30
|
+
expect(getWidgetDefinition('other')).toEqual({ label: 'Other' })
|
|
31
|
+
expect(Object.keys(getAllWidgetDefinitions()).sort()).toEqual(['my-thing', 'other'])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('initWidgetRegistry replaces previous entries', () => {
|
|
35
|
+
initWidgetRegistry({ a: { label: 'A' } })
|
|
36
|
+
initWidgetRegistry({ b: { label: 'B' } })
|
|
37
|
+
expect(getWidgetDefinition('a')).toBeNull()
|
|
38
|
+
expect(getWidgetDefinition('b')).toEqual({ label: 'B' })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('initWidgetRegistry ignores null/undefined and non-object entries', () => {
|
|
42
|
+
initWidgetRegistry({
|
|
43
|
+
ok: { label: 'OK' },
|
|
44
|
+
nullish: null,
|
|
45
|
+
undef: undefined,
|
|
46
|
+
notObj: 'string',
|
|
47
|
+
})
|
|
48
|
+
expect(Object.keys(getAllWidgetDefinitions())).toEqual(['ok'])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('initWidgetRegistry with null/undefined clears the store', () => {
|
|
52
|
+
initWidgetRegistry({ a: { label: 'A' } })
|
|
53
|
+
initWidgetRegistry(null)
|
|
54
|
+
expect(getAllWidgetDefinitions()).toEqual({})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('registerWidget adds a single entry', () => {
|
|
58
|
+
registerWidget('foo', { label: 'Foo' })
|
|
59
|
+
expect(getWidgetDefinition('foo')).toEqual({ label: 'Foo' })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('registerWidget replaces an existing entry', () => {
|
|
63
|
+
registerWidget('foo', { label: 'Foo' })
|
|
64
|
+
registerWidget('foo', { label: 'Foo2' })
|
|
65
|
+
expect(getWidgetDefinition('foo')).toEqual({ label: 'Foo2' })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('registerWidget throws on invalid type', () => {
|
|
69
|
+
expect(() => registerWidget('', { label: 'X' })).toThrow(/non-empty string/)
|
|
70
|
+
expect(() => registerWidget(null, { label: 'X' })).toThrow(/non-empty string/)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('registerWidget throws on invalid definition', () => {
|
|
74
|
+
expect(() => registerWidget('foo', null)).toThrow(/must be an object/)
|
|
75
|
+
expect(() => registerWidget('foo', 'string')).toThrow(/must be an object/)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('unregisterWidget removes an entry', () => {
|
|
79
|
+
registerWidget('foo', { label: 'Foo' })
|
|
80
|
+
unregisterWidget('foo')
|
|
81
|
+
expect(getWidgetDefinition('foo')).toBeNull()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('unregisterWidget on missing entry is a no-op', () => {
|
|
85
|
+
expect(() => unregisterWidget('nope')).not.toThrow()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('subscribeToWidgetRegistry fires on init/register/unregister', () => {
|
|
89
|
+
let calls = 0
|
|
90
|
+
const unsubscribe = subscribeToWidgetRegistry(() => { calls++ })
|
|
91
|
+
initWidgetRegistry({ a: { label: 'A' } })
|
|
92
|
+
expect(calls).toBe(1)
|
|
93
|
+
registerWidget('b', { label: 'B' })
|
|
94
|
+
expect(calls).toBe(2)
|
|
95
|
+
unregisterWidget('a')
|
|
96
|
+
expect(calls).toBe(3)
|
|
97
|
+
unsubscribe()
|
|
98
|
+
registerWidget('c', { label: 'C' })
|
|
99
|
+
expect(calls).toBe(3)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('getWidgetRegistrySnapshot returns a stable string per state', () => {
|
|
103
|
+
const a = getWidgetRegistrySnapshot()
|
|
104
|
+
expect(a).toBe(getWidgetRegistrySnapshot())
|
|
105
|
+
registerWidget('foo', { label: 'Foo' })
|
|
106
|
+
const b = getWidgetRegistrySnapshot()
|
|
107
|
+
expect(b).not.toBe(a)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -116,7 +116,7 @@ function menuVisibleInMode(menu, mode) {
|
|
|
116
116
|
return menu.modes.includes('*') || menu.modes.includes(mode)
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
// Subscribable mode state for useExternalStore
|
|
119
|
+
// Subscribable mode state for useExternalStore
|
|
120
120
|
const modeState = {
|
|
121
121
|
get mode() { return getCurrentMode() },
|
|
122
122
|
get modes() { return getRegisteredModes() },
|
package/src/core/ui-entry.js
CHANGED
|
@@ -22,9 +22,3 @@ export { mountDevTools, unmountDevTools } from './devtools/devtools.js'
|
|
|
22
22
|
|
|
23
23
|
// Comments UI
|
|
24
24
|
export { mountComments } from './comments/ui/mount.js'
|
|
25
|
-
|
|
26
|
-
// Viewfinder dashboard
|
|
27
|
-
export { mountViewfinder, unmountViewfinder } from './ui/viewfinder.ts'
|
|
28
|
-
|
|
29
|
-
// Design modes
|
|
30
|
-
export { mountDesignModesUI as mountDesignModes } from './ui/design-modes.ts'
|
|
@@ -14,6 +14,7 @@ import fs from 'node:fs'
|
|
|
14
14
|
import path from 'node:path'
|
|
15
15
|
import { parse as parseJsonc } from 'jsonc-parser'
|
|
16
16
|
import { getConfig } from '../stores/configSchema.js'
|
|
17
|
+
import { initServerWidgets } from '../canvas/customWidgets.js'
|
|
17
18
|
import { createDevLogger, setDevLogger } from '../logger/devLogger.js'
|
|
18
19
|
import { serverFeatures as workshopFeatures } from '../workshop/features/registry-server.js'
|
|
19
20
|
import { docsHandler, collectFiles } from './docs-handler.js'
|
|
@@ -161,6 +162,17 @@ export default function storyboardServer() {
|
|
|
161
162
|
base = viteConfig.base || '/'
|
|
162
163
|
config = readConfig(root)
|
|
163
164
|
isDev = viteConfig.command === 'serve'
|
|
165
|
+
|
|
166
|
+
// Seed the server-side custom-widget registry from the consumer's
|
|
167
|
+
// `storyboard.config.json.widgets` block. Consumer entries override
|
|
168
|
+
// built-in widget metadata for collision detection, default sizes,
|
|
169
|
+
// prompt-exec config, etc. Server-side does not load React components
|
|
170
|
+
// — those are seeded in the browser via `mountStoryboardCore({ widgets })`.
|
|
171
|
+
try {
|
|
172
|
+
initServerWidgets(config?.widgets || null)
|
|
173
|
+
} catch (err) {
|
|
174
|
+
console.warn('[storyboard] Failed to init custom widget registry:', err.message)
|
|
175
|
+
}
|
|
164
176
|
},
|
|
165
177
|
|
|
166
178
|
configureServer(server) {
|
|
@@ -11,6 +11,7 @@ import { useState, useEffect, useMemo } from 'react'
|
|
|
11
11
|
import { createPortal } from 'react-dom'
|
|
12
12
|
import { GitBranchIcon } from '@primer/octicons-react'
|
|
13
13
|
import { useBranches } from './useBranches.js'
|
|
14
|
+
import ChromeSlot from '../../core/ui/ChromeSlot.jsx'
|
|
14
15
|
import css from './BranchBar.module.css'
|
|
15
16
|
|
|
16
17
|
/** Check if we're in local dev mode (respects ?prodMode simulation). */
|
|
@@ -78,28 +79,34 @@ export default function BranchBar({ basePath }) {
|
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
return createPortal(
|
|
81
|
-
<
|
|
82
|
-
<div
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
{
|
|
88
|
-
<span className={css.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
82
|
+
<ChromeSlot id="branch-bar:root" surface="branch-bar" ctx={{ currentBranch, isLocalDev }}>
|
|
83
|
+
<div className={css.bar} data-branch-bar>
|
|
84
|
+
<div
|
|
85
|
+
className={`${css.barInner}${isLocalDev ? '' : ` ${css.barProd}`}`}
|
|
86
|
+
style={domainColor ? { '--sb-branch-bar-bg': domainColor } : undefined}
|
|
87
|
+
>
|
|
88
|
+
<ChromeSlot id="branch-bar:label" surface="branch-bar" ctx={{ currentBranch, isLocalDev }}>
|
|
89
|
+
<span className={css.barLabel}>
|
|
90
|
+
{isLocalDev && window.__SB_DEV_DOMAIN__ && <>
|
|
91
|
+
<span className={css.barDomainName}>⌘ {window.__SB_DEV_DOMAIN__}</span>
|
|
92
|
+
<span className={css.barSeparator}>·</span>
|
|
93
|
+
</>}
|
|
94
|
+
<GitBranchIcon size={12} />
|
|
95
|
+
<span className={css.barBranchName}>{currentBranch}</span>
|
|
96
|
+
{isLocalDev && <>
|
|
97
|
+
<span className={css.barSeparator}>·</span>
|
|
98
|
+
<span>Local development</span>
|
|
99
|
+
</>}
|
|
100
|
+
</span>
|
|
101
|
+
</ChromeSlot>
|
|
102
|
+
<div className={css.barActions}>
|
|
103
|
+
<ChromeSlot id="branch-bar:hide-button" surface="branch-bar" ctx={{ currentBranch, isLocalDev }}>
|
|
104
|
+
<button className={css.barAction} onClick={hideChrome}>Hide</button>
|
|
105
|
+
</ChromeSlot>
|
|
106
|
+
</div>
|
|
100
107
|
</div>
|
|
101
108
|
</div>
|
|
102
|
-
</
|
|
109
|
+
</ChromeSlot>,
|
|
103
110
|
portalContainer
|
|
104
111
|
)
|
|
105
112
|
}
|
|
@@ -72,6 +72,8 @@ vi.mock('./widgets/widgetConfig.js', async () => {
|
|
|
72
72
|
getAnchorState: () => ({}),
|
|
73
73
|
canAcceptConnection: () => false,
|
|
74
74
|
isSplitScreenCapable: () => false,
|
|
75
|
+
getChromeOptions: () => ({ enabled: true }),
|
|
76
|
+
getInteractionOptions: () => ({ selectable: true, movable: true, resize: null, expandable: false, splitScreen: false, interactGate: false, interactGateLabel: 'Click to interact' }),
|
|
75
77
|
schemas: {},
|
|
76
78
|
getMenuWidgetTypes: () => [],
|
|
77
79
|
getConnectorDefaults: actual.getConnectorDefaults,
|
|
@@ -57,6 +57,8 @@ vi.mock('./widgets/widgetConfig.js', async () => {
|
|
|
57
57
|
getAnchorState: () => ({}),
|
|
58
58
|
canAcceptConnection: () => false,
|
|
59
59
|
isSplitScreenCapable: () => false,
|
|
60
|
+
getChromeOptions: () => ({ enabled: true }),
|
|
61
|
+
getInteractionOptions: () => ({ selectable: true, movable: true, resize: null, expandable: false, splitScreen: false, interactGate: false, interactGateLabel: 'Click to interact' }),
|
|
60
62
|
schemas: {},
|
|
61
63
|
getMenuWidgetTypes: () => [],
|
|
62
64
|
getConnectorDefaults: actual.getConnectorDefaults,
|
|
@@ -6,7 +6,7 @@ import { shouldPreventCanvasTextSelection } from './textSelection.js'
|
|
|
6
6
|
import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
|
|
7
7
|
import { getWidgetComponent } from './widgets/index.js'
|
|
8
8
|
import { schemas, getDefaults } from './widgets/widgetProps.js'
|
|
9
|
-
import { getFeatures, isResizable, isExpandable, getAnchorState, canAcceptConnection, isSplitScreenCapable } from './widgets/widgetConfig.js'
|
|
9
|
+
import { getFeatures, isResizable, isExpandable, getAnchorState, canAcceptConnection, isSplitScreenCapable, getChromeOptions, getInteractionOptions } from './widgets/widgetConfig.js'
|
|
10
10
|
import { createPasteContext, resolvePaste } from './widgets/pasteRules.js'
|
|
11
11
|
import { getPasteRules } from '../../core/index.js'
|
|
12
12
|
import { isTerminalResizable, getTerminalDimensions } from '../../core/index.js'
|
|
@@ -346,7 +346,7 @@ function computeCanvasBounds(widgets, componentEntries) {
|
|
|
346
346
|
}
|
|
347
347
|
|
|
348
348
|
/** Renders a single JSON-defined widget by type lookup. */
|
|
349
|
-
function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefreshGitHub, multiSelected }) {
|
|
349
|
+
function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefreshGitHub, multiSelected, selected, onSelect }) {
|
|
350
350
|
const Component = getWidgetComponent(widget.type)
|
|
351
351
|
if (!Component) {
|
|
352
352
|
console.warn(`[canvas] Unknown widget type: ${widget.type}`)
|
|
@@ -355,7 +355,10 @@ function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefre
|
|
|
355
355
|
const resizable = (widget.type === 'terminal' || widget.type === 'agent')
|
|
356
356
|
? isTerminalResizable(widget.props?.agentId) && !!onUpdate
|
|
357
357
|
: isResizable(widget.type) && !!onUpdate
|
|
358
|
-
//
|
|
358
|
+
// Standard contract: every custom widget component receives these props.
|
|
359
|
+
// selected / onSelect are especially important for chrome-disabled custom
|
|
360
|
+
// widgets that own their visible UI and need to render their own select
|
|
361
|
+
// affordance.
|
|
359
362
|
const elementProps = {
|
|
360
363
|
id: widget.id,
|
|
361
364
|
props: widget.props,
|
|
@@ -364,6 +367,8 @@ function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefre
|
|
|
364
367
|
onRefreshGitHub,
|
|
365
368
|
canRefreshGitHub,
|
|
366
369
|
multiSelected,
|
|
370
|
+
selected,
|
|
371
|
+
onSelect,
|
|
367
372
|
}
|
|
368
373
|
if (Component.$$typeof === Symbol.for('react.forward_ref')) {
|
|
369
374
|
elementProps.ref = widgetRef
|
|
@@ -536,6 +541,12 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
536
541
|
onUpdate?.(widget.id, updates)
|
|
537
542
|
}, [onUpdate, widget.id])
|
|
538
543
|
|
|
544
|
+
// Per-widget chrome + interaction switches from the (consumer-merged)
|
|
545
|
+
// widget definition. Defaults match historical behavior, so core widgets
|
|
546
|
+
// (sticky-note, image, terminal, etc.) are unaffected.
|
|
547
|
+
const chromeOpts = useMemo(() => getChromeOptions(widget.type), [widget.type])
|
|
548
|
+
const interactionOpts = useMemo(() => getInteractionOptions(widget.type), [widget.type])
|
|
549
|
+
|
|
539
550
|
return (
|
|
540
551
|
<WidgetChrome
|
|
541
552
|
widgetId={widget.id}
|
|
@@ -554,6 +565,9 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
554
565
|
currentRole={widget.props?.role || defaultHubRole}
|
|
555
566
|
onRoleChange={onRoleChange ? (roleId) => onRoleChange(widget.id, roleId) : undefined}
|
|
556
567
|
readOnly={readOnly}
|
|
568
|
+
chromeEnabled={chromeOpts.enabled}
|
|
569
|
+
movable={interactionOpts.movable}
|
|
570
|
+
selectable={interactionOpts.selectable}
|
|
557
571
|
>
|
|
558
572
|
<WidgetRenderer
|
|
559
573
|
widget={widget}
|
|
@@ -562,6 +576,8 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
562
576
|
onRefreshGitHub={onRefreshGitHub}
|
|
563
577
|
canRefreshGitHub={canRefreshGitHub}
|
|
564
578
|
multiSelected={multiSelected}
|
|
579
|
+
selected={selected}
|
|
580
|
+
onSelect={onSelect}
|
|
565
581
|
/>
|
|
566
582
|
</WidgetChrome>
|
|
567
583
|
)
|
|
@@ -100,6 +100,8 @@ vi.mock('./widgets/widgetConfig.js', async () => {
|
|
|
100
100
|
isExpandable: () => false,
|
|
101
101
|
isSplitScreenCapable: () => false,
|
|
102
102
|
getInteractGate: () => ({ enabled: false, label: 'Click to interact' }),
|
|
103
|
+
getChromeOptions: () => ({ enabled: true }),
|
|
104
|
+
getInteractionOptions: () => ({ selectable: true, movable: true, resize: null, expandable: false, splitScreen: false, interactGate: false, interactGateLabel: 'Click to interact' }),
|
|
103
105
|
getWidgetMeta: () => null,
|
|
104
106
|
getConnectorConfig: actual.getConnectorConfig,
|
|
105
107
|
getAnchorState: actual.getAnchorState,
|
|
@@ -344,6 +344,25 @@ function ColorPickerFeature({ currentColor, options, onColorChange }) {
|
|
|
344
344
|
* useImperativeHandle(ref, () => ({ handleAction(actionId) { ... } }))
|
|
345
345
|
* WidgetChrome will call widgetRef.current.handleAction(actionId) for
|
|
346
346
|
* non-standard actions (anything other than 'delete').
|
|
347
|
+
*
|
|
348
|
+
* ## Per-widget chrome / interaction switches
|
|
349
|
+
*
|
|
350
|
+
* Three booleans (read from the widget definition by CanvasPage) gate the
|
|
351
|
+
* built-in chrome surface. Defaults match historical behavior so existing
|
|
352
|
+
* widgets are unaffected.
|
|
353
|
+
*
|
|
354
|
+
* - `chromeEnabled` (default true) — When false, the entire WidgetChrome
|
|
355
|
+
* wrapper short-circuits and renders `children` inside a minimal
|
|
356
|
+
* `<div data-widget-id>` shell. No toolbar, no anchors, no select handle,
|
|
357
|
+
* no interact gate. Use this for fully custom client widgets that own
|
|
358
|
+
* their own visual chrome.
|
|
359
|
+
* - `movable` (default true) — When false, the `tc-drag-surface` class is
|
|
360
|
+
* omitted from the inner slot so tiny-canvas won't treat the widget as
|
|
361
|
+
* draggable. Useful for pinned/locked widgets (background stickers,
|
|
362
|
+
* decorative chrome).
|
|
363
|
+
* - `selectable` (default true) — When false, the select handle button is
|
|
364
|
+
* not rendered and select-via-handle is unavailable. Canvas-level click
|
|
365
|
+
* selection (configured elsewhere) is independent of this flag.
|
|
347
366
|
*/
|
|
348
367
|
export default function WidgetChrome({
|
|
349
368
|
widgetId,
|
|
@@ -363,6 +382,9 @@ export default function WidgetChrome({
|
|
|
363
382
|
onRoleChange,
|
|
364
383
|
children,
|
|
365
384
|
readOnly = false,
|
|
385
|
+
chromeEnabled = true,
|
|
386
|
+
movable = true,
|
|
387
|
+
selectable = true,
|
|
366
388
|
}) {
|
|
367
389
|
const [hovered, setHovered] = useState(false)
|
|
368
390
|
const altHeld = useAltKey()
|
|
@@ -461,6 +483,29 @@ export default function WidgetChrome({
|
|
|
461
483
|
onSelect?.()
|
|
462
484
|
}, [onSelect])
|
|
463
485
|
|
|
486
|
+
// ── chrome.enabled: false short-circuit ──
|
|
487
|
+
// Render the widget bare with just a minimal positioning shell. Keep
|
|
488
|
+
// data-widget-id so DOM lookups (anchor port queries, scroll-to-widget,
|
|
489
|
+
// selection state propagation) still work. All hooks above remain in
|
|
490
|
+
// play so React's hook ordering is stable regardless of this flag.
|
|
491
|
+
if (!chromeEnabled) {
|
|
492
|
+
return (
|
|
493
|
+
<div
|
|
494
|
+
className={styles.chromeContainer}
|
|
495
|
+
data-widget-id={widgetId}
|
|
496
|
+
data-widget-chrome-disabled
|
|
497
|
+
data-widget-selected={selected || undefined}
|
|
498
|
+
>
|
|
499
|
+
{children}
|
|
500
|
+
</div>
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Drag surface class is omitted when `movable: false` — tiny-canvas only
|
|
505
|
+
// initiates a drag from elements matching `data-tc-handle=".tc-drag-handle,
|
|
506
|
+
// .tc-drag-surface"`, so dropping the class effectively pins the widget.
|
|
507
|
+
const slotDragClass = movable ? 'tc-drag-surface' : ''
|
|
508
|
+
|
|
464
509
|
return (
|
|
465
510
|
<div
|
|
466
511
|
className={styles.chromeContainer}
|
|
@@ -470,7 +515,7 @@ export default function WidgetChrome({
|
|
|
470
515
|
onMouseEnter={(readOnly && !hasFeatures) ? undefined : handleMouseEnter}
|
|
471
516
|
onMouseLeave={(readOnly && !hasFeatures) ? undefined : handleMouseLeave}
|
|
472
517
|
>
|
|
473
|
-
<div ref={slotRef} className={
|
|
518
|
+
<div ref={slotRef} className={`${slotDragClass} ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`} data-widget-selected={selected || undefined} data-widget-interacting={interacting || undefined}>
|
|
474
519
|
{children}
|
|
475
520
|
{gate.enabled && !interacting && !readOnly && (
|
|
476
521
|
<div
|
|
@@ -645,12 +690,12 @@ export default function WidgetChrome({
|
|
|
645
690
|
</div>
|
|
646
691
|
)}
|
|
647
692
|
|
|
648
|
-
{!readOnly && (
|
|
649
|
-
<Tooltip text={selected ? "Click and drag to move" : "Select"} direction="n">
|
|
693
|
+
{!readOnly && selectable && (
|
|
694
|
+
<Tooltip text={selected ? (movable ? "Click and drag to move" : "Selected") : "Select"} direction="n">
|
|
650
695
|
<button
|
|
651
|
-
className={
|
|
696
|
+
className={`${movable ? 'tc-drag-handle ' : ''}${styles.selectHandle} ${selected ? styles.selectHandleActive : ''}`}
|
|
652
697
|
onClick={handleHandleClick}
|
|
653
|
-
aria-label={selected ? "Drag to move widget" : "Select widget"}
|
|
698
|
+
aria-label={selected ? (movable ? "Drag to move widget" : "Widget selected") : "Select widget"}
|
|
654
699
|
aria-pressed={selected}
|
|
655
700
|
/>
|
|
656
701
|
</Tooltip>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render } from '@testing-library/react'
|
|
3
|
+
import WidgetChrome from './WidgetChrome.jsx'
|
|
4
|
+
|
|
5
|
+
function renderChrome(extra = {}) {
|
|
6
|
+
return render(
|
|
7
|
+
<WidgetChrome widgetId="w1" widgetType="sticky-note" {...extra}>
|
|
8
|
+
<div data-testid="inner">inner</div>
|
|
9
|
+
</WidgetChrome>
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('WidgetChrome chrome/interaction switches', () => {
|
|
14
|
+
it('renders the full chrome shell by default', () => {
|
|
15
|
+
const { container, getByTestId } = renderChrome()
|
|
16
|
+
expect(getByTestId('inner')).toBeTruthy()
|
|
17
|
+
expect(container.querySelector('[data-widget-id="w1"]')).toBeTruthy()
|
|
18
|
+
// Drag surface is present (allows tiny-canvas drag) and select handle exists
|
|
19
|
+
expect(container.querySelector('.tc-drag-surface')).toBeTruthy()
|
|
20
|
+
expect(container.querySelector('.tc-drag-handle')).toBeTruthy()
|
|
21
|
+
// Not flagged as chrome-disabled
|
|
22
|
+
expect(container.querySelector('[data-widget-chrome-disabled]')).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('chromeEnabled=false short-circuits to a minimal shell', () => {
|
|
26
|
+
const { container, getByTestId } = renderChrome({ chromeEnabled: false })
|
|
27
|
+
expect(getByTestId('inner')).toBeTruthy()
|
|
28
|
+
const root = container.querySelector('[data-widget-id="w1"]')
|
|
29
|
+
expect(root).toBeTruthy()
|
|
30
|
+
expect(root.hasAttribute('data-widget-chrome-disabled')).toBe(true)
|
|
31
|
+
// No toolbar, no anchors, no select handle
|
|
32
|
+
expect(container.querySelector('.tc-drag-surface')).toBeNull()
|
|
33
|
+
expect(container.querySelector('.tc-drag-handle')).toBeNull()
|
|
34
|
+
// Inner content rendered directly
|
|
35
|
+
expect(root.contains(getByTestId('inner'))).toBe(true)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('chromeEnabled=false propagates selected state for custom widgets', () => {
|
|
39
|
+
const { container } = renderChrome({ chromeEnabled: false, selected: true })
|
|
40
|
+
const root = container.querySelector('[data-widget-id="w1"]')
|
|
41
|
+
expect(root.getAttribute('data-widget-selected')).toBe('true')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('movable=false omits the tc-drag-surface class from the slot', () => {
|
|
45
|
+
const { container } = renderChrome({ movable: false })
|
|
46
|
+
expect(container.querySelector('.tc-drag-surface')).toBeNull()
|
|
47
|
+
// Select handle still renders (selectable default true) and is no longer
|
|
48
|
+
// a drag handle — should NOT carry tc-drag-handle.
|
|
49
|
+
const handle = container.querySelector('button[aria-label*="Select"], button[aria-label*="selected"], button[aria-pressed]')
|
|
50
|
+
expect(handle).toBeTruthy()
|
|
51
|
+
expect(handle.className.includes('tc-drag-handle')).toBe(false)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('selectable=false hides the select handle', () => {
|
|
55
|
+
const { container } = renderChrome({ selectable: false })
|
|
56
|
+
// The select-handle button has aria-pressed; no other button in chrome
|
|
57
|
+
// exposes aria-pressed by default (color picker, etc. use aria-label only)
|
|
58
|
+
expect(container.querySelector('button[aria-pressed]')).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('chromeEnabled=false skips select handle even when selectable=true', () => {
|
|
62
|
+
const { container } = renderChrome({ chromeEnabled: false, selectable: true })
|
|
63
|
+
expect(container.querySelector('button[aria-pressed]')).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
})
|