@dfosco/storyboard 0.11.4 → 0.11.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard",
3
- "version": "0.11.4",
3
+ "version": "0.11.5",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -0,0 +1,270 @@
1
+ import {
2
+ createElement,
3
+ forwardRef,
4
+ useCallback,
5
+ useEffect,
6
+ useId,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from 'react'
11
+ import { getWidgetComponent } from './index.js'
12
+ import styles from './Widget.module.css'
13
+
14
+ /**
15
+ * `<Widget>` — render any registered canvas widget inside a normal React page.
16
+ *
17
+ * Canvas widgets follow a stable contract: they resolve from a `type` string
18
+ * (via `getWidgetComponent`), read their fields from a flat `props` object, and
19
+ * render chrome-less (the canvas applies toolbar/anchors/selection externally).
20
+ * This component reproduces that contract off-canvas so any widget can be used
21
+ * as a first-class element in a page:
22
+ *
23
+ * <Widget type="markdown" content="# Hi" className="prose" />
24
+ * <Widget type="prototype" src="StartupSignup" width={900} height={600} />
25
+ * <Widget type="sticky-note" text="Note" color="yellow" draggable resizable />
26
+ *
27
+ * Prop mapping
28
+ * ------------
29
+ * Reserved props configure the wrapper / contract and are NOT forwarded into
30
+ * the widget's `props` object:
31
+ * type, id, className, style, onUpdate, widgetRef, children,
32
+ * draggable, resizable, chrome
33
+ * Every OTHER prop is spread into the widget `props` object, so all widget
34
+ * fields (content, settings, size — unique or not) are passable as props.
35
+ *
36
+ * onUpdate
37
+ * --------
38
+ * Defaults to internal local state (uncontrolled): widgets that self-heal or
39
+ * edit through `onUpdate(partial)` keep working, and resize/drag persist for
40
+ * the lifetime of the element. Pass your own `onUpdate` for controlled mode —
41
+ * it receives the same partial-merge object and you own the props.
42
+ *
43
+ * chrome
44
+ * ------
45
+ * `chrome` (default true) renders the canvas-style selection outline and the
46
+ * drag/select handle (the trigger dot that reveals a grab handle on hover),
47
+ * matching how widgets look on the canvas — minus the action menu. Set
48
+ * `chrome={false}` for a bare widget with no selection affordance.
49
+ *
50
+ * draggable / resizable
51
+ * ---------------------
52
+ * `resizable` (default false) is forwarded to the widget, which renders its own
53
+ * ResizeHandle and persists width/height through `onUpdate`. `draggable`
54
+ * (default false) is a page-level affordance: the widget body and the select
55
+ * handle become pointer-draggable and the offset is applied as a CSS translate.
56
+ */
57
+
58
+ const RESERVED = new Set([
59
+ 'type',
60
+ 'id',
61
+ 'className',
62
+ 'style',
63
+ 'onUpdate',
64
+ 'widgetRef',
65
+ 'children',
66
+ 'draggable',
67
+ 'resizable',
68
+ 'chrome',
69
+ ])
70
+
71
+ const DRAG_THRESHOLD = 3
72
+
73
+ function isForwardRef(Component) {
74
+ return Component != null && Component.$$typeof === Symbol.for('react.forward_ref')
75
+ }
76
+
77
+ function Widget(
78
+ {
79
+ type,
80
+ id,
81
+ className,
82
+ style,
83
+ onUpdate,
84
+ draggable = false,
85
+ resizable = false,
86
+ chrome = true,
87
+ ...rest
88
+ },
89
+ ref,
90
+ ) {
91
+ const autoId = useId()
92
+ const widgetId = id || `widget-${type || 'unknown'}-${autoId}`
93
+
94
+ // Collect every non-reserved prop into the flat widget props object.
95
+ const incomingProps = useMemo(() => {
96
+ const out = {}
97
+ for (const key of Object.keys(rest)) {
98
+ if (!RESERVED.has(key)) out[key] = rest[key]
99
+ }
100
+ return out
101
+ // eslint-disable-next-line react-hooks/exhaustive-deps
102
+ }, [JSON.stringify(rest)])
103
+
104
+ // Local override state powers the default (uncontrolled) onUpdate so that
105
+ // self-healing widgets, resizing, and editing all persist without a canvas.
106
+ const [overrides, setOverrides] = useState(null)
107
+
108
+ const handleUpdate = useCallback(
109
+ (partial) => {
110
+ if (!partial || typeof partial !== 'object') return
111
+ if (typeof onUpdate === 'function') {
112
+ onUpdate(partial)
113
+ return
114
+ }
115
+ setOverrides((prev) => ({ ...(prev || {}), ...partial }))
116
+ },
117
+ [onUpdate],
118
+ )
119
+
120
+ const effectiveProps = useMemo(
121
+ () => (overrides ? { ...incomingProps, ...overrides } : incomingProps),
122
+ [incomingProps, overrides],
123
+ )
124
+
125
+ // Selection + drag offset (canvas-style chrome affordances).
126
+ const [selected, setSelected] = useState(false)
127
+ const [hovered, setHovered] = useState(false)
128
+ const [offset, setOffset] = useState({ x: 0, y: 0 })
129
+ const offsetRef = useRef(offset)
130
+ offsetRef.current = offset
131
+ const containerRef = useRef(null)
132
+
133
+ const selectWidget = useCallback(() => setSelected(true), [])
134
+
135
+ // Click anywhere outside the widget deselects it.
136
+ useEffect(() => {
137
+ if (!chrome || !selected) return undefined
138
+ const onDocPointerDown = (e) => {
139
+ if (containerRef.current && !containerRef.current.contains(e.target)) {
140
+ setSelected(false)
141
+ }
142
+ }
143
+ document.addEventListener('pointerdown', onDocPointerDown)
144
+ return () => document.removeEventListener('pointerdown', onDocPointerDown)
145
+ }, [chrome, selected])
146
+
147
+ // Shared drag/click handler. `fromHandle` distinguishes a click on the select
148
+ // handle (selects when no drag occurs) from a body drag.
149
+ const beginPointer = useCallback(
150
+ (e, fromHandle) => {
151
+ // Let interactive controls (inputs, links, resize handle…) keep working.
152
+ if (
153
+ !fromHandle &&
154
+ e.target.closest(
155
+ 'input, textarea, select, button, a, [contenteditable="true"], [data-widget-resize-handle]',
156
+ )
157
+ ) {
158
+ return
159
+ }
160
+ if (fromHandle) e.stopPropagation()
161
+ if (!draggable && !fromHandle) return
162
+
163
+ const startX = e.clientX
164
+ const startY = e.clientY
165
+ const start = offsetRef.current
166
+ let moved = false
167
+
168
+ const move = (ev) => {
169
+ const dx = ev.clientX - startX
170
+ const dy = ev.clientY - startY
171
+ if (!moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return
172
+ moved = true
173
+ if (draggable) setOffset({ x: start.x + dx, y: start.y + dy })
174
+ }
175
+ const up = () => {
176
+ document.removeEventListener('pointermove', move)
177
+ document.removeEventListener('pointerup', up)
178
+ if (fromHandle && !moved) selectWidget()
179
+ }
180
+ document.addEventListener('pointermove', move)
181
+ document.addEventListener('pointerup', up)
182
+ },
183
+ [draggable, selectWidget],
184
+ )
185
+
186
+ const Component = getWidgetComponent(type)
187
+ if (!Component) {
188
+ if (typeof console !== 'undefined') {
189
+ console.warn(`[storyboard] <Widget>: unknown widget type "${type}"`)
190
+ }
191
+ return null
192
+ }
193
+
194
+ const elementProps = {
195
+ id: widgetId,
196
+ props: effectiveProps,
197
+ onUpdate: handleUpdate,
198
+ resizable: !!resizable,
199
+ selected: chrome ? selected : false,
200
+ onSelect: chrome ? selectWidget : () => {},
201
+ }
202
+ if (isForwardRef(Component)) {
203
+ elementProps.ref = ref
204
+ }
205
+
206
+ const containerStyle = {
207
+ ...style,
208
+ ...(draggable
209
+ ? {
210
+ transform: `translate(${offset.x}px, ${offset.y}px)`,
211
+ touchAction: 'none',
212
+ }
213
+ : null),
214
+ }
215
+
216
+ const showHandle = chrome && (hovered || selected)
217
+
218
+ return (
219
+ <div
220
+ ref={containerRef}
221
+ className={[
222
+ styles.widget,
223
+ chrome ? styles.chromeContainer : '',
224
+ draggable ? styles.draggable : '',
225
+ className,
226
+ ]
227
+ .filter(Boolean)
228
+ .join(' ')}
229
+ style={containerStyle}
230
+ data-widget-type={type}
231
+ data-widget-selected={chrome && selected ? '' : undefined}
232
+ onPointerDown={draggable ? (e) => beginPointer(e, false) : undefined}
233
+ onClick={chrome ? selectWidget : undefined}
234
+ onMouseEnter={chrome ? () => setHovered(true) : undefined}
235
+ onMouseLeave={chrome ? () => setHovered(false) : undefined}
236
+ >
237
+ <div
238
+ className={[styles.widgetSlot, chrome && selected ? styles.widgetSlotSelected : '']
239
+ .filter(Boolean)
240
+ .join(' ')}
241
+ >
242
+ {createElement(Component, elementProps)}
243
+ </div>
244
+
245
+ {chrome && (
246
+ <div className={styles.toolbar}>
247
+ <span
248
+ className={`${styles.triggerDot} ${showHandle ? styles.triggerDotHidden : ''}`}
249
+ aria-hidden="true"
250
+ />
251
+ <div className={`${styles.toolbarContent} ${showHandle ? styles.toolbarContentVisible : ''}`}>
252
+ <button
253
+ type="button"
254
+ className={`${styles.selectHandle} ${selected ? styles.selectHandleActive : ''} ${
255
+ draggable ? styles.selectHandleDraggable : ''
256
+ }`}
257
+ onPointerDown={(e) => beginPointer(e, true)}
258
+ aria-label={
259
+ selected ? (draggable ? 'Drag to move widget' : 'Widget selected') : 'Select widget'
260
+ }
261
+ aria-pressed={selected}
262
+ />
263
+ </div>
264
+ </div>
265
+ )}
266
+ </div>
267
+ )
268
+ }
269
+
270
+ export default forwardRef(Widget)
@@ -0,0 +1,132 @@
1
+ /* <Widget> — page-embeddable canvas widget.
2
+ Mirrors the canvas WidgetChrome selection outline + drag/select handle,
3
+ minus the action menu. */
4
+
5
+ .widget {
6
+ display: inline-block;
7
+ position: relative;
8
+ max-width: 100%;
9
+ }
10
+
11
+ .chromeContainer {
12
+ position: relative;
13
+ }
14
+
15
+ .draggable {
16
+ user-select: none;
17
+ }
18
+
19
+ /* Widget slot — selection outline targets this. */
20
+ .widgetSlot {
21
+ position: relative;
22
+ border-radius: 4px;
23
+ }
24
+
25
+ .widgetSlotSelected {
26
+ outline: 4px solid var(--color-background-accent-emphasis, #2f81f7);
27
+ outline-offset: 2px;
28
+ border-radius: 4px;
29
+ }
30
+
31
+ /* Toolbar — absolutely positioned below the widget so it never affects the
32
+ widget's box dimensions. */
33
+ .toolbar {
34
+ display: flex;
35
+ align-items: center;
36
+ justify-content: flex-end;
37
+ height: 28px;
38
+ position: absolute;
39
+ left: 0;
40
+ right: 0;
41
+ top: calc(100% + 10px);
42
+ }
43
+
44
+ /* Invisible hit-area bridging the 10px gap so hover survives the pointer
45
+ crossing the padding between the widget and its handle. */
46
+ .toolbar::before {
47
+ content: '';
48
+ position: absolute;
49
+ left: 0;
50
+ right: 0;
51
+ bottom: 100%;
52
+ height: 10px;
53
+ pointer-events: auto;
54
+ }
55
+
56
+ /* Trigger dot — visible at rest, hidden once the handle is revealed. */
57
+ .triggerDot {
58
+ width: 6px;
59
+ height: 6px;
60
+ border-radius: 50%;
61
+ background: var(--color-border-muted, #d0d7de);
62
+ opacity: 0.5;
63
+ margin-left: auto;
64
+ transition: opacity 120ms;
65
+ }
66
+
67
+ :global([data-sb-canvas-theme^='dark']) .triggerDot {
68
+ background: var(--color-border-muted, #373e47);
69
+ opacity: 0.6;
70
+ }
71
+
72
+ .triggerDotHidden {
73
+ opacity: 0;
74
+ pointer-events: none;
75
+ }
76
+
77
+ /* Toolbar content — holds the select handle. */
78
+ .toolbarContent {
79
+ display: flex;
80
+ align-items: center;
81
+ justify-content: flex-end;
82
+ opacity: 0;
83
+ pointer-events: none;
84
+ transition: opacity 120ms;
85
+ }
86
+
87
+ .toolbarContentVisible {
88
+ opacity: 1;
89
+ pointer-events: auto;
90
+ }
91
+
92
+ /* Select handle — rounded rect. Click selects; drag (when draggable) moves. */
93
+ .selectHandle {
94
+ all: unset;
95
+ cursor: pointer;
96
+ width: 16px;
97
+ height: 16px;
98
+ border-radius: 4px;
99
+ border: 1.6px solid var(--color-border-muted, #d0d7de);
100
+ background: var(--color-background, #ffffff);
101
+ transition: background 100ms, border-color 100ms;
102
+ flex-shrink: 0;
103
+ }
104
+
105
+ .selectHandleDraggable {
106
+ cursor: grab;
107
+ }
108
+
109
+ .selectHandleDraggable:active {
110
+ cursor: grabbing;
111
+ }
112
+
113
+ :global([data-sb-canvas-theme^='dark']) .selectHandle {
114
+ background: var(--color-background-muted, #161b22);
115
+ border-color: var(--color-border-muted, #373e47);
116
+ }
117
+
118
+ .selectHandle:hover {
119
+ border-color: var(--color-background-accent-emphasis, #2f81f7);
120
+ }
121
+
122
+ .selectHandleActive,
123
+ :global([data-sb-canvas-theme^='dark']) .selectHandleActive {
124
+ background: var(--color-background-accent-emphasis, #2f81f7);
125
+ border-color: var(--color-background-accent-emphasis, #2f81f7);
126
+ }
127
+
128
+ .selectHandleActive:hover,
129
+ :global([data-sb-canvas-theme^='dark']) .selectHandleActive:hover {
130
+ background: var(--color-background-accent-emphasis, #388bfd);
131
+ border-color: var(--color-background-accent-emphasis, #388bfd);
132
+ }
@@ -0,0 +1,186 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import Widget from './Widget.jsx'
4
+ import {
5
+ registerWidget,
6
+ _resetWidgetRegistry,
7
+ } from '../../../core/stores/widgetRegistry.js'
8
+
9
+ afterEach(() => {
10
+ _resetWidgetRegistry()
11
+ vi.restoreAllMocks()
12
+ })
13
+
14
+ // A minimal probe widget that renders its props and exposes onUpdate so we can
15
+ // assert the standard contract the dispatcher supplies.
16
+ function ProbeWidget({ id, props, onUpdate, resizable }) {
17
+ return (
18
+ <div data-testid="probe" data-id={id} data-resizable={String(resizable)}>
19
+ <span data-testid="text">{props?.text ?? ''}</span>
20
+ <span data-testid="width">{String(props?.width ?? '')}</span>
21
+ <button type="button" onClick={() => onUpdate({ text: 'updated' })}>
22
+ update
23
+ </button>
24
+ </div>
25
+ )
26
+ }
27
+
28
+ describe('<Widget>', () => {
29
+ it('resolves a registered type and maps non-reserved props into the widget props object', () => {
30
+ registerWidget('probe', { component: ProbeWidget })
31
+
32
+ render(<Widget type="probe" text="hello" width={320} />)
33
+
34
+ expect(screen.getByTestId('probe')).toBeInTheDocument()
35
+ expect(screen.getByTestId('text').textContent).toBe('hello')
36
+ expect(screen.getByTestId('width').textContent).toBe('320')
37
+ })
38
+
39
+ it('applies className and data-widget-type to the wrapper', () => {
40
+ registerWidget('probe', { component: ProbeWidget })
41
+
42
+ const { container } = render(
43
+ <Widget type="probe" className="my-class" text="x" />,
44
+ )
45
+
46
+ const wrapper = container.querySelector('[data-widget-type="probe"]')
47
+ expect(wrapper).toBeTruthy()
48
+ expect(wrapper.className).toContain('my-class')
49
+ })
50
+
51
+ it('forwards the resizable prop to the widget', () => {
52
+ registerWidget('probe', { component: ProbeWidget })
53
+
54
+ render(<Widget type="probe" resizable text="x" />)
55
+ expect(screen.getByTestId('probe').getAttribute('data-resizable')).toBe('true')
56
+
57
+ _resetWidgetRegistry()
58
+ registerWidget('probe', { component: ProbeWidget })
59
+ render(<Widget type="probe" text="y" />)
60
+ const probes = screen.getAllByTestId('probe')
61
+ expect(probes[probes.length - 1].getAttribute('data-resizable')).toBe('false')
62
+ })
63
+
64
+ it('defaults onUpdate to internal local state (uncontrolled)', () => {
65
+ registerWidget('probe', { component: ProbeWidget })
66
+
67
+ render(<Widget type="probe" text="initial" />)
68
+ expect(screen.getByTestId('text').textContent).toBe('initial')
69
+
70
+ fireEvent.click(screen.getByText('update'))
71
+ expect(screen.getByTestId('text').textContent).toBe('updated')
72
+ })
73
+
74
+ it('uses a consumer-supplied onUpdate when provided (controlled)', () => {
75
+ registerWidget('probe', { component: ProbeWidget })
76
+ const onUpdate = vi.fn()
77
+
78
+ render(<Widget type="probe" text="initial" onUpdate={onUpdate} />)
79
+ fireEvent.click(screen.getByText('update'))
80
+
81
+ expect(onUpdate).toHaveBeenCalledWith({ text: 'updated' })
82
+ // Controlled: local state is not applied, text stays as the incoming prop.
83
+ expect(screen.getByTestId('text').textContent).toBe('initial')
84
+ })
85
+
86
+ it('does not forward reserved props into the widget props object', () => {
87
+ function PropsSpy({ props }) {
88
+ return <pre data-testid="json">{JSON.stringify(props)}</pre>
89
+ }
90
+ registerWidget('spy', { component: PropsSpy })
91
+
92
+ render(
93
+ <Widget
94
+ type="spy"
95
+ id="fixed-id"
96
+ className="c"
97
+ draggable
98
+ resizable
99
+ text="keep"
100
+ />,
101
+ )
102
+
103
+ const parsed = JSON.parse(screen.getByTestId('json').textContent)
104
+ expect(parsed).toEqual({ text: 'keep' })
105
+ })
106
+
107
+ it('renders nothing and warns for an unknown type', () => {
108
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
109
+ const { container } = render(<Widget type="does-not-exist" />)
110
+
111
+ expect(container.firstChild).toBeNull()
112
+ expect(warn).toHaveBeenCalledWith(
113
+ expect.stringContaining('unknown widget type "does-not-exist"'),
114
+ )
115
+ })
116
+
117
+ it('applies a translate transform to the wrapper when draggable is set', () => {
118
+ registerWidget('probe', { component: ProbeWidget })
119
+
120
+ const { container } = render(<Widget type="probe" draggable text="x" />)
121
+ const wrapper = container.querySelector('[data-widget-type="probe"]')
122
+ expect(wrapper.style.transform).toContain('translate(')
123
+ })
124
+
125
+ it('renders the chrome select handle by default and selects on handle click', () => {
126
+ registerWidget('probe', { component: ProbeWidget })
127
+
128
+ const { container } = render(<Widget type="probe" text="x" />)
129
+ const handle = container.querySelector('[aria-label="Select widget"]')
130
+ expect(handle).toBeTruthy()
131
+ expect(handle.getAttribute('aria-pressed')).toBe('false')
132
+
133
+ // pointerDown without movement selects on pointerUp.
134
+ fireEvent.pointerDown(handle, { clientX: 0, clientY: 0 })
135
+ fireEvent.pointerUp(document, { clientX: 0, clientY: 0 })
136
+
137
+ expect(container.querySelector('[aria-pressed="true"]')).toBeTruthy()
138
+ expect(container.querySelector('[data-widget-selected]')).toBeTruthy()
139
+ })
140
+
141
+ it('selects when clicking anywhere on the widget body', () => {
142
+ registerWidget('probe', { component: ProbeWidget })
143
+
144
+ const { container } = render(<Widget type="probe" text="x" />)
145
+ expect(container.querySelector('[data-widget-selected]')).toBeNull()
146
+
147
+ fireEvent.click(screen.getByTestId('probe'))
148
+ expect(container.querySelector('[data-widget-selected]')).toBeTruthy()
149
+ })
150
+
151
+ it('deselects when clicking outside the widget', () => {
152
+ registerWidget('probe', { component: ProbeWidget })
153
+
154
+ const { container } = render(
155
+ <div>
156
+ <Widget type="probe" text="x" />
157
+ <button type="button">outside</button>
158
+ </div>,
159
+ )
160
+
161
+ fireEvent.click(screen.getByTestId('probe'))
162
+ expect(container.querySelector('[data-widget-selected]')).toBeTruthy()
163
+
164
+ fireEvent.pointerDown(screen.getByText('outside'))
165
+ expect(container.querySelector('[data-widget-selected]')).toBeNull()
166
+ })
167
+
168
+ it('omits chrome (no handle, no selection) when chrome={false}', () => {
169
+ registerWidget('probe', { component: ProbeWidget })
170
+
171
+ const { container } = render(<Widget type="probe" chrome={false} text="x" />)
172
+ expect(container.querySelector('[aria-label="Select widget"]')).toBeNull()
173
+ expect(screen.getByTestId('probe')).toBeInTheDocument()
174
+ })
175
+
176
+ it('does not forward the chrome prop into the widget props object', () => {
177
+ function PropsSpy({ props }) {
178
+ return <pre data-testid="json">{JSON.stringify(props)}</pre>
179
+ }
180
+ registerWidget('spy', { component: PropsSpy })
181
+
182
+ render(<Widget type="spy" chrome text="keep" />)
183
+ const parsed = JSON.parse(screen.getByTestId('json').textContent)
184
+ expect(parsed).toEqual({ text: 'keep' })
185
+ })
186
+ })
@@ -62,6 +62,10 @@ export { default as AuthModal } from './AuthModal/AuthModal.jsx'
62
62
  export { default as CanvasPage } from './canvas/CanvasPage.jsx'
63
63
  export { useCanvas } from './canvas/useCanvas.js'
64
64
 
65
+ // Widget — render any registered canvas widget inside a normal React page.
66
+ export { default as Widget } from './canvas/widgets/Widget.jsx'
67
+ export { getWidgetComponent } from './canvas/widgets/index.js'
68
+
65
69
  // Error boundaries
66
70
  export { default as PrototypeErrorBoundary, ImportErrorFallback, AppErrorBoundary } from './PrototypeErrorBoundary.jsx'
67
71