@dfosco/storyboard-react 3.6.1 → 3.8.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/package.json +2 -2
- package/src/canvas/CanvasPage.bridge.test.jsx +205 -0
- package/src/canvas/CanvasPage.jsx +143 -25
- package/src/canvas/CanvasPage.module.css +2 -2
- package/src/canvas/canvasTheme.js +74 -0
- package/src/canvas/textSelection.js +10 -0
- package/src/canvas/textSelection.test.js +26 -0
- package/src/canvas/useCanvas.js +20 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +1 -0
- package/src/canvas/widgets/MarkdownBlock.module.css +14 -5
- package/src/canvas/widgets/PrototypeEmbed.jsx +35 -2
- package/src/canvas/widgets/PrototypeEmbed.module.css +4 -16
- package/src/canvas/widgets/PrototypeEmbed.test.jsx +10 -0
- package/src/canvas/widgets/StickyNote.jsx +1 -1
- package/src/canvas/widgets/StickyNote.module.css +21 -0
- package/src/canvas/widgets/embedTheme.js +49 -0
- package/src/context.jsx +13 -3
- package/src/vite/data-plugin.js +100 -10
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "3.
|
|
6
|
+
"@dfosco/storyboard-core": "3.8.0",
|
|
7
7
|
"@dfosco/tiny-canvas": "^1.1.0",
|
|
8
8
|
"@neodrag/react": "^2.3.1",
|
|
9
9
|
"glob": "^11.0.0",
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
2
|
+
import CanvasPage from './CanvasPage.jsx'
|
|
3
|
+
import { getCanvasPrimerAttrs, getCanvasThemeVars } from './canvasTheme.js'
|
|
4
|
+
import { updateCanvas } from './canvasApi.js'
|
|
5
|
+
|
|
6
|
+
vi.mock('@dfosco/tiny-canvas', () => ({
|
|
7
|
+
Canvas: ({ children, onDragEnd }) => (
|
|
8
|
+
<div data-testid="tiny-canvas">
|
|
9
|
+
{children}
|
|
10
|
+
<button
|
|
11
|
+
data-testid="drag-widget"
|
|
12
|
+
onClick={() => onDragEnd?.('widget-1', { x: 111.4, y: 222.7 })}
|
|
13
|
+
>
|
|
14
|
+
drag widget
|
|
15
|
+
</button>
|
|
16
|
+
<button
|
|
17
|
+
data-testid="drag-source"
|
|
18
|
+
onClick={() => onDragEnd?.('jsx-PrimaryButtons', { x: 333.2, y: 444.8 })}
|
|
19
|
+
>
|
|
20
|
+
drag source
|
|
21
|
+
</button>
|
|
22
|
+
</div>
|
|
23
|
+
),
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
const mockCanvas = {
|
|
27
|
+
title: 'Bridge Test Canvas',
|
|
28
|
+
widgets: [{ id: 'widget-1', type: 'mock-widget', position: { x: 10, y: 20 }, props: {} }],
|
|
29
|
+
sources: [{ export: 'PrimaryButtons', position: { x: 1, y: 2 } }],
|
|
30
|
+
centered: false,
|
|
31
|
+
dotted: false,
|
|
32
|
+
grid: false,
|
|
33
|
+
gridSize: 18,
|
|
34
|
+
colorMode: 'auto',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
vi.mock('./useCanvas.js', () => ({
|
|
38
|
+
useCanvas: () => ({
|
|
39
|
+
canvas: mockCanvas,
|
|
40
|
+
jsxExports: {
|
|
41
|
+
PrimaryButtons: () => <div data-testid="jsx-widget-content">jsx widget</div>,
|
|
42
|
+
},
|
|
43
|
+
loading: false,
|
|
44
|
+
}),
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
vi.mock('./widgets/index.js', () => ({
|
|
48
|
+
getWidgetComponent: () => function MockWidget() { return <div>mock widget</div> },
|
|
49
|
+
}))
|
|
50
|
+
|
|
51
|
+
vi.mock('./widgets/widgetProps.js', () => ({
|
|
52
|
+
schemas: {},
|
|
53
|
+
getDefaults: () => ({}),
|
|
54
|
+
}))
|
|
55
|
+
|
|
56
|
+
vi.mock('./canvasApi.js', () => ({
|
|
57
|
+
addWidget: vi.fn(),
|
|
58
|
+
updateCanvas: vi.fn(() => Promise.resolve({ success: true })),
|
|
59
|
+
removeWidget: vi.fn(),
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
describe('CanvasPage canvas bridge', () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
delete window.__storyboardCanvasBridgeState
|
|
65
|
+
vi.clearAllMocks()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('publishes bridge state and responds to status requests', () => {
|
|
69
|
+
const mountedHandler = vi.fn()
|
|
70
|
+
const statusHandler = vi.fn()
|
|
71
|
+
document.addEventListener('storyboard:canvas:mounted', mountedHandler)
|
|
72
|
+
document.addEventListener('storyboard:canvas:status', statusHandler)
|
|
73
|
+
|
|
74
|
+
const { unmount } = render(<CanvasPage name="design-overview" />)
|
|
75
|
+
|
|
76
|
+
expect(window.__storyboardCanvasBridgeState).toEqual({
|
|
77
|
+
active: true,
|
|
78
|
+
name: 'design-overview',
|
|
79
|
+
zoom: 100,
|
|
80
|
+
})
|
|
81
|
+
expect(mountedHandler).toHaveBeenCalled()
|
|
82
|
+
|
|
83
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:status-request'))
|
|
84
|
+
expect(statusHandler).toHaveBeenCalled()
|
|
85
|
+
expect(statusHandler.mock.calls.at(-1)?.[0]?.detail).toEqual({
|
|
86
|
+
active: true,
|
|
87
|
+
name: 'design-overview',
|
|
88
|
+
zoom: 100,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
unmount()
|
|
92
|
+
|
|
93
|
+
document.removeEventListener('storyboard:canvas:mounted', mountedHandler)
|
|
94
|
+
document.removeEventListener('storyboard:canvas:status', statusHandler)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('marks bridge inactive on unmount', () => {
|
|
98
|
+
const unmountedHandler = vi.fn()
|
|
99
|
+
document.addEventListener('storyboard:canvas:unmounted', unmountedHandler)
|
|
100
|
+
|
|
101
|
+
const { unmount } = render(<CanvasPage name="design-overview" />)
|
|
102
|
+
unmount()
|
|
103
|
+
|
|
104
|
+
expect(unmountedHandler).toHaveBeenCalled()
|
|
105
|
+
expect(window.__storyboardCanvasBridgeState).toEqual({
|
|
106
|
+
active: false,
|
|
107
|
+
name: '',
|
|
108
|
+
zoom: 100,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
document.removeEventListener('storyboard:canvas:unmounted', unmountedHandler)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('persists dragged JSON widgets and JSX sources to canvas JSONL via update API', async () => {
|
|
115
|
+
render(<CanvasPage name="design-overview" />)
|
|
116
|
+
|
|
117
|
+
fireEvent.click(screen.getByTestId('drag-widget'))
|
|
118
|
+
await waitFor(() => {
|
|
119
|
+
expect(updateCanvas).toHaveBeenCalledWith(
|
|
120
|
+
'design-overview',
|
|
121
|
+
expect.objectContaining({
|
|
122
|
+
widgets: expect.arrayContaining([
|
|
123
|
+
expect.objectContaining({
|
|
124
|
+
id: 'widget-1',
|
|
125
|
+
position: { x: 111, y: 223 },
|
|
126
|
+
}),
|
|
127
|
+
]),
|
|
128
|
+
})
|
|
129
|
+
)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
fireEvent.click(screen.getByTestId('drag-source'))
|
|
133
|
+
await waitFor(() => {
|
|
134
|
+
expect(updateCanvas).toHaveBeenCalledWith(
|
|
135
|
+
'design-overview',
|
|
136
|
+
expect.objectContaining({
|
|
137
|
+
sources: expect.arrayContaining([
|
|
138
|
+
expect.objectContaining({
|
|
139
|
+
export: 'PrimaryButtons',
|
|
140
|
+
position: { x: 333, y: 445 },
|
|
141
|
+
}),
|
|
142
|
+
]),
|
|
143
|
+
})
|
|
144
|
+
)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('getCanvasThemeVars', () => {
|
|
150
|
+
it('returns a distinct dark-dimmed background token', () => {
|
|
151
|
+
expect(getCanvasThemeVars('light')['--sb--canvas-bg']).toBe('#f6f8fa')
|
|
152
|
+
expect(getCanvasThemeVars('light')['--tc-bg-muted']).toBe('#f6f8fa')
|
|
153
|
+
expect(getCanvasThemeVars('dark')['--sb--canvas-bg']).toBe('#161b22')
|
|
154
|
+
expect(getCanvasThemeVars('dark')['--bgColor-muted']).toBe('#161b22')
|
|
155
|
+
expect(getCanvasThemeVars('dark')['--tc-bg-muted']).toBe('#161b22')
|
|
156
|
+
expect(getCanvasThemeVars('dark_dimmed')['--sb--canvas-bg']).toBe('#22272e')
|
|
157
|
+
expect(getCanvasThemeVars('dark_dimmed')['--bgColor-muted']).toBe('#22272e')
|
|
158
|
+
expect(getCanvasThemeVars('dark_dimmed')['--tc-bg-muted']).toBe('#22272e')
|
|
159
|
+
expect(getCanvasThemeVars('dark_dimmed')['--tc-dot-color']).toBe('rgba(205, 217, 229, 0.22)')
|
|
160
|
+
expect(getCanvasThemeVars('dark_dimmed')['--overlay-backdrop-bgColor']).toBe('rgba(205, 217, 229, 0.22)')
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('getCanvasPrimerAttrs', () => {
|
|
165
|
+
it('maps canvas theme to local Primer mode attrs', () => {
|
|
166
|
+
expect(getCanvasPrimerAttrs('light')).toEqual({
|
|
167
|
+
'data-color-mode': 'light',
|
|
168
|
+
'data-dark-theme': 'dark',
|
|
169
|
+
'data-light-theme': 'light',
|
|
170
|
+
})
|
|
171
|
+
expect(getCanvasPrimerAttrs('dark')).toEqual({
|
|
172
|
+
'data-color-mode': 'dark',
|
|
173
|
+
'data-dark-theme': 'dark',
|
|
174
|
+
'data-light-theme': 'light',
|
|
175
|
+
})
|
|
176
|
+
expect(getCanvasPrimerAttrs('dark_dimmed')).toEqual({
|
|
177
|
+
'data-color-mode': 'dark',
|
|
178
|
+
'data-dark-theme': 'dark_dimmed',
|
|
179
|
+
'data-light-theme': 'light',
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('canvas target fallback', () => {
|
|
185
|
+
it('stays light when canvas target is unchecked even if stale canvas attribute is dark', () => {
|
|
186
|
+
localStorage.setItem('sb-theme-sync', JSON.stringify({
|
|
187
|
+
prototype: true,
|
|
188
|
+
toolbar: true,
|
|
189
|
+
codeBoxes: true,
|
|
190
|
+
canvas: false,
|
|
191
|
+
}))
|
|
192
|
+
localStorage.setItem('sb-color-scheme', 'dark')
|
|
193
|
+
document.documentElement.setAttribute('data-sb-canvas-theme', 'dark')
|
|
194
|
+
|
|
195
|
+
render(<CanvasPage name="design-overview" />)
|
|
196
|
+
|
|
197
|
+
const scroll = document.querySelector('[data-storyboard-canvas-scroll]')
|
|
198
|
+
const jsxWidget = document.getElementById('jsx-PrimaryButtons')
|
|
199
|
+
expect(scroll?.style.getPropertyValue('--sb--canvas-bg')).toBe('#f6f8fa')
|
|
200
|
+
expect(scroll?.style.getPropertyValue('--tc-bg-muted')).toBe('#f6f8fa')
|
|
201
|
+
expect(scroll?.getAttribute('data-color-mode')).toBe('light')
|
|
202
|
+
expect(jsxWidget?.getAttribute('data-color-mode')).toBe('light')
|
|
203
|
+
expect(jsxWidget?.style.getPropertyValue('--bgColor-default')).toBe('#ffffff')
|
|
204
|
+
})
|
|
205
|
+
})
|
|
@@ -2,6 +2,8 @@ import { createElement, useCallback, useEffect, useRef, useState } from 'react'
|
|
|
2
2
|
import { Canvas } from '@dfosco/tiny-canvas'
|
|
3
3
|
import '@dfosco/tiny-canvas/style.css'
|
|
4
4
|
import { useCanvas } from './useCanvas.js'
|
|
5
|
+
import { shouldPreventCanvasTextSelection } from './textSelection.js'
|
|
6
|
+
import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
|
|
5
7
|
import { getWidgetComponent } from './widgets/index.js'
|
|
6
8
|
import { schemas, getDefaults } from './widgets/widgetProps.js'
|
|
7
9
|
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
@@ -11,6 +13,32 @@ import styles from './CanvasPage.module.css'
|
|
|
11
13
|
const ZOOM_MIN = 25
|
|
12
14
|
const ZOOM_MAX = 200
|
|
13
15
|
|
|
16
|
+
const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
|
|
17
|
+
|
|
18
|
+
function getToolbarColorMode(theme) {
|
|
19
|
+
return String(theme || 'light').startsWith('dark') ? 'dark' : 'light'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveCanvasThemeFromStorage() {
|
|
23
|
+
if (typeof localStorage === 'undefined') return 'light'
|
|
24
|
+
let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: false }
|
|
25
|
+
try {
|
|
26
|
+
const rawSync = localStorage.getItem('sb-theme-sync')
|
|
27
|
+
if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
|
|
28
|
+
} catch {
|
|
29
|
+
// Ignore malformed sync settings
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!sync.canvas) return 'light'
|
|
33
|
+
|
|
34
|
+
const attrTheme = document.documentElement.getAttribute('data-sb-canvas-theme')
|
|
35
|
+
if (attrTheme) return attrTheme
|
|
36
|
+
|
|
37
|
+
const stored = localStorage.getItem('sb-color-scheme') || 'system'
|
|
38
|
+
if (stored !== 'system') return stored
|
|
39
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
40
|
+
}
|
|
41
|
+
|
|
14
42
|
/**
|
|
15
43
|
* Debounce helper — returns a function that delays invocation.
|
|
16
44
|
*/
|
|
@@ -22,21 +50,6 @@ function debounce(fn, ms) {
|
|
|
22
50
|
}
|
|
23
51
|
}
|
|
24
52
|
|
|
25
|
-
/**
|
|
26
|
-
* Save a drag position to localStorage so tiny-canvas picks it up on render.
|
|
27
|
-
*/
|
|
28
|
-
function saveWidgetPosition(widgetId, x, y) {
|
|
29
|
-
try {
|
|
30
|
-
const queue = JSON.parse(localStorage.getItem('tiny-canvas-queue')) || []
|
|
31
|
-
const now = new Date().toISOString().replace(/[:.]/g, '-')
|
|
32
|
-
const entry = { id: widgetId, x, y, time: now }
|
|
33
|
-
const idx = queue.findIndex((item) => item.id === widgetId)
|
|
34
|
-
if (idx >= 0) queue[idx] = entry
|
|
35
|
-
else queue.push(entry)
|
|
36
|
-
localStorage.setItem('tiny-canvas-queue', JSON.stringify(queue))
|
|
37
|
-
} catch { /* localStorage unavailable */ }
|
|
38
|
-
}
|
|
39
|
-
|
|
40
53
|
/**
|
|
41
54
|
* Get viewport-center coordinates for placing a new widget.
|
|
42
55
|
*/
|
|
@@ -47,6 +60,10 @@ function getViewportCenter() {
|
|
|
47
60
|
}
|
|
48
61
|
}
|
|
49
62
|
|
|
63
|
+
function roundPosition(value) {
|
|
64
|
+
return Math.round(value)
|
|
65
|
+
}
|
|
66
|
+
|
|
50
67
|
/** Renders a single JSON-defined widget by type lookup. */
|
|
51
68
|
function WidgetRenderer({ widget, onUpdate }) {
|
|
52
69
|
const Component = getWidgetComponent(widget.type)
|
|
@@ -75,13 +92,17 @@ export default function CanvasPage({ name }) {
|
|
|
75
92
|
const [trackedCanvas, setTrackedCanvas] = useState(canvas)
|
|
76
93
|
const [selectedWidgetId, setSelectedWidgetId] = useState(null)
|
|
77
94
|
const [zoom, setZoom] = useState(100)
|
|
95
|
+
const zoomRef = useRef(100)
|
|
78
96
|
const scrollRef = useRef(null)
|
|
79
97
|
const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
|
|
80
98
|
const titleInputRef = useRef(null)
|
|
99
|
+
const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
|
|
100
|
+
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
|
|
81
101
|
|
|
82
102
|
if (canvas !== trackedCanvas) {
|
|
83
103
|
setTrackedCanvas(canvas)
|
|
84
104
|
setLocalWidgets(canvas?.widgets ?? null)
|
|
105
|
+
setLocalSources(canvas?.sources ?? [])
|
|
85
106
|
setCanvasTitle(canvas?.title || name)
|
|
86
107
|
}
|
|
87
108
|
|
|
@@ -133,15 +154,61 @@ export default function CanvasPage({ name }) {
|
|
|
133
154
|
)
|
|
134
155
|
}, [name])
|
|
135
156
|
|
|
136
|
-
|
|
157
|
+
const handleItemDragEnd = useCallback((dragId, position) => {
|
|
158
|
+
if (!dragId || !position) return
|
|
159
|
+
const rounded = { x: roundPosition(position.x), y: roundPosition(position.y) }
|
|
160
|
+
|
|
161
|
+
if (dragId.startsWith('jsx-')) {
|
|
162
|
+
const sourceExport = dragId.replace(/^jsx-/, '')
|
|
163
|
+
setLocalSources((prev) => {
|
|
164
|
+
const current = Array.isArray(prev) ? prev : []
|
|
165
|
+
const next = current.some((s) => s?.export === sourceExport)
|
|
166
|
+
? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
|
|
167
|
+
: [...current, { export: sourceExport, position: rounded }]
|
|
168
|
+
updateCanvas(name, { sources: next }).catch((err) =>
|
|
169
|
+
console.error('[canvas] Failed to save source position:', err)
|
|
170
|
+
)
|
|
171
|
+
return next
|
|
172
|
+
})
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
setLocalWidgets((prev) => {
|
|
177
|
+
if (!prev) return prev
|
|
178
|
+
const next = prev.map((w) =>
|
|
179
|
+
w.id === dragId ? { ...w, position: rounded } : w
|
|
180
|
+
)
|
|
181
|
+
updateCanvas(name, { widgets: next }).catch((err) =>
|
|
182
|
+
console.error('[canvas] Failed to save widget position:', err)
|
|
183
|
+
)
|
|
184
|
+
return next
|
|
185
|
+
})
|
|
186
|
+
}, [name])
|
|
187
|
+
|
|
137
188
|
useEffect(() => {
|
|
189
|
+
zoomRef.current = zoom
|
|
190
|
+
}, [zoom])
|
|
191
|
+
|
|
192
|
+
// Signal canvas mount/unmount to CoreUIBar
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
window[CANVAS_BRIDGE_STATE_KEY] = { active: true, name, zoom: zoomRef.current }
|
|
138
195
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:mounted', {
|
|
139
|
-
detail: { name, zoom }
|
|
196
|
+
detail: { name, zoom: zoomRef.current }
|
|
140
197
|
}))
|
|
198
|
+
|
|
199
|
+
function handleStatusRequest() {
|
|
200
|
+
const state = window[CANVAS_BRIDGE_STATE_KEY] || { active: true, name, zoom: zoomRef.current }
|
|
201
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:status', { detail: state }))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
document.addEventListener('storyboard:canvas:status-request', handleStatusRequest)
|
|
205
|
+
|
|
141
206
|
return () => {
|
|
207
|
+
document.removeEventListener('storyboard:canvas:status-request', handleStatusRequest)
|
|
208
|
+
window[CANVAS_BRIDGE_STATE_KEY] = { active: false, name: '', zoom: 100 }
|
|
142
209
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:unmounted'))
|
|
143
210
|
}
|
|
144
|
-
}, [name
|
|
211
|
+
}, [name])
|
|
145
212
|
|
|
146
213
|
// Add a widget by type — used by CanvasControls and CoreUIBar event
|
|
147
214
|
const addWidget = useCallback(async (type) => {
|
|
@@ -154,7 +221,6 @@ export default function CanvasPage({ name }) {
|
|
|
154
221
|
position: pos,
|
|
155
222
|
})
|
|
156
223
|
if (result.success && result.widget) {
|
|
157
|
-
saveWidgetPosition(result.widget.id, pos.x, pos.y)
|
|
158
224
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
159
225
|
}
|
|
160
226
|
} catch (err) {
|
|
@@ -183,14 +249,36 @@ export default function CanvasPage({ name }) {
|
|
|
183
249
|
return () => document.removeEventListener('storyboard:canvas:set-zoom', handleZoom)
|
|
184
250
|
}, [])
|
|
185
251
|
|
|
252
|
+
// Canvas background should follow toolbar theme target.
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
function readMode() {
|
|
255
|
+
setCanvasTheme(resolveCanvasThemeFromStorage())
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
readMode()
|
|
259
|
+
document.addEventListener('storyboard:theme:changed', readMode)
|
|
260
|
+
return () => document.removeEventListener('storyboard:theme:changed', readMode)
|
|
261
|
+
}, [])
|
|
262
|
+
|
|
186
263
|
// Broadcast zoom level to CoreUIBar whenever it changes
|
|
187
264
|
useEffect(() => {
|
|
265
|
+
window[CANVAS_BRIDGE_STATE_KEY] = { active: true, name, zoom }
|
|
188
266
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
|
|
189
267
|
detail: { zoom }
|
|
190
268
|
}))
|
|
191
|
-
}, [zoom])
|
|
269
|
+
}, [name, zoom])
|
|
192
270
|
|
|
193
271
|
// Delete selected widget on Delete/Backspace key
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
function handleSelectStart(e) {
|
|
274
|
+
if (shouldPreventCanvasTextSelection(e.target)) {
|
|
275
|
+
e.preventDefault()
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
document.addEventListener('selectstart', handleSelectStart)
|
|
279
|
+
return () => document.removeEventListener('selectstart', handleSelectStart)
|
|
280
|
+
}, [])
|
|
281
|
+
|
|
194
282
|
useEffect(() => {
|
|
195
283
|
function handleKeyDown(e) {
|
|
196
284
|
if (!selectedWidgetId) return
|
|
@@ -246,7 +334,6 @@ export default function CanvasPage({ name }) {
|
|
|
246
334
|
position: pos,
|
|
247
335
|
})
|
|
248
336
|
if (result.success && result.widget) {
|
|
249
|
-
saveWidgetPosition(result.widget.id, pos.x, pos.y)
|
|
250
337
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
251
338
|
}
|
|
252
339
|
} catch (err) {
|
|
@@ -355,17 +442,36 @@ export default function CanvasPage({ name }) {
|
|
|
355
442
|
dotted: canvas.dotted ?? false,
|
|
356
443
|
grid: canvas.grid ?? false,
|
|
357
444
|
gridSize: canvas.gridSize ?? 18,
|
|
358
|
-
colorMode: canvas.colorMode
|
|
445
|
+
colorMode: canvas.colorMode === 'auto'
|
|
446
|
+
? getToolbarColorMode(canvasTheme)
|
|
447
|
+
: (canvas.colorMode ?? 'auto'),
|
|
359
448
|
}
|
|
360
449
|
|
|
450
|
+
const canvasThemeVars = getCanvasThemeVars(canvasTheme)
|
|
451
|
+
const canvasPrimerAttrs = getCanvasPrimerAttrs(canvasTheme)
|
|
452
|
+
|
|
361
453
|
// Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
|
|
362
454
|
const allChildren = []
|
|
363
455
|
|
|
456
|
+
const sourcePositionByExport = Object.fromEntries(
|
|
457
|
+
(localSources || [])
|
|
458
|
+
.filter((source) => source?.export)
|
|
459
|
+
.map((source) => [source.export, source.position || { x: 0, y: 0 }])
|
|
460
|
+
)
|
|
461
|
+
|
|
364
462
|
// 1. JSX-sourced component widgets
|
|
365
463
|
if (jsxExports) {
|
|
366
464
|
for (const [exportName, Component] of Object.entries(jsxExports)) {
|
|
465
|
+
const sourcePosition = sourcePositionByExport[exportName] || { x: 0, y: 0 }
|
|
367
466
|
allChildren.push(
|
|
368
|
-
<div
|
|
467
|
+
<div
|
|
468
|
+
key={`jsx-${exportName}`}
|
|
469
|
+
id={`jsx-${exportName}`}
|
|
470
|
+
data-tc-x={sourcePosition.x}
|
|
471
|
+
data-tc-y={sourcePosition.y}
|
|
472
|
+
{...canvasPrimerAttrs}
|
|
473
|
+
style={canvasThemeVars}
|
|
474
|
+
>
|
|
369
475
|
<ComponentWidget component={Component} />
|
|
370
476
|
</div>
|
|
371
477
|
)
|
|
@@ -378,6 +484,10 @@ export default function CanvasPage({ name }) {
|
|
|
378
484
|
<div
|
|
379
485
|
key={widget.id}
|
|
380
486
|
id={widget.id}
|
|
487
|
+
data-tc-x={widget?.position?.x ?? 0}
|
|
488
|
+
data-tc-y={widget?.position?.y ?? 0}
|
|
489
|
+
{...canvasPrimerAttrs}
|
|
490
|
+
style={canvasThemeVars}
|
|
381
491
|
onClick={(e) => {
|
|
382
492
|
e.stopPropagation()
|
|
383
493
|
setSelectedWidgetId(widget.id)
|
|
@@ -411,12 +521,20 @@ export default function CanvasPage({ name }) {
|
|
|
411
521
|
</div>
|
|
412
522
|
<div
|
|
413
523
|
ref={scrollRef}
|
|
524
|
+
data-storyboard-canvas-scroll
|
|
525
|
+
data-sb-canvas-theme={canvasTheme}
|
|
526
|
+
{...canvasPrimerAttrs}
|
|
414
527
|
className={styles.canvasScroll}
|
|
415
|
-
style={
|
|
528
|
+
style={{
|
|
529
|
+
...canvasThemeVars,
|
|
530
|
+
...(spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : {}),
|
|
531
|
+
}}
|
|
416
532
|
onClick={() => setSelectedWidgetId(null)}
|
|
417
533
|
onMouseDown={handlePanStart}
|
|
418
534
|
>
|
|
419
535
|
<div
|
|
536
|
+
data-storyboard-canvas-zoom
|
|
537
|
+
data-sb-canvas-theme={canvasTheme}
|
|
420
538
|
className={styles.canvasZoom}
|
|
421
539
|
style={{
|
|
422
540
|
transform: `scale(${scale})`,
|
|
@@ -425,7 +543,7 @@ export default function CanvasPage({ name }) {
|
|
|
425
543
|
height: `${Math.max(10000, 100 / scale)}vh`,
|
|
426
544
|
}}
|
|
427
545
|
>
|
|
428
|
-
<Canvas {...canvasProps}>
|
|
546
|
+
<Canvas {...canvasProps} onDragEnd={handleItemDragEnd}>
|
|
429
547
|
{allChildren}
|
|
430
548
|
</Canvas>
|
|
431
549
|
</div>
|
|
@@ -18,12 +18,12 @@
|
|
|
18
18
|
width: 100vw;
|
|
19
19
|
height: 100vh;
|
|
20
20
|
overflow: auto;
|
|
21
|
-
background-color: var(--bgColor-muted, #f6f8fa);
|
|
21
|
+
background-color: var(--sb--canvas-bg, var(--bgColor-muted, #f6f8fa));
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
@media (prefers-color-scheme: dark) {
|
|
25
25
|
.canvasScroll {
|
|
26
|
-
background-color: var(--bgColor-muted, #161b22);
|
|
26
|
+
background-color: var(--sb--canvas-bg, var(--bgColor-muted, #161b22));
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export function getCanvasPrimerAttrs(theme) {
|
|
2
|
+
if (String(theme || 'light') === 'dark_dimmed') {
|
|
3
|
+
return {
|
|
4
|
+
'data-color-mode': 'dark',
|
|
5
|
+
'data-dark-theme': 'dark_dimmed',
|
|
6
|
+
'data-light-theme': 'light',
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
if (String(theme || 'light').startsWith('dark')) {
|
|
10
|
+
return {
|
|
11
|
+
'data-color-mode': 'dark',
|
|
12
|
+
'data-dark-theme': 'dark',
|
|
13
|
+
'data-light-theme': 'light',
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
'data-color-mode': 'light',
|
|
18
|
+
'data-dark-theme': 'dark',
|
|
19
|
+
'data-light-theme': 'light',
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getCanvasThemeVars(theme) {
|
|
24
|
+
const value = String(theme || 'light')
|
|
25
|
+
if (value === 'dark_dimmed') {
|
|
26
|
+
return {
|
|
27
|
+
'--sb--canvas-bg': '#22272e',
|
|
28
|
+
'--bgColor-default': '#22272e',
|
|
29
|
+
'--bgColor-muted': '#22272e',
|
|
30
|
+
'--bgColor-neutral-muted': 'rgba(99, 110, 123, 0.3)',
|
|
31
|
+
'--bgColor-accent-emphasis': '#316dca',
|
|
32
|
+
'--tc-bg-muted': '#22272e',
|
|
33
|
+
'--tc-dot-color': 'rgba(205, 217, 229, 0.22)',
|
|
34
|
+
'--overlay-backdrop-bgColor': 'rgba(205, 217, 229, 0.22)',
|
|
35
|
+
'--fgColor-muted': '#768390',
|
|
36
|
+
'--fgColor-default': '#adbac7',
|
|
37
|
+
'--fgColor-onEmphasis': '#ffffff',
|
|
38
|
+
'--borderColor-default': '#444c56',
|
|
39
|
+
'--borderColor-muted': '#545d68',
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (value.startsWith('dark')) {
|
|
43
|
+
return {
|
|
44
|
+
'--sb--canvas-bg': '#161b22',
|
|
45
|
+
'--bgColor-default': '#161b22',
|
|
46
|
+
'--bgColor-muted': '#161b22',
|
|
47
|
+
'--bgColor-neutral-muted': 'rgba(110, 118, 129, 0.2)',
|
|
48
|
+
'--bgColor-accent-emphasis': '#2f81f7',
|
|
49
|
+
'--tc-bg-muted': '#161b22',
|
|
50
|
+
'--tc-dot-color': 'rgba(255, 255, 255, 0.1)',
|
|
51
|
+
'--overlay-backdrop-bgColor': 'rgba(255, 255, 255, 0.1)',
|
|
52
|
+
'--fgColor-muted': '#8b949e',
|
|
53
|
+
'--fgColor-default': '#e6edf3',
|
|
54
|
+
'--fgColor-onEmphasis': '#ffffff',
|
|
55
|
+
'--borderColor-default': '#30363d',
|
|
56
|
+
'--borderColor-muted': '#30363d',
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
'--sb--canvas-bg': '#f6f8fa',
|
|
61
|
+
'--bgColor-default': '#ffffff',
|
|
62
|
+
'--tc-bg-muted': '#f6f8fa',
|
|
63
|
+
'--tc-dot-color': 'rgba(0, 0, 0, 0.08)',
|
|
64
|
+
'--overlay-backdrop-bgColor': 'rgba(0, 0, 0, 0.08)',
|
|
65
|
+
'--bgColor-muted': '#f6f8fa',
|
|
66
|
+
'--bgColor-neutral-muted': '#eaeef2',
|
|
67
|
+
'--bgColor-accent-emphasis': '#2f81f7',
|
|
68
|
+
'--fgColor-muted': '#656d76',
|
|
69
|
+
'--fgColor-default': '#1f2328',
|
|
70
|
+
'--fgColor-onEmphasis': '#ffffff',
|
|
71
|
+
'--borderColor-default': '#d1d9e0',
|
|
72
|
+
'--borderColor-muted': '#d8dee4',
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const TEXT_SELECTION_EDITING_SELECTOR = 'textarea, [contenteditable="true"], [contenteditable=""], [data-canvas-allow-text-selection]'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns true when canvas mouse interactions should suppress browser text selection.
|
|
5
|
+
*/
|
|
6
|
+
export function shouldPreventCanvasTextSelection(target) {
|
|
7
|
+
if (!(target instanceof Element)) return true
|
|
8
|
+
return !target.closest(TEXT_SELECTION_EDITING_SELECTOR)
|
|
9
|
+
}
|
|
10
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { shouldPreventCanvasTextSelection } from './textSelection.js'
|
|
2
|
+
|
|
3
|
+
describe('shouldPreventCanvasTextSelection', () => {
|
|
4
|
+
it('prevents selection for regular canvas elements', () => {
|
|
5
|
+
const container = document.createElement('div')
|
|
6
|
+
const regular = document.createElement('p')
|
|
7
|
+
container.appendChild(regular)
|
|
8
|
+
|
|
9
|
+
expect(shouldPreventCanvasTextSelection(regular)).toBe(true)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('allows selection for textarea editing', () => {
|
|
13
|
+
const textarea = document.createElement('textarea')
|
|
14
|
+
expect(shouldPreventCanvasTextSelection(textarea)).toBe(false)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('allows selection for explicit editable markers', () => {
|
|
18
|
+
const wrapper = document.createElement('div')
|
|
19
|
+
wrapper.setAttribute('data-canvas-allow-text-selection', '')
|
|
20
|
+
const child = document.createElement('span')
|
|
21
|
+
wrapper.appendChild(child)
|
|
22
|
+
|
|
23
|
+
expect(shouldPreventCanvasTextSelection(child)).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
package/src/canvas/useCanvas.js
CHANGED
|
@@ -70,5 +70,25 @@ export function useCanvas(name) {
|
|
|
70
70
|
})
|
|
71
71
|
}, [canvas?._jsxModule])
|
|
72
72
|
|
|
73
|
+
// In dev, react to file mutations from the data plugin without reloading
|
|
74
|
+
// the current page. This keeps canvas editing state and route stable.
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!import.meta.hot || !buildTimeCanvas) return
|
|
77
|
+
|
|
78
|
+
const handleCanvasFileChanged = ({ data }) => {
|
|
79
|
+
if (!data || data.name !== name) return
|
|
80
|
+
fetchCanvasFromServer(name).then((fresh) => {
|
|
81
|
+
if (fresh) {
|
|
82
|
+
setCanvas((prev) => ({ ...(prev || buildTimeCanvas), ...fresh }))
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
import.meta.hot.on('storyboard:canvas-file-changed', handleCanvasFileChanged)
|
|
88
|
+
return () => {
|
|
89
|
+
import.meta.hot.off('storyboard:canvas-file-changed', handleCanvasFileChanged)
|
|
90
|
+
}
|
|
91
|
+
}, [name, buildTimeCanvas])
|
|
92
|
+
|
|
73
93
|
return { canvas, jsxExports, loading }
|
|
74
94
|
}
|
|
@@ -62,6 +62,7 @@ export default function MarkdownBlock({ props, onUpdate }) {
|
|
|
62
62
|
<textarea
|
|
63
63
|
ref={textareaRef}
|
|
64
64
|
className={styles.editor}
|
|
65
|
+
data-canvas-allow-text-selection
|
|
65
66
|
style={{ minHeight: editHeight ? editHeight - 2 : undefined }}
|
|
66
67
|
value={content}
|
|
67
68
|
onChange={handleContentChange}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
.block {
|
|
2
2
|
min-height: 80px;
|
|
3
|
-
|
|
3
|
+
--sb--markdown-bg: var(--bgColor-default, #ffffff);
|
|
4
|
+
--sb--markdown-fg: var(--fgColor-default, #1f2328);
|
|
5
|
+
--sb--markdown-muted: var(--fgColor-muted, #656d76);
|
|
6
|
+
--sb--markdown-accent: var(--bgColor-accent-emphasis, #2f81f7);
|
|
7
|
+
background: var(--sb--markdown-bg);
|
|
8
|
+
color: var(--sb--markdown-fg);
|
|
4
9
|
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
5
10
|
}
|
|
6
11
|
|
|
@@ -8,11 +13,15 @@
|
|
|
8
13
|
padding: 16px 20px;
|
|
9
14
|
font-size: 14px;
|
|
10
15
|
line-height: 1.6;
|
|
11
|
-
color: var(--
|
|
16
|
+
color: var(--sb--markdown-fg);
|
|
12
17
|
cursor: text;
|
|
13
18
|
min-height: 60px;
|
|
14
19
|
}
|
|
15
20
|
|
|
21
|
+
.preview :global(*) {
|
|
22
|
+
color: inherit;
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
.preview h1 {
|
|
17
26
|
font-size: 20px;
|
|
18
27
|
font-weight: 700;
|
|
@@ -56,7 +65,7 @@
|
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
.preview :global(.placeholder) {
|
|
59
|
-
color: var(--
|
|
68
|
+
color: var(--sb--markdown-muted);
|
|
60
69
|
font-style: italic;
|
|
61
70
|
}
|
|
62
71
|
|
|
@@ -69,10 +78,10 @@
|
|
|
69
78
|
padding: 16px 20px;
|
|
70
79
|
border: none;
|
|
71
80
|
outline: none;
|
|
72
|
-
background: var(--
|
|
81
|
+
background: var(--sb--markdown-bg);
|
|
73
82
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
|
74
83
|
font-size: 13px;
|
|
75
84
|
line-height: 1.5;
|
|
76
|
-
color: var(--
|
|
85
|
+
color: var(--sb--markdown-fg);
|
|
77
86
|
resize: none;
|
|
78
87
|
}
|
|
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
|
|
2
2
|
import { buildPrototypeIndex } from '@dfosco/storyboard-core'
|
|
3
3
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
4
4
|
import { readProp, prototypeEmbedSchema } from './widgetProps.js'
|
|
5
|
+
import { getEmbedChromeVars } from './embedTheme.js'
|
|
5
6
|
import styles from './PrototypeEmbed.module.css'
|
|
6
7
|
|
|
7
8
|
function formatName(name) {
|
|
@@ -10,6 +11,23 @@ function formatName(name) {
|
|
|
10
11
|
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
function resolveCanvasThemeFromStorage() {
|
|
15
|
+
if (typeof localStorage === 'undefined') return 'light'
|
|
16
|
+
let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: false }
|
|
17
|
+
try {
|
|
18
|
+
const rawSync = localStorage.getItem('sb-theme-sync')
|
|
19
|
+
if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
|
|
20
|
+
} catch {
|
|
21
|
+
// Ignore malformed sync settings
|
|
22
|
+
}
|
|
23
|
+
if (!sync.canvas) return 'light'
|
|
24
|
+
const attrTheme = document.documentElement.getAttribute('data-sb-canvas-theme')
|
|
25
|
+
if (attrTheme) return attrTheme
|
|
26
|
+
const stored = localStorage.getItem('sb-color-scheme') || 'system'
|
|
27
|
+
if (stored !== 'system') return stored
|
|
28
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
29
|
+
}
|
|
30
|
+
|
|
13
31
|
export default function PrototypeEmbed({ props, onUpdate }) {
|
|
14
32
|
const src = readProp(props, 'src', prototypeEmbedSchema)
|
|
15
33
|
const width = readProp(props, 'width', prototypeEmbedSchema)
|
|
@@ -19,17 +37,21 @@ export default function PrototypeEmbed({ props, onUpdate }) {
|
|
|
19
37
|
|
|
20
38
|
const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
21
39
|
const rawSrc = src ? `${basePath}${src}` : ''
|
|
22
|
-
const iframeSrc = rawSrc ? `${rawSrc}${rawSrc.includes('?') ? '&' : '?'}_sb_embed` : ''
|
|
23
40
|
|
|
24
41
|
const scale = zoom / 100
|
|
25
42
|
|
|
26
43
|
const [editing, setEditing] = useState(false)
|
|
27
44
|
const [interactive, setInteractive] = useState(false)
|
|
28
45
|
const [filter, setFilter] = useState('')
|
|
46
|
+
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
|
|
29
47
|
const inputRef = useRef(null)
|
|
30
48
|
const filterRef = useRef(null)
|
|
31
49
|
const embedRef = useRef(null)
|
|
32
50
|
|
|
51
|
+
const iframeSrc = rawSrc
|
|
52
|
+
? `${rawSrc}${rawSrc.includes('?') ? '&' : '?'}_sb_embed&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}`
|
|
53
|
+
: ''
|
|
54
|
+
|
|
33
55
|
// Build prototype index for the picker
|
|
34
56
|
const prototypeIndex = useMemo(() => {
|
|
35
57
|
try {
|
|
@@ -132,6 +154,17 @@ export default function PrototypeEmbed({ props, onUpdate }) {
|
|
|
132
154
|
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
133
155
|
}, [interactive])
|
|
134
156
|
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
function readToolbarTheme() {
|
|
159
|
+
setCanvasTheme(resolveCanvasThemeFromStorage())
|
|
160
|
+
}
|
|
161
|
+
readToolbarTheme()
|
|
162
|
+
document.addEventListener('storyboard:theme:changed', readToolbarTheme)
|
|
163
|
+
return () => document.removeEventListener('storyboard:theme:changed', readToolbarTheme)
|
|
164
|
+
}, [])
|
|
165
|
+
|
|
166
|
+
const chromeVars = useMemo(() => getEmbedChromeVars(canvasTheme), [canvasTheme])
|
|
167
|
+
|
|
135
168
|
const enterInteractive = useCallback(() => setInteractive(true), [])
|
|
136
169
|
|
|
137
170
|
function handlePickRoute(route) {
|
|
@@ -158,7 +191,7 @@ export default function PrototypeEmbed({ props, onUpdate }) {
|
|
|
158
191
|
<div
|
|
159
192
|
ref={embedRef}
|
|
160
193
|
className={styles.embed}
|
|
161
|
-
style={{ width, height }}
|
|
194
|
+
style={{ width, height, ...chromeVars }}
|
|
162
195
|
>
|
|
163
196
|
{editing ? (
|
|
164
197
|
<div
|
|
@@ -52,10 +52,9 @@
|
|
|
52
52
|
align-items: center;
|
|
53
53
|
justify-content: center;
|
|
54
54
|
border-radius: 6px;
|
|
55
|
-
background: rgba(255, 255, 255, 0.92);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
border: 1px solid rgba(0, 0, 0, 0.12);
|
|
55
|
+
background: var(--bgColor-default, rgba(255, 255, 255, 0.92));
|
|
56
|
+
border: 1px solid var(--borderColor-default, rgba(0, 0, 0, 0.12));
|
|
57
|
+
color: var(--fgColor-default, #1f2328);
|
|
59
58
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
|
60
59
|
font-size: 14px;
|
|
61
60
|
opacity: 0;
|
|
@@ -68,18 +67,7 @@
|
|
|
68
67
|
}
|
|
69
68
|
|
|
70
69
|
.editBtn:hover {
|
|
71
|
-
background: rgba(255, 255, 255, 0.98);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
@media (prefers-color-scheme: dark) {
|
|
75
|
-
.editBtn {
|
|
76
|
-
background: rgba(22, 27, 34, 0.88);
|
|
77
|
-
border-color: rgba(255, 255, 255, 0.1);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
.editBtn:hover {
|
|
81
|
-
background: rgba(30, 37, 46, 0.95);
|
|
82
|
-
}
|
|
70
|
+
background: var(--bgColor-muted, rgba(255, 255, 255, 0.98));
|
|
83
71
|
}
|
|
84
72
|
|
|
85
73
|
.urlForm {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { getEmbedChromeVars } from './embedTheme.js'
|
|
3
|
+
|
|
4
|
+
describe('getEmbedChromeVars', () => {
|
|
5
|
+
it('follows toolbar theme variants for embed edit chrome', () => {
|
|
6
|
+
expect(getEmbedChromeVars('light')['--bgColor-default']).toBe('#ffffff')
|
|
7
|
+
expect(getEmbedChromeVars('dark')['--bgColor-default']).toBe('#161b22')
|
|
8
|
+
expect(getEmbedChromeVars('dark_dimmed')['--bgColor-default']).toBe('#22272e')
|
|
9
|
+
})
|
|
10
|
+
})
|
|
@@ -53,6 +53,7 @@ export default function StickyNote({ props, onUpdate }) {
|
|
|
53
53
|
<textarea
|
|
54
54
|
ref={textareaRef}
|
|
55
55
|
className={styles.textarea}
|
|
56
|
+
data-canvas-allow-text-selection
|
|
56
57
|
value={text}
|
|
57
58
|
onChange={handleTextChange}
|
|
58
59
|
onBlur={() => setEditing(false)}
|
|
@@ -95,4 +96,3 @@ export default function StickyNote({ props, onUpdate }) {
|
|
|
95
96
|
</div>
|
|
96
97
|
)
|
|
97
98
|
}
|
|
98
|
-
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
position: relative;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
:global([data-sb-canvas-theme^='dark']) .sticky {
|
|
18
|
+
background: color-mix(in srgb, var(--sticky-bg) 30%, #0d1117 70%);
|
|
19
|
+
border-color: color-mix(in srgb, var(--sticky-bg) 55%, #f0f6fc 18%);
|
|
20
|
+
box-shadow: 2px 3px 10px rgba(0, 0, 0, 0.35);
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
.text {
|
|
18
24
|
padding: 16px 20px;
|
|
19
25
|
margin: 0;
|
|
@@ -26,6 +32,10 @@
|
|
|
26
32
|
min-height: 60px;
|
|
27
33
|
}
|
|
28
34
|
|
|
35
|
+
:global([data-sb-canvas-theme^='dark']) .text {
|
|
36
|
+
color: color-mix(in srgb, var(--sticky-bg) 30%, #f0f6fc 70%);
|
|
37
|
+
}
|
|
38
|
+
|
|
29
39
|
.textarea {
|
|
30
40
|
position: absolute;
|
|
31
41
|
top: 0;
|
|
@@ -47,6 +57,10 @@
|
|
|
47
57
|
resize: none;
|
|
48
58
|
}
|
|
49
59
|
|
|
60
|
+
:global([data-sb-canvas-theme^='dark']) .textarea {
|
|
61
|
+
color: color-mix(in srgb, var(--sticky-bg) 26%, #f0f6fc 74%);
|
|
62
|
+
}
|
|
63
|
+
|
|
50
64
|
/* Color picker area — sits below the sticky */
|
|
51
65
|
|
|
52
66
|
.pickerArea {
|
|
@@ -82,6 +96,13 @@
|
|
|
82
96
|
z-index: 10;
|
|
83
97
|
}
|
|
84
98
|
|
|
99
|
+
:global([data-sb-canvas-theme^='dark']) .pickerPopup {
|
|
100
|
+
background: var(--bgColor-muted, #161b22);
|
|
101
|
+
box-shadow:
|
|
102
|
+
0 0 0 1px rgba(255, 255, 255, 0.08),
|
|
103
|
+
0 4px 12px rgba(0, 0, 0, 0.45);
|
|
104
|
+
}
|
|
105
|
+
|
|
85
106
|
.pickerArea:hover .pickerDot {
|
|
86
107
|
opacity: 0;
|
|
87
108
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export function getEmbedChromeVars(theme) {
|
|
2
|
+
const value = String(theme || 'light')
|
|
3
|
+
if (value === 'dark_dimmed') {
|
|
4
|
+
return {
|
|
5
|
+
'--bgColor-default': '#22272e',
|
|
6
|
+
'--bgColor-muted': '#2d333b',
|
|
7
|
+
'--bgColor-neutral-muted': 'rgba(99, 110, 123, 0.3)',
|
|
8
|
+
'--fgColor-default': '#adbac7',
|
|
9
|
+
'--fgColor-muted': '#768390',
|
|
10
|
+
'--fgColor-onEmphasis': '#ffffff',
|
|
11
|
+
'--borderColor-default': '#444c56',
|
|
12
|
+
'--borderColor-muted': '#545d68',
|
|
13
|
+
'--bgColor-accent-emphasis': '#316dca',
|
|
14
|
+
'--trigger-bg': '#2d333b',
|
|
15
|
+
'--trigger-bg-hover': '#373e47',
|
|
16
|
+
'--trigger-border': '#444c56',
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (value.startsWith('dark')) {
|
|
20
|
+
return {
|
|
21
|
+
'--bgColor-default': '#161b22',
|
|
22
|
+
'--bgColor-muted': '#21262d',
|
|
23
|
+
'--bgColor-neutral-muted': 'rgba(110, 118, 129, 0.2)',
|
|
24
|
+
'--fgColor-default': '#e6edf3',
|
|
25
|
+
'--fgColor-muted': '#8b949e',
|
|
26
|
+
'--fgColor-onEmphasis': '#ffffff',
|
|
27
|
+
'--borderColor-default': '#30363d',
|
|
28
|
+
'--borderColor-muted': '#30363d',
|
|
29
|
+
'--bgColor-accent-emphasis': '#2f81f7',
|
|
30
|
+
'--trigger-bg': '#21262d',
|
|
31
|
+
'--trigger-bg-hover': '#30363d',
|
|
32
|
+
'--trigger-border': '#30363d',
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
'--bgColor-default': '#ffffff',
|
|
37
|
+
'--bgColor-muted': '#f6f8fa',
|
|
38
|
+
'--bgColor-neutral-muted': '#eaeef2',
|
|
39
|
+
'--fgColor-default': '#1f2328',
|
|
40
|
+
'--fgColor-muted': '#656d76',
|
|
41
|
+
'--fgColor-onEmphasis': '#ffffff',
|
|
42
|
+
'--borderColor-default': '#d0d7de',
|
|
43
|
+
'--borderColor-muted': '#d8dee4',
|
|
44
|
+
'--bgColor-accent-emphasis': '#2f81f7',
|
|
45
|
+
'--trigger-bg': '#f6f8fa',
|
|
46
|
+
'--trigger-bg-hover': '#eaeef2',
|
|
47
|
+
'--trigger-border': '#d0d7de',
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/context.jsx
CHANGED
|
@@ -73,6 +73,9 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
73
73
|
if (canvasName) return null
|
|
74
74
|
const requested = sceneParam || flowName || sceneName
|
|
75
75
|
if (requested) {
|
|
76
|
+
// Allow fully-scoped flow names from URLs/widgets without re-prefixing
|
|
77
|
+
// (e.g. "Proto/flow" should not become "Proto/Proto/flow").
|
|
78
|
+
if (requested.includes('/')) return requested
|
|
76
79
|
return resolveFlowName(prototypeName, requested)
|
|
77
80
|
}
|
|
78
81
|
// 1. Page-specific flow (e.g., Example/Forms)
|
|
@@ -83,8 +86,14 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
83
86
|
const protoFlow = resolveFlowName(prototypeName, prototypeName)
|
|
84
87
|
if (flowExists(protoFlow)) return protoFlow
|
|
85
88
|
}
|
|
86
|
-
// 3.
|
|
87
|
-
|
|
89
|
+
// 3. Prototype-scoped default (e.g. Example/default)
|
|
90
|
+
if (prototypeName) {
|
|
91
|
+
const scopedDefault = resolveFlowName(prototypeName, 'default')
|
|
92
|
+
if (flowExists(scopedDefault)) return scopedDefault
|
|
93
|
+
}
|
|
94
|
+
// 4. Global default — or null if no flow exists at all
|
|
95
|
+
if (flowExists('default')) return 'default'
|
|
96
|
+
return null
|
|
88
97
|
}, [canvasName, sceneParam, flowName, sceneName, prototypeName, pageFlow])
|
|
89
98
|
|
|
90
99
|
// Auto-install body class sync (sb-key--value classes on <body>)
|
|
@@ -106,9 +115,10 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
106
115
|
return () => cleanup?.()
|
|
107
116
|
}, [])
|
|
108
117
|
|
|
109
|
-
// Skip flow loading for canvas pages
|
|
118
|
+
// Skip flow loading for canvas pages and flow-less pages
|
|
110
119
|
const { data, error } = useMemo(() => {
|
|
111
120
|
if (canvasName) return { data: null, error: null }
|
|
121
|
+
if (!activeFlowName) return { data: {}, error: null }
|
|
112
122
|
try {
|
|
113
123
|
let flowData = loadFlow(activeFlowName)
|
|
114
124
|
|
package/src/vite/data-plugin.js
CHANGED
|
@@ -573,13 +573,37 @@ export default function storyboardDataPlugin() {
|
|
|
573
573
|
configureServer(server) {
|
|
574
574
|
// Watch for data file changes in dev mode
|
|
575
575
|
const watcher = server.watcher
|
|
576
|
+
if (!buildResult) buildResult = buildIndex(root)
|
|
577
|
+
const knownCanvasNames = new Set(Object.keys(buildResult.index.canvas || {}))
|
|
578
|
+
const pendingCanvasUnlinks = new Map()
|
|
579
|
+
|
|
580
|
+
const triggerFullReload = () => {
|
|
581
|
+
buildResult = null
|
|
582
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
|
|
583
|
+
if (mod) {
|
|
584
|
+
server.moduleGraph.invalidateModule(mod)
|
|
585
|
+
server.ws.send({ type: 'full-reload' })
|
|
586
|
+
}
|
|
587
|
+
}
|
|
576
588
|
|
|
577
589
|
const invalidate = (filePath) => {
|
|
578
590
|
const normalized = filePath.replace(/\\/g, '/')
|
|
579
591
|
// Skip .canvas.jsonl content changes entirely — these are mutated
|
|
580
592
|
// at runtime by the canvas server API. A full-reload would create
|
|
581
593
|
// a feedback loop (save → file change → reload → lose editing state).
|
|
582
|
-
|
|
594
|
+
// Instead, send a custom HMR event so the active canvas page can refetch
|
|
595
|
+
// file-backed data in place with no navigation or document reload.
|
|
596
|
+
if (/\.canvas\.jsonl$/.test(normalized)) {
|
|
597
|
+
const parsed = parseDataFile(filePath)
|
|
598
|
+
if (parsed?.suffix === 'canvas' && parsed?.name) {
|
|
599
|
+
server.ws.send({
|
|
600
|
+
type: 'custom',
|
|
601
|
+
event: 'storyboard:canvas-file-changed',
|
|
602
|
+
data: { name: parsed.name },
|
|
603
|
+
})
|
|
604
|
+
}
|
|
605
|
+
return
|
|
606
|
+
}
|
|
583
607
|
|
|
584
608
|
// Invalidate when toolbar.config.json inside a prototype changes
|
|
585
609
|
if (normalized.endsWith('/toolbar.config.json') && normalized.includes('/prototypes/')) {
|
|
@@ -605,17 +629,64 @@ export default function storyboardDataPlugin() {
|
|
|
605
629
|
}
|
|
606
630
|
}
|
|
607
631
|
|
|
608
|
-
const invalidateOnAddRemove = (filePath) => {
|
|
632
|
+
const invalidateOnAddRemove = (filePath, eventType) => {
|
|
609
633
|
const parsed = parseDataFile(filePath)
|
|
610
634
|
const inFolder = filePath.replace(/\\/g, '/').includes('.folder/')
|
|
611
635
|
if (!parsed && !inFolder) return
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
636
|
+
|
|
637
|
+
// Canvas writers/editors can emit unlink+add for an in-place save.
|
|
638
|
+
// Treat canvas add/unlink as runtime data updates and never full-reload
|
|
639
|
+
// from watcher events. Canvas pages sync from disk via custom WS events.
|
|
640
|
+
if (parsed?.suffix === 'canvas') {
|
|
641
|
+
const name = parsed.name
|
|
642
|
+
if (eventType === 'unlink') {
|
|
643
|
+
const timer = setTimeout(() => {
|
|
644
|
+
pendingCanvasUnlinks.delete(name)
|
|
645
|
+
knownCanvasNames.delete(name)
|
|
646
|
+
server.ws.send({
|
|
647
|
+
type: 'custom',
|
|
648
|
+
event: 'storyboard:canvas-file-changed',
|
|
649
|
+
data: { name },
|
|
650
|
+
})
|
|
651
|
+
}, 1500)
|
|
652
|
+
pendingCanvasUnlinks.set(name, timer)
|
|
653
|
+
return
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (eventType === 'add') {
|
|
657
|
+
const pending = pendingCanvasUnlinks.get(name)
|
|
658
|
+
if (pending) {
|
|
659
|
+
clearTimeout(pending)
|
|
660
|
+
pendingCanvasUnlinks.delete(name)
|
|
661
|
+
server.ws.send({
|
|
662
|
+
type: 'custom',
|
|
663
|
+
event: 'storyboard:canvas-file-changed',
|
|
664
|
+
data: { name },
|
|
665
|
+
})
|
|
666
|
+
return
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (knownCanvasNames.has(name)) {
|
|
670
|
+
server.ws.send({
|
|
671
|
+
type: 'custom',
|
|
672
|
+
event: 'storyboard:canvas-file-changed',
|
|
673
|
+
data: { name },
|
|
674
|
+
})
|
|
675
|
+
return
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
knownCanvasNames.add(name)
|
|
679
|
+
server.ws.send({
|
|
680
|
+
type: 'custom',
|
|
681
|
+
event: 'storyboard:canvas-file-changed',
|
|
682
|
+
data: { name },
|
|
683
|
+
})
|
|
684
|
+
return
|
|
685
|
+
}
|
|
618
686
|
}
|
|
687
|
+
|
|
688
|
+
// Non-canvas additions/removals and folder changes update the route/data graph.
|
|
689
|
+
triggerFullReload()
|
|
619
690
|
}
|
|
620
691
|
|
|
621
692
|
// Watch storyboard.config.json for changes
|
|
@@ -632,14 +703,33 @@ export default function storyboardDataPlugin() {
|
|
|
632
703
|
}
|
|
633
704
|
}
|
|
634
705
|
|
|
635
|
-
watcher.on('add', invalidateOnAddRemove)
|
|
636
|
-
watcher.on('unlink', invalidateOnAddRemove)
|
|
706
|
+
watcher.on('add', (filePath) => invalidateOnAddRemove(filePath, 'add'))
|
|
707
|
+
watcher.on('unlink', (filePath) => invalidateOnAddRemove(filePath, 'unlink'))
|
|
637
708
|
watcher.on('change', (filePath) => {
|
|
638
709
|
invalidate(filePath)
|
|
639
710
|
invalidateConfig(filePath)
|
|
640
711
|
})
|
|
641
712
|
},
|
|
642
713
|
|
|
714
|
+
handleHotUpdate(ctx) {
|
|
715
|
+
const normalized = ctx.file.replace(/\\/g, '/')
|
|
716
|
+
if (!/\.canvas\.jsonl$/.test(normalized)) return
|
|
717
|
+
|
|
718
|
+
const parsed = parseDataFile(ctx.file)
|
|
719
|
+
if (parsed?.suffix === 'canvas' && parsed?.name) {
|
|
720
|
+
ctx.server.ws.send({
|
|
721
|
+
type: 'custom',
|
|
722
|
+
event: 'storyboard:canvas-file-changed',
|
|
723
|
+
data: { name: parsed.name },
|
|
724
|
+
})
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Prevent Vite's default fallback behavior (full page reload) for
|
|
728
|
+
// non-module .canvas.jsonl edits. Canvas pages consume these updates
|
|
729
|
+
// through the custom WS event and in-page refetch.
|
|
730
|
+
return []
|
|
731
|
+
},
|
|
732
|
+
|
|
643
733
|
// Rebuild index on each build start
|
|
644
734
|
buildStart() {
|
|
645
735
|
buildResult = null
|