@dfosco/storyboard-react 4.0.0-beta.11 → 4.0.0-beta.13
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 +3 -3
- package/src/canvas/CanvasPage.jsx +58 -30
- package/src/canvas/CanvasPage.module.css +6 -0
- package/src/canvas/canvasApi.js +4 -0
- package/src/canvas/widgets/ComponentWidget.module.css +3 -0
- package/src/canvas/widgets/WidgetChrome.jsx +1 -0
- package/src/canvas/widgets/WidgetChrome.module.css +2 -6
- package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
- package/src/canvas/widgets/widgetConfig.js +10 -1
- package/src/hooks/useSceneData.js +4 -2
- package/src/vite/data-plugin.js +64 -22
- package/src/vite/data-plugin.test.js +218 -1
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "4.0.0-beta.
|
|
7
|
-
"@dfosco/tiny-canvas": "4.0.0-beta.
|
|
6
|
+
"@dfosco/storyboard-core": "4.0.0-beta.13",
|
|
7
|
+
"@dfosco/tiny-canvas": "4.0.0-beta.13",
|
|
8
8
|
"@neodrag/react": "^2.3.1",
|
|
9
9
|
"glob": "^11.0.0",
|
|
10
10
|
"jsonc-parser": "^3.3.1",
|
|
@@ -12,7 +12,7 @@ import { isFigmaUrl, sanitizeFigmaUrl } from './widgets/figmaUrl.js'
|
|
|
12
12
|
import WidgetChrome from './widgets/WidgetChrome.jsx'
|
|
13
13
|
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
14
14
|
import useUndoRedo from './useUndoRedo.js'
|
|
15
|
-
import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage } from './canvasApi.js'
|
|
15
|
+
import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage, getCanvas as getCanvasApi } from './canvasApi.js'
|
|
16
16
|
import styles from './CanvasPage.module.css'
|
|
17
17
|
|
|
18
18
|
const ZOOM_MIN = 25
|
|
@@ -51,6 +51,7 @@ function resolveCanvasThemeFromStorage() {
|
|
|
51
51
|
* Get the copyable URL for a widget based on its type.
|
|
52
52
|
* Returns the most relevant URL/path for the widget content.
|
|
53
53
|
*/
|
|
54
|
+
// eslint-disable-next-line no-unused-vars
|
|
54
55
|
function getWidgetCopyableUrl(widget) {
|
|
55
56
|
const { type, props = {} } = widget
|
|
56
57
|
const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
|
|
@@ -403,13 +404,13 @@ export default function CanvasPage({ name }) {
|
|
|
403
404
|
// Flag to suppress the click-based selection reset that fires after a drag
|
|
404
405
|
const justDraggedRef = useRef(false)
|
|
405
406
|
|
|
406
|
-
const handleItemDragStart = useCallback((dragId
|
|
407
|
+
const handleItemDragStart = useCallback((dragId) => {
|
|
407
408
|
const ids = selectedIdsRef.current
|
|
408
409
|
peerArticlesRef.current.clear()
|
|
409
410
|
if (ids.size <= 1 || !ids.has(dragId)) return
|
|
410
411
|
|
|
411
412
|
// Suppress selection changes for the duration of the drag
|
|
412
|
-
justDraggedRef.current = true
|
|
413
|
+
justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
|
|
413
414
|
|
|
414
415
|
// Collect peer article elements for transition on drag end
|
|
415
416
|
for (const id of ids) {
|
|
@@ -449,6 +450,8 @@ export default function CanvasPage({ name }) {
|
|
|
449
450
|
setLocalWidgets(canvas?.widgets ?? null)
|
|
450
451
|
setLocalSources(canvas?.sources ?? [])
|
|
451
452
|
setCanvasTitle(canvas?.title || name)
|
|
453
|
+
setSnapEnabled(canvas?.snapToGrid ?? false)
|
|
454
|
+
setSnapGridSize(canvas?.gridSize || 40)
|
|
452
455
|
undoRedo.reset()
|
|
453
456
|
}
|
|
454
457
|
|
|
@@ -575,7 +578,7 @@ export default function CanvasPage({ name }) {
|
|
|
575
578
|
if (ids.size > 1 && ids.has(dragId)) {
|
|
576
579
|
transitionPeers()
|
|
577
580
|
// Suppress the click-based selection reset that fires after pointerup
|
|
578
|
-
justDraggedRef.current = true
|
|
581
|
+
justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
|
|
579
582
|
requestAnimationFrame(() => { justDraggedRef.current = false })
|
|
580
583
|
undoRedo.snapshot(stateRef.current, 'multi-move')
|
|
581
584
|
|
|
@@ -911,7 +914,7 @@ export default function CanvasPage({ name }) {
|
|
|
911
914
|
function handleSnapToggle() {
|
|
912
915
|
setSnapEnabled((prev) => {
|
|
913
916
|
const next = !prev
|
|
914
|
-
updateCanvas(name, { snapToGrid: next }).catch((err) =>
|
|
917
|
+
updateCanvas(name, { settings: { snapToGrid: next } }).catch((err) =>
|
|
915
918
|
console.error('[canvas] Failed to persist snap setting:', err)
|
|
916
919
|
)
|
|
917
920
|
return next
|
|
@@ -929,6 +932,17 @@ export default function CanvasPage({ name }) {
|
|
|
929
932
|
snapEnabledRef.current = snapEnabled
|
|
930
933
|
}, [snapEnabled])
|
|
931
934
|
|
|
935
|
+
// Respond to snap-state requests from Svelte toolbar (handles mount-order race)
|
|
936
|
+
useEffect(() => {
|
|
937
|
+
function handleRequest() {
|
|
938
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
|
|
939
|
+
detail: { snapEnabled: snapEnabledRef.current }
|
|
940
|
+
}))
|
|
941
|
+
}
|
|
942
|
+
document.addEventListener('storyboard:canvas:snap-state-request', handleRequest)
|
|
943
|
+
return () => document.removeEventListener('storyboard:canvas:snap-state-request', handleRequest)
|
|
944
|
+
}, [])
|
|
945
|
+
|
|
932
946
|
// Listen for gridSize from Svelte toolbar config
|
|
933
947
|
useEffect(() => {
|
|
934
948
|
function handleGridSize(e) {
|
|
@@ -1015,33 +1029,13 @@ export default function CanvasPage({ name }) {
|
|
|
1015
1029
|
e.preventDefault()
|
|
1016
1030
|
setSelectedWidgetIds(new Set())
|
|
1017
1031
|
}
|
|
1018
|
-
// Copy
|
|
1019
|
-
//
|
|
1020
|
-
// - Shift+C (no cmd) → copy widget ID (or file path for images)
|
|
1032
|
+
// Copy shortcut (single widget selected):
|
|
1033
|
+
// cmd+c → copy canvasName/widgetId (for cross-canvas paste-duplicate)
|
|
1021
1034
|
const mod = e.metaKey || e.ctrlKey
|
|
1022
1035
|
if (mod && e.key === 'c' && !e.shiftKey && selectedWidgetIds.size === 1) {
|
|
1023
1036
|
const widgetId = [...selectedWidgetIds][0]
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
e.preventDefault()
|
|
1027
|
-
const url = getWidgetCopyableUrl(widget)
|
|
1028
|
-
if (url) {
|
|
1029
|
-
navigator.clipboard.writeText(url).catch(() => {})
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
// Shift+C (uppercase C, no cmd) → copy ID or file path
|
|
1034
|
-
if (e.key === 'C' && e.shiftKey && !mod && selectedWidgetIds.size === 1) {
|
|
1035
|
-
const widgetId = [...selectedWidgetIds][0]
|
|
1036
|
-
const widget = localWidgets?.find(w => w.id === widgetId)
|
|
1037
|
-
if (widget) {
|
|
1038
|
-
e.preventDefault()
|
|
1039
|
-
if (widget.type === 'image' && widget.props?.src) {
|
|
1040
|
-
navigator.clipboard.writeText(`src/canvas/images/${widget.props.src}`).catch(() => {})
|
|
1041
|
-
} else {
|
|
1042
|
-
navigator.clipboard.writeText(widgetId).catch(() => {})
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1037
|
+
e.preventDefault()
|
|
1038
|
+
navigator.clipboard.writeText(`${name}/${widgetId}`).catch(() => {})
|
|
1045
1039
|
}
|
|
1046
1040
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
1047
1041
|
e.preventDefault()
|
|
@@ -1217,6 +1211,40 @@ export default function CanvasPage({ name }) {
|
|
|
1217
1211
|
|
|
1218
1212
|
e.preventDefault()
|
|
1219
1213
|
|
|
1214
|
+
// Detect canvasName/widgetId format for widget duplication (cross-canvas copy-paste)
|
|
1215
|
+
const widgetRefMatch = text.match(/^([^/]+)\/([^/]+)$/)
|
|
1216
|
+
if (widgetRefMatch) {
|
|
1217
|
+
const [, sourceCanvas, sourceWidgetId] = widgetRefMatch
|
|
1218
|
+
// Component widgets are code, not duplicable data — silently consume the ref
|
|
1219
|
+
if (sourceWidgetId.startsWith('jsx-')) return
|
|
1220
|
+
try {
|
|
1221
|
+
let sourceWidget = null
|
|
1222
|
+
if (sourceCanvas === name) {
|
|
1223
|
+
sourceWidget = (localWidgets ?? []).find(w => w.id === sourceWidgetId)
|
|
1224
|
+
} else {
|
|
1225
|
+
const canvasData = await getCanvasApi(sourceCanvas)
|
|
1226
|
+
sourceWidget = (canvasData?.widgets ?? []).find(w => w.id === sourceWidgetId)
|
|
1227
|
+
}
|
|
1228
|
+
if (sourceWidget) {
|
|
1229
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1230
|
+
const pos = centerPositionForWidget(center, sourceWidget.type, sourceWidget.props)
|
|
1231
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
1232
|
+
const result = await addWidgetApi(name, {
|
|
1233
|
+
type: sourceWidget.type,
|
|
1234
|
+
props: { ...sourceWidget.props },
|
|
1235
|
+
position: pos,
|
|
1236
|
+
})
|
|
1237
|
+
if (result.success && result.widget) {
|
|
1238
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
} catch (err) {
|
|
1242
|
+
console.error('[canvas] Failed to paste widget reference:', err)
|
|
1243
|
+
}
|
|
1244
|
+
// Always consume the ref — never fall through to markdown creation
|
|
1245
|
+
return
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1220
1248
|
let type, props
|
|
1221
1249
|
const url = looksLikeWebUrl(text)
|
|
1222
1250
|
if (url) {
|
|
@@ -1256,7 +1284,7 @@ export default function CanvasPage({ name }) {
|
|
|
1256
1284
|
|
|
1257
1285
|
document.addEventListener('paste', handlePaste)
|
|
1258
1286
|
return () => document.removeEventListener('paste', handlePaste)
|
|
1259
|
-
}, [name, undoRedo])
|
|
1287
|
+
}, [name, undoRedo, localWidgets])
|
|
1260
1288
|
|
|
1261
1289
|
// --- Drag and drop handlers for images from Finder/file manager ---
|
|
1262
1290
|
// Separate effect to ensure listeners attach after scroll container mounts (loading=false)
|
|
@@ -103,6 +103,12 @@
|
|
|
103
103
|
overflow: visible;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/* Elevate stacking context for hovered/selected widgets so their chrome
|
|
107
|
+
(toolbar, menus, selection outline) renders above sibling widgets. */
|
|
108
|
+
:global(.tc-drag:has([data-tc-elevated])) {
|
|
109
|
+
z-index: 1;
|
|
110
|
+
}
|
|
111
|
+
|
|
106
112
|
.localEditingLabel {
|
|
107
113
|
display: inline-flex;
|
|
108
114
|
align-items: center;
|
package/src/canvas/canvasApi.js
CHANGED
|
@@ -47,3 +47,7 @@ export function uploadImage(dataUrl, canvasName) {
|
|
|
47
47
|
export function toggleImagePrivacy(filename) {
|
|
48
48
|
return request('/image/toggle-private', 'POST', { filename })
|
|
49
49
|
}
|
|
50
|
+
|
|
51
|
+
export function getCanvas(name) {
|
|
52
|
+
return request(`/read?name=${encodeURIComponent(name)}`, 'GET')
|
|
53
|
+
}
|
|
@@ -421,6 +421,7 @@ export default function WidgetChrome({
|
|
|
421
421
|
return (
|
|
422
422
|
<div
|
|
423
423
|
className={styles.chromeContainer}
|
|
424
|
+
data-tc-elevated={(hovered || selected) || undefined}
|
|
424
425
|
onMouseEnter={(readOnly && !hasFeatures) ? undefined : handleMouseEnter}
|
|
425
426
|
onMouseLeave={(readOnly && !hasFeatures) ? undefined : handleMouseLeave}
|
|
426
427
|
>
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
top: calc(100% + 10px);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/* Trigger dot —
|
|
36
|
+
/* Trigger dot — positioned in the toolbar, visible at rest */
|
|
37
37
|
.triggerDot {
|
|
38
38
|
width: 6px;
|
|
39
39
|
height: 6px;
|
|
@@ -41,10 +41,6 @@
|
|
|
41
41
|
background: var(--borderColor-muted, #d0d7de);
|
|
42
42
|
opacity: 0.5;
|
|
43
43
|
transition: opacity 120ms;
|
|
44
|
-
position: absolute;
|
|
45
|
-
left: 50%;
|
|
46
|
-
top: 50%;
|
|
47
|
-
transform: translate(-50%, -50%);
|
|
48
44
|
}
|
|
49
45
|
|
|
50
46
|
:global([data-sb-canvas-theme^='dark']) .triggerDot {
|
|
@@ -235,7 +231,7 @@
|
|
|
235
231
|
.overflowMenu {
|
|
236
232
|
position: absolute;
|
|
237
233
|
top: calc(100% + 10px);
|
|
238
|
-
|
|
234
|
+
left: 0;
|
|
239
235
|
min-width: max-content;
|
|
240
236
|
padding: 4px;
|
|
241
237
|
background: var(--bgColor-default, #ffffff);
|
|
@@ -34,7 +34,16 @@ function resolveFeature(feature) {
|
|
|
34
34
|
if (key === 'items' && Array.isArray(val)) {
|
|
35
35
|
resolved[key] = val.map((item) => {
|
|
36
36
|
const r = {}
|
|
37
|
-
for (const [k, v] of Object.entries(item))
|
|
37
|
+
for (const [k, v] of Object.entries(item)) {
|
|
38
|
+
// Resolve nested alt object inside items
|
|
39
|
+
if (k === 'alt' && v && typeof v === 'object') {
|
|
40
|
+
const altResolved = {}
|
|
41
|
+
for (const [ak, av] of Object.entries(v)) altResolved[ak] = resolveVar(av)
|
|
42
|
+
r[k] = altResolved
|
|
43
|
+
} else {
|
|
44
|
+
r[k] = resolveVar(v)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
38
47
|
return r
|
|
39
48
|
})
|
|
40
49
|
} else if (key === 'alt' && val && typeof val === 'object') {
|
|
@@ -17,10 +17,12 @@ import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
|
|
|
17
17
|
*
|
|
18
18
|
* @param {string} [path] - Dot-notation path (e.g. 'user.profile.name').
|
|
19
19
|
* Omit to get the entire flow object.
|
|
20
|
+
* @param {{ optional?: boolean }} [opts] - Pass { optional: true } to suppress
|
|
21
|
+
* the "path not found" warning for optional data.
|
|
20
22
|
* @returns {*} The resolved value. Returns {} if path is missing after loading.
|
|
21
23
|
* @throws If used outside a StoryboardProvider.
|
|
22
24
|
*/
|
|
23
|
-
export function useFlowData(path) {
|
|
25
|
+
export function useFlowData(path, opts) {
|
|
24
26
|
const context = useContext(StoryboardContext)
|
|
25
27
|
|
|
26
28
|
if (context === null) {
|
|
@@ -73,7 +75,7 @@ export function useFlowData(path) {
|
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
if (sceneValue === undefined) {
|
|
76
|
-
if (data != null && Object.keys(data).length > 0) {
|
|
78
|
+
if (!opts?.optional && data != null && Object.keys(data).length > 0) {
|
|
77
79
|
console.warn(`[useFlowData] Path "${path}" not found in flow data.`)
|
|
78
80
|
}
|
|
79
81
|
return {}
|
package/src/vite/data-plugin.js
CHANGED
|
@@ -633,6 +633,22 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
633
633
|
`export { flows, scenes, objects, records, prototypes, folders, canvases }`,
|
|
634
634
|
`export const index = { flows, scenes, objects, records, prototypes, folders, canvases }`,
|
|
635
635
|
`export default index`,
|
|
636
|
+
'',
|
|
637
|
+
'// Live-patch canvas data on HMR events so SPA navigation shows fresh state',
|
|
638
|
+
'if (import.meta.hot) {',
|
|
639
|
+
' import.meta.hot.on("storyboard:canvas-file-changed", (data) => {',
|
|
640
|
+
' if (!data) return',
|
|
641
|
+
' if (data.removed) {',
|
|
642
|
+
' delete canvases[data.name]',
|
|
643
|
+
' } else if (data.metadata) {',
|
|
644
|
+
' // Merge into existing entry to preserve build-time fields (_jsxModule, _jsxImport, etc.)',
|
|
645
|
+
' canvases[data.name] = canvases[data.name]',
|
|
646
|
+
' ? Object.assign({}, canvases[data.name], data.metadata)',
|
|
647
|
+
' : data.metadata',
|
|
648
|
+
' }',
|
|
649
|
+
' init({ flows, objects, records, prototypes, folders, canvases })',
|
|
650
|
+
' })',
|
|
651
|
+
'}',
|
|
636
652
|
].join('\n')
|
|
637
653
|
}
|
|
638
654
|
|
|
@@ -697,7 +713,7 @@ export default function storyboardDataPlugin() {
|
|
|
697
713
|
const rawHtml = [
|
|
698
714
|
'<!DOCTYPE html>',
|
|
699
715
|
'<html><head>',
|
|
700
|
-
'<style>html,body{margin:0;padding:0;width:100%;height:100
|
|
716
|
+
'<style>html,body{margin:0;padding:0;width:100%;height:100%;background:var(--bgColor-default,transparent)}#root{width:100%;height:100%}</style>',
|
|
701
717
|
'</head><body>',
|
|
702
718
|
'<div id="root"></div>',
|
|
703
719
|
`<script type="module" src="/@fs${isolateEntryPath}"></script>`,
|
|
@@ -730,22 +746,50 @@ export default function storyboardDataPlugin() {
|
|
|
730
746
|
}
|
|
731
747
|
}
|
|
732
748
|
|
|
749
|
+
// Mark the virtual module as stale so the next page load rebuilds it,
|
|
750
|
+
// but do NOT trigger a full-reload (avoids losing canvas editing state).
|
|
751
|
+
const softInvalidate = () => {
|
|
752
|
+
buildResult = null
|
|
753
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
|
|
754
|
+
if (mod) server.moduleGraph.invalidateModule(mod)
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Read a canvas file and build HMR metadata for the client-side listener.
|
|
758
|
+
const readCanvasMetadata = (filePath, parsed) => {
|
|
759
|
+
try {
|
|
760
|
+
const absPath = path.resolve(root, filePath)
|
|
761
|
+
const raw = fs.readFileSync(absPath, 'utf-8')
|
|
762
|
+
const materialized = materializeFromText(raw)
|
|
763
|
+
const result = { ...materialized }
|
|
764
|
+
// Inject _route and _folder the same way generateModule does
|
|
765
|
+
if (parsed.inferredRoute) result._route = parsed.inferredRoute
|
|
766
|
+
const folderDirMatch = path.relative(root, absPath).replace(/\\/g, '/').match(/(?:^|\/)src\/(?:prototypes|canvas)\/([^/]+)\.folder\//)
|
|
767
|
+
if (folderDirMatch) result._folder = folderDirMatch[1]
|
|
768
|
+
return result
|
|
769
|
+
} catch {
|
|
770
|
+
return null
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
733
774
|
const invalidate = (filePath) => {
|
|
734
775
|
const normalized = filePath.replace(/\\/g, '/')
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
776
|
+
// Canvas .jsonl content changes are mutated at runtime by the canvas
|
|
777
|
+
// server API. A full-reload would create a feedback loop (save →
|
|
778
|
+
// file change → reload → lose editing state). Instead, soft-invalidate
|
|
779
|
+
// the virtual module (so page refresh picks up changes) and send a
|
|
780
|
+
// custom HMR event with updated metadata so the canvas page and
|
|
781
|
+
// viewfinder can react in place.
|
|
740
782
|
if (/\.canvas\.jsonl$/.test(normalized)) {
|
|
741
783
|
const parsed = parseDataFile(filePath)
|
|
742
784
|
if (parsed?.suffix === 'canvas' && parsed?.name) {
|
|
785
|
+
const metadata = readCanvasMetadata(filePath, parsed)
|
|
743
786
|
server.ws.send({
|
|
744
787
|
type: 'custom',
|
|
745
788
|
event: 'storyboard:canvas-file-changed',
|
|
746
|
-
data: { name: parsed.name },
|
|
789
|
+
data: { name: parsed.name, ...(metadata ? { metadata } : {}) },
|
|
747
790
|
})
|
|
748
791
|
}
|
|
792
|
+
softInvalidate()
|
|
749
793
|
return
|
|
750
794
|
}
|
|
751
795
|
|
|
@@ -790,23 +834,27 @@ export default function storyboardDataPlugin() {
|
|
|
790
834
|
server.ws.send({
|
|
791
835
|
type: 'custom',
|
|
792
836
|
event: 'storyboard:canvas-file-changed',
|
|
793
|
-
data: { name },
|
|
837
|
+
data: { name, removed: true },
|
|
794
838
|
})
|
|
839
|
+
softInvalidate()
|
|
795
840
|
}, 1500)
|
|
796
841
|
pendingCanvasUnlinks.set(name, timer)
|
|
797
842
|
return
|
|
798
843
|
}
|
|
799
844
|
|
|
800
845
|
if (eventType === 'add') {
|
|
846
|
+
const metadata = readCanvasMetadata(filePath, parsed)
|
|
801
847
|
const pending = pendingCanvasUnlinks.get(name)
|
|
802
848
|
if (pending) {
|
|
849
|
+
// unlink+add pair = in-place save (atomic write), not a real remove
|
|
803
850
|
clearTimeout(pending)
|
|
804
851
|
pendingCanvasUnlinks.delete(name)
|
|
805
852
|
server.ws.send({
|
|
806
853
|
type: 'custom',
|
|
807
854
|
event: 'storyboard:canvas-file-changed',
|
|
808
|
-
data: { name },
|
|
855
|
+
data: { name, ...(metadata ? { metadata } : {}) },
|
|
809
856
|
})
|
|
857
|
+
softInvalidate()
|
|
810
858
|
return
|
|
811
859
|
}
|
|
812
860
|
|
|
@@ -814,8 +862,9 @@ export default function storyboardDataPlugin() {
|
|
|
814
862
|
server.ws.send({
|
|
815
863
|
type: 'custom',
|
|
816
864
|
event: 'storyboard:canvas-file-changed',
|
|
817
|
-
data: { name },
|
|
865
|
+
data: { name, ...(metadata ? { metadata } : {}) },
|
|
818
866
|
})
|
|
867
|
+
softInvalidate()
|
|
819
868
|
return
|
|
820
869
|
}
|
|
821
870
|
|
|
@@ -823,8 +872,9 @@ export default function storyboardDataPlugin() {
|
|
|
823
872
|
server.ws.send({
|
|
824
873
|
type: 'custom',
|
|
825
874
|
event: 'storyboard:canvas-file-changed',
|
|
826
|
-
data: { name },
|
|
875
|
+
data: { name, ...(metadata ? { metadata } : {}) },
|
|
827
876
|
})
|
|
877
|
+
softInvalidate()
|
|
828
878
|
return
|
|
829
879
|
}
|
|
830
880
|
}
|
|
@@ -859,18 +909,10 @@ export default function storyboardDataPlugin() {
|
|
|
859
909
|
const normalized = ctx.file.replace(/\\/g, '/')
|
|
860
910
|
if (!/\.canvas\.jsonl$/.test(normalized)) return
|
|
861
911
|
|
|
862
|
-
const parsed = parseDataFile(ctx.file)
|
|
863
|
-
if (parsed?.suffix === 'canvas' && parsed?.name) {
|
|
864
|
-
ctx.server.ws.send({
|
|
865
|
-
type: 'custom',
|
|
866
|
-
event: 'storyboard:canvas-file-changed',
|
|
867
|
-
data: { name: parsed.name },
|
|
868
|
-
})
|
|
869
|
-
}
|
|
870
|
-
|
|
871
912
|
// Prevent Vite's default fallback behavior (full page reload) for
|
|
872
|
-
// non-module .canvas.jsonl edits.
|
|
873
|
-
//
|
|
913
|
+
// non-module .canvas.jsonl edits. The watcher 'change' handler
|
|
914
|
+
// (invalidate) already sends the custom HMR event and soft-invalidates
|
|
915
|
+
// the virtual module — no duplicate event needed here.
|
|
874
916
|
return []
|
|
875
917
|
},
|
|
876
918
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
|
|
1
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import storyboardDataPlugin, { resolveTemplateVars, computeTemplateVars } from './data-plugin.js'
|
|
@@ -829,3 +829,220 @@ describe('template variable integration', () => {
|
|
|
829
829
|
warnSpy.mockRestore()
|
|
830
830
|
})
|
|
831
831
|
})
|
|
832
|
+
|
|
833
|
+
// ── Canvas watcher / HMR tests ──────────────────────────────────────
|
|
834
|
+
|
|
835
|
+
describe('canvas watcher behavior', () => {
|
|
836
|
+
/** Helper: create a mock Vite dev server for configureServer */
|
|
837
|
+
function createMockServer(root) {
|
|
838
|
+
const listeners = {}
|
|
839
|
+
const wsSent = []
|
|
840
|
+
const invalidatedModules = []
|
|
841
|
+
|
|
842
|
+
return {
|
|
843
|
+
wsSent,
|
|
844
|
+
invalidatedModules,
|
|
845
|
+
listeners,
|
|
846
|
+
config: { root, base: '/' },
|
|
847
|
+
watcher: {
|
|
848
|
+
add: vi.fn(),
|
|
849
|
+
on(event, fn) {
|
|
850
|
+
if (!listeners[event]) listeners[event] = []
|
|
851
|
+
listeners[event].push(fn)
|
|
852
|
+
},
|
|
853
|
+
},
|
|
854
|
+
moduleGraph: {
|
|
855
|
+
getModuleById(id) {
|
|
856
|
+
if (id === RESOLVED_ID) return { id: RESOLVED_ID }
|
|
857
|
+
return null
|
|
858
|
+
},
|
|
859
|
+
invalidateModule(mod) {
|
|
860
|
+
invalidatedModules.push(mod.id)
|
|
861
|
+
},
|
|
862
|
+
},
|
|
863
|
+
ws: {
|
|
864
|
+
send(msg) { wsSent.push(msg) },
|
|
865
|
+
},
|
|
866
|
+
middlewares: {
|
|
867
|
+
use: vi.fn(),
|
|
868
|
+
},
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** Emit a watcher event on the mock server */
|
|
873
|
+
function emit(server, event, filePath) {
|
|
874
|
+
for (const fn of (server.listeners[event] || [])) {
|
|
875
|
+
fn(filePath)
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function writeCanvasFile(dir, name, title) {
|
|
880
|
+
const canvasDir = path.join(dir, 'src', 'canvas')
|
|
881
|
+
mkdirSync(canvasDir, { recursive: true })
|
|
882
|
+
const evt = { event: 'canvas_created', title: title || name, timestamp: Date.now() }
|
|
883
|
+
writeFileSync(path.join(canvasDir, `${name}.canvas.jsonl`), JSON.stringify(evt) + '\n')
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
it('soft-invalidates virtual module on canvas content change (no full-reload)', () => {
|
|
887
|
+
writeCanvasFile(tmpDir, 'test-canvas', 'Original Title')
|
|
888
|
+
const plugin = createPlugin()
|
|
889
|
+
// Force initial buildResult
|
|
890
|
+
plugin.load(RESOLVED_ID)
|
|
891
|
+
|
|
892
|
+
const server = createMockServer(tmpDir)
|
|
893
|
+
plugin.configureServer(server)
|
|
894
|
+
|
|
895
|
+
// Simulate a canvas file content change
|
|
896
|
+
const canvasPath = path.join(tmpDir, 'src', 'canvas', 'test-canvas.canvas.jsonl')
|
|
897
|
+
emit(server, 'change', canvasPath)
|
|
898
|
+
|
|
899
|
+
// Should have sent custom HMR event (not full-reload)
|
|
900
|
+
const customEvents = server.wsSent.filter(m => m.type === 'custom')
|
|
901
|
+
const fullReloads = server.wsSent.filter(m => m.type === 'full-reload')
|
|
902
|
+
|
|
903
|
+
expect(customEvents.length).toBe(1)
|
|
904
|
+
expect(customEvents[0].event).toBe('storyboard:canvas-file-changed')
|
|
905
|
+
expect(customEvents[0].data.name).toBe('test-canvas')
|
|
906
|
+
expect(fullReloads.length).toBe(0)
|
|
907
|
+
|
|
908
|
+
// Should have invalidated the virtual module
|
|
909
|
+
expect(server.invalidatedModules).toContain(RESOLVED_ID)
|
|
910
|
+
})
|
|
911
|
+
|
|
912
|
+
it('includes metadata in HMR event for canvas content changes', () => {
|
|
913
|
+
writeCanvasFile(tmpDir, 'meta-canvas', 'My Canvas Title')
|
|
914
|
+
const plugin = createPlugin()
|
|
915
|
+
plugin.load(RESOLVED_ID)
|
|
916
|
+
|
|
917
|
+
const server = createMockServer(tmpDir)
|
|
918
|
+
plugin.configureServer(server)
|
|
919
|
+
|
|
920
|
+
emit(server, 'change', path.join(tmpDir, 'src', 'canvas', 'meta-canvas.canvas.jsonl'))
|
|
921
|
+
|
|
922
|
+
const event = server.wsSent.find(m => m.type === 'custom')
|
|
923
|
+
expect(event.data.metadata).toBeDefined()
|
|
924
|
+
expect(event.data.metadata.title).toBe('My Canvas Title')
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
it('soft-invalidates on canvas file add (new canvas)', () => {
|
|
928
|
+
const plugin = createPlugin()
|
|
929
|
+
plugin.load(RESOLVED_ID)
|
|
930
|
+
|
|
931
|
+
const server = createMockServer(tmpDir)
|
|
932
|
+
plugin.configureServer(server)
|
|
933
|
+
|
|
934
|
+
// Create the file after the server is configured
|
|
935
|
+
writeCanvasFile(tmpDir, 'new-canvas', 'Brand New')
|
|
936
|
+
emit(server, 'add', path.join(tmpDir, 'src', 'canvas', 'new-canvas.canvas.jsonl'))
|
|
937
|
+
|
|
938
|
+
const customEvents = server.wsSent.filter(m => m.type === 'custom')
|
|
939
|
+
const fullReloads = server.wsSent.filter(m => m.type === 'full-reload')
|
|
940
|
+
|
|
941
|
+
expect(customEvents.length).toBe(1)
|
|
942
|
+
expect(customEvents[0].data.name).toBe('new-canvas')
|
|
943
|
+
expect(customEvents[0].data.metadata).toBeDefined()
|
|
944
|
+
expect(fullReloads.length).toBe(0)
|
|
945
|
+
expect(server.invalidatedModules).toContain(RESOLVED_ID)
|
|
946
|
+
})
|
|
947
|
+
|
|
948
|
+
it('soft-invalidates on canvas file unlink after timeout (true delete)', async () => {
|
|
949
|
+
writeCanvasFile(tmpDir, 'doomed-canvas', 'Gone Soon')
|
|
950
|
+
const plugin = createPlugin()
|
|
951
|
+
plugin.load(RESOLVED_ID)
|
|
952
|
+
|
|
953
|
+
const server = createMockServer(tmpDir)
|
|
954
|
+
plugin.configureServer(server)
|
|
955
|
+
|
|
956
|
+
emit(server, 'unlink', path.join(tmpDir, 'src', 'canvas', 'doomed-canvas.canvas.jsonl'))
|
|
957
|
+
|
|
958
|
+
// Immediately after unlink — no event yet (deferred by 1500ms)
|
|
959
|
+
expect(server.wsSent.length).toBe(0)
|
|
960
|
+
|
|
961
|
+
// Wait for deferred timer
|
|
962
|
+
await new Promise(resolve => setTimeout(resolve, 1600))
|
|
963
|
+
|
|
964
|
+
const customEvents = server.wsSent.filter(m => m.type === 'custom')
|
|
965
|
+
expect(customEvents.length).toBe(1)
|
|
966
|
+
expect(customEvents[0].data.name).toBe('doomed-canvas')
|
|
967
|
+
expect(customEvents[0].data.removed).toBe(true)
|
|
968
|
+
expect(server.invalidatedModules).toContain(RESOLVED_ID)
|
|
969
|
+
})
|
|
970
|
+
|
|
971
|
+
it('cancels deferred unlink on add (atomic write / in-place save)', async () => {
|
|
972
|
+
writeCanvasFile(tmpDir, 'saved-canvas', 'Saved')
|
|
973
|
+
const plugin = createPlugin()
|
|
974
|
+
plugin.load(RESOLVED_ID)
|
|
975
|
+
|
|
976
|
+
const server = createMockServer(tmpDir)
|
|
977
|
+
plugin.configureServer(server)
|
|
978
|
+
|
|
979
|
+
const canvasPath = path.join(tmpDir, 'src', 'canvas', 'saved-canvas.canvas.jsonl')
|
|
980
|
+
|
|
981
|
+
// Simulate atomic write: unlink then add within 1500ms
|
|
982
|
+
emit(server, 'unlink', canvasPath)
|
|
983
|
+
emit(server, 'add', canvasPath)
|
|
984
|
+
|
|
985
|
+
// Should have sent one event immediately (the add cancelling the unlink)
|
|
986
|
+
const customEvents = server.wsSent.filter(m => m.type === 'custom')
|
|
987
|
+
expect(customEvents.length).toBe(1)
|
|
988
|
+
expect(customEvents[0].data.name).toBe('saved-canvas')
|
|
989
|
+
expect(customEvents[0].data.removed).toBeUndefined()
|
|
990
|
+
expect(server.invalidatedModules).toContain(RESOLVED_ID)
|
|
991
|
+
|
|
992
|
+
// Wait past the unlink timer — should NOT get a second event
|
|
993
|
+
await new Promise(resolve => setTimeout(resolve, 1600))
|
|
994
|
+
const allCustom = server.wsSent.filter(m => m.type === 'custom')
|
|
995
|
+
expect(allCustom.length).toBe(1)
|
|
996
|
+
})
|
|
997
|
+
|
|
998
|
+
it('handleHotUpdate returns empty array for canvas files (suppresses full-reload)', () => {
|
|
999
|
+
const plugin = createPlugin()
|
|
1000
|
+
const result = plugin.handleHotUpdate({
|
|
1001
|
+
file: path.join(tmpDir, 'src', 'canvas', 'test.canvas.jsonl'),
|
|
1002
|
+
server: createMockServer(tmpDir),
|
|
1003
|
+
modules: [],
|
|
1004
|
+
})
|
|
1005
|
+
expect(result).toEqual([])
|
|
1006
|
+
})
|
|
1007
|
+
|
|
1008
|
+
it('handleHotUpdate does not send duplicate HMR events', () => {
|
|
1009
|
+
const plugin = createPlugin()
|
|
1010
|
+
const server = createMockServer(tmpDir)
|
|
1011
|
+
plugin.handleHotUpdate({
|
|
1012
|
+
file: path.join(tmpDir, 'src', 'canvas', 'test.canvas.jsonl'),
|
|
1013
|
+
server,
|
|
1014
|
+
modules: [],
|
|
1015
|
+
})
|
|
1016
|
+
// handleHotUpdate should NOT send events (invalidate() handles it)
|
|
1017
|
+
expect(server.wsSent.length).toBe(0)
|
|
1018
|
+
})
|
|
1019
|
+
|
|
1020
|
+
it('generated virtual module includes HMR listener for canvas updates', () => {
|
|
1021
|
+
writeCanvasFile(tmpDir, 'hmr-canvas', 'HMR Test')
|
|
1022
|
+
const plugin = createPlugin()
|
|
1023
|
+
const code = plugin.load(RESOLVED_ID)
|
|
1024
|
+
|
|
1025
|
+
expect(code).toContain('import.meta.hot')
|
|
1026
|
+
expect(code).toContain('storyboard:canvas-file-changed')
|
|
1027
|
+
expect(code).toContain('data.removed')
|
|
1028
|
+
expect(code).toContain('data.metadata')
|
|
1029
|
+
// Should merge into existing entries to preserve build-time fields
|
|
1030
|
+
expect(code).toContain('Object.assign')
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
it('page refresh after canvas add yields updated module with new canvas', () => {
|
|
1034
|
+
const plugin = createPlugin()
|
|
1035
|
+
// First load — no canvases
|
|
1036
|
+
const code1 = plugin.load(RESOLVED_ID)
|
|
1037
|
+
expect(code1).not.toContain('"refresh-canvas"')
|
|
1038
|
+
|
|
1039
|
+
// Simulate adding a canvas and clearing buildResult (what softInvalidate does)
|
|
1040
|
+
writeCanvasFile(tmpDir, 'refresh-canvas', 'After Refresh')
|
|
1041
|
+
|
|
1042
|
+
// Manually clear buildResult by loading a fresh plugin instance with the same root
|
|
1043
|
+
const plugin2 = createPlugin()
|
|
1044
|
+
const code2 = plugin2.load(RESOLVED_ID)
|
|
1045
|
+
expect(code2).toContain('"refresh-canvas"')
|
|
1046
|
+
expect(code2).toContain('After Refresh')
|
|
1047
|
+
})
|
|
1048
|
+
})
|