@dfosco/storyboard 0.6.11 → 0.6.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/dist/storyboard-ui.css +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +6 -2
- package/scaffold/AGENTS.md +2 -0
- package/scaffold/README.md +18 -0
- package/scaffold/gitignore +7 -14
- package/scaffold/{manifest.json → scaffold.config.json} +7 -2
- package/src/core/canvas/materializer.js +39 -13
- package/src/core/canvas/server.js +245 -46
- package/src/core/canvas/server.test.js +226 -0
- package/src/core/canvas/undoRedo.js +174 -0
- package/src/core/canvas/undoRedo.test.js +250 -0
- package/src/core/cli/canvasRedo.js +44 -0
- package/src/core/cli/canvasUndo.js +40 -0
- package/src/core/cli/dev.js +5 -10
- package/src/core/cli/index.js +6 -0
- package/src/core/cli/setup.js +14 -26
- package/src/core/scaffold/fragments.js +287 -0
- package/src/core/scaffold/fragments.test.js +401 -0
- package/src/core/scaffold/index.js +291 -0
- package/src/core/scaffold/migrateLegacyGitignore.js +157 -0
- package/src/core/scaffold/scaffold-integration.test.js +244 -0
- package/src/core/scaffold.js +4 -97
- package/src/internals/canvas/CanvasPage.jsx +247 -124
- package/src/internals/canvas/CanvasPage.multiselect.test.jsx +39 -26
- package/src/internals/canvas/canvasApi.js +29 -0
- package/src/internals/canvas/prototypeRoutes.jsx +131 -0
- package/src/internals/canvas/prototypesEntry.jsx +66 -0
- package/src/internals/canvas/useUndoRedo.js +75 -60
- package/src/internals/canvas/useUndoRedo.test.js +46 -178
- package/src/internals/vite/data-plugin.js +72 -0
|
@@ -1,231 +1,99 @@
|
|
|
1
1
|
import { renderHook, act } from '@testing-library/react'
|
|
2
|
+
import { describe, it, expect } from 'vitest'
|
|
2
3
|
import useUndoRedo from './useUndoRedo.js'
|
|
3
4
|
|
|
4
|
-
describe('useUndoRedo', () => {
|
|
5
|
+
describe('useUndoRedo (canvas)', () => {
|
|
5
6
|
it('starts with canUndo and canRedo as false', () => {
|
|
6
7
|
const { result } = renderHook(() => useUndoRedo())
|
|
7
8
|
expect(result.current.canUndo).toBe(false)
|
|
8
9
|
expect(result.current.canRedo).toBe(false)
|
|
9
10
|
})
|
|
10
11
|
|
|
11
|
-
it('
|
|
12
|
+
it('track pushes an event id and enables undo', () => {
|
|
12
13
|
const { result } = renderHook(() => useUndoRedo())
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
act(() => result.current.snapshot(widgets, 'add'))
|
|
16
|
-
|
|
14
|
+
act(() => result.current.track('evt_a'))
|
|
17
15
|
expect(result.current.canUndo).toBe(true)
|
|
18
16
|
expect(result.current.canRedo).toBe(false)
|
|
19
17
|
})
|
|
20
18
|
|
|
21
|
-
it('
|
|
19
|
+
it('popUndo returns the most recent tracked id; pushRedo enables redo', () => {
|
|
22
20
|
const { result } = renderHook(() => useUndoRedo())
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
act(() => result.current.track('evt_a'))
|
|
22
|
+
act(() => result.current.track('evt_b'))
|
|
25
23
|
|
|
26
|
-
|
|
24
|
+
let popped
|
|
25
|
+
act(() => { popped = result.current.popUndo() })
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
expect(popped).toBe('evt_b')
|
|
28
|
+
expect(result.current.canUndo).toBe(true)
|
|
29
|
+
expect(result.current.canRedo).toBe(false)
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
expect(result.current.canUndo).toBe(false)
|
|
31
|
+
act(() => result.current.pushRedo('evt_b_inv'))
|
|
33
32
|
expect(result.current.canRedo).toBe(true)
|
|
34
33
|
})
|
|
35
34
|
|
|
36
|
-
it('
|
|
35
|
+
it('popRedo returns the most recent redo id; pushUndo restores undo availability', () => {
|
|
37
36
|
const { result } = renderHook(() => useUndoRedo())
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
act(() => result.current.track('evt_a'))
|
|
38
|
+
act(() => { result.current.popUndo() })
|
|
39
|
+
act(() => result.current.pushRedo('evt_a_inv'))
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
act(() => { result.current.undo(after) })
|
|
41
|
+
expect(result.current.canRedo).toBe(true)
|
|
43
42
|
|
|
44
|
-
let
|
|
45
|
-
act(() => {
|
|
43
|
+
let popped
|
|
44
|
+
act(() => { popped = result.current.popRedo() })
|
|
45
|
+
expect(popped).toBe('evt_a_inv')
|
|
46
|
+
expect(result.current.canRedo).toBe(false)
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
act(() => result.current.pushUndo('evt_a_inv_inv'))
|
|
48
49
|
expect(result.current.canUndo).toBe(true)
|
|
49
|
-
expect(result.current.canRedo).toBe(false)
|
|
50
50
|
})
|
|
51
51
|
|
|
52
|
-
it('new
|
|
52
|
+
it('new track call after undo clears the redo stack', () => {
|
|
53
53
|
const { result } = renderHook(() => useUndoRedo())
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
act(() => result.current.snapshot(s0, 'add'))
|
|
59
|
-
act(() => result.current.undo(s1))
|
|
54
|
+
act(() => result.current.track('evt_a'))
|
|
55
|
+
act(() => { result.current.popUndo() })
|
|
56
|
+
act(() => result.current.pushRedo('evt_a_inv'))
|
|
60
57
|
expect(result.current.canRedo).toBe(true)
|
|
61
58
|
|
|
62
|
-
|
|
63
|
-
act(() => result.current.snapshot(s0, 'add'))
|
|
59
|
+
act(() => result.current.track('evt_b'))
|
|
64
60
|
expect(result.current.canRedo).toBe(false)
|
|
65
61
|
expect(result.current.canUndo).toBe(true)
|
|
66
62
|
})
|
|
67
63
|
|
|
68
|
-
it('
|
|
64
|
+
it('trackMany pushes multiple ids and clears redo', () => {
|
|
69
65
|
const { result } = renderHook(() => useUndoRedo())
|
|
70
|
-
|
|
71
|
-
const s1 = [{ id: '1' }, { id: '2' }]
|
|
72
|
-
const s2 = [{ id: '1' }, { id: '2' }, { id: '3' }]
|
|
73
|
-
const s3 = [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]
|
|
74
|
-
|
|
75
|
-
// Build history: s0 → s1 → s2 → s3
|
|
76
|
-
act(() => result.current.snapshot(s0, 'add'))
|
|
77
|
-
act(() => result.current.snapshot(s1, 'add'))
|
|
78
|
-
act(() => result.current.snapshot(s2, 'add'))
|
|
79
|
-
|
|
80
|
-
// present = s3, past = [s0, s1, s2]
|
|
81
|
-
// Undo to s2
|
|
82
|
-
let r
|
|
83
|
-
act(() => { r = result.current.undo(s3) })
|
|
84
|
-
expect(r).toEqual(s2)
|
|
85
|
-
|
|
86
|
-
// Undo to s1
|
|
87
|
-
act(() => { r = result.current.undo(s2) })
|
|
88
|
-
expect(r).toEqual(s1)
|
|
89
|
-
|
|
90
|
-
// Redo to s2
|
|
91
|
-
act(() => { r = result.current.redo(s1) })
|
|
92
|
-
expect(r).toEqual(s2)
|
|
93
|
-
|
|
94
|
-
// Redo to s3
|
|
95
|
-
act(() => { r = result.current.redo(s2) })
|
|
96
|
-
expect(r).toEqual(s3)
|
|
97
|
-
|
|
98
|
-
// Undo to s2 again
|
|
99
|
-
act(() => { r = result.current.undo(s3) })
|
|
100
|
-
expect(r).toEqual(s2)
|
|
101
|
-
|
|
102
|
-
// Undo to s1
|
|
103
|
-
act(() => { r = result.current.undo(s2) })
|
|
104
|
-
expect(r).toEqual(s1)
|
|
105
|
-
|
|
106
|
-
// Undo to s0
|
|
107
|
-
act(() => { r = result.current.undo(s1) })
|
|
108
|
-
expect(r).toEqual(s0)
|
|
109
|
-
|
|
110
|
-
// Can't undo further
|
|
111
|
-
expect(result.current.canUndo).toBe(false)
|
|
112
|
-
act(() => { r = result.current.undo(s0) })
|
|
113
|
-
expect(r).toBeNull()
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
it('coalesces edits to the same widget within timeout', () => {
|
|
117
|
-
const { result } = renderHook(() => useUndoRedo())
|
|
118
|
-
const s0 = [{ id: '1', props: { text: '' } }]
|
|
119
|
-
|
|
120
|
-
// First edit — creates snapshot
|
|
121
|
-
act(() => result.current.snapshot(s0, 'edit', '1'))
|
|
66
|
+
act(() => result.current.trackMany(['evt_a', 'evt_b', 'evt_c']))
|
|
122
67
|
expect(result.current.canUndo).toBe(true)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
// Still only one entry in past
|
|
127
|
-
let r
|
|
128
|
-
act(() => { r = result.current.undo([{ id: '1', props: { text: 'abc' } }]) })
|
|
129
|
-
expect(r).toEqual(s0)
|
|
130
|
-
// No more undo after that
|
|
131
|
-
expect(result.current.canUndo).toBe(false)
|
|
68
|
+
let popped
|
|
69
|
+
act(() => { popped = result.current.popUndo() })
|
|
70
|
+
expect(popped).toBe('evt_c')
|
|
132
71
|
})
|
|
133
72
|
|
|
134
|
-
it('
|
|
73
|
+
it('ignores empty / non-string ids', () => {
|
|
135
74
|
const { result } = renderHook(() => useUndoRedo())
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
act(() => result.current.
|
|
140
|
-
act(() => result.current.snapshot(s1, 'edit', '2'))
|
|
141
|
-
|
|
142
|
-
// Two entries in past
|
|
143
|
-
expect(result.current.canUndo).toBe(true)
|
|
144
|
-
act(() => { result.current.undo([]) })
|
|
145
|
-
expect(result.current.canUndo).toBe(true)
|
|
146
|
-
act(() => { result.current.undo([]) })
|
|
75
|
+
act(() => result.current.track(null))
|
|
76
|
+
act(() => result.current.track(undefined))
|
|
77
|
+
act(() => result.current.track(''))
|
|
78
|
+
act(() => result.current.track(42))
|
|
147
79
|
expect(result.current.canUndo).toBe(false)
|
|
148
80
|
})
|
|
149
81
|
|
|
150
|
-
it('
|
|
82
|
+
it('reset clears both stacks', () => {
|
|
151
83
|
const { result } = renderHook(() => useUndoRedo())
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
act(() => result.current.snapshot(s0, 'edit', '1'))
|
|
156
|
-
act(() => result.current.snapshot(s1, 'add'))
|
|
157
|
-
act(() => result.current.snapshot(s1, 'edit', '1'))
|
|
158
|
-
|
|
159
|
-
// Three entries in past (edit + add + edit — not coalesced because add broke the sequence)
|
|
160
|
-
act(() => { result.current.undo([]) })
|
|
161
|
-
act(() => { result.current.undo([]) })
|
|
162
|
-
act(() => { result.current.undo([]) })
|
|
163
|
-
expect(result.current.canUndo).toBe(false)
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
it('reset clears all history', () => {
|
|
167
|
-
const { result } = renderHook(() => useUndoRedo())
|
|
168
|
-
act(() => result.current.snapshot([{ id: '1' }], 'add'))
|
|
169
|
-
act(() => result.current.snapshot([{ id: '1' }, { id: '2' }], 'add'))
|
|
170
|
-
expect(result.current.canUndo).toBe(true)
|
|
84
|
+
act(() => result.current.track('evt_a'))
|
|
85
|
+
act(() => { result.current.popUndo() })
|
|
86
|
+
act(() => result.current.pushRedo('evt_a_inv'))
|
|
171
87
|
|
|
172
88
|
act(() => result.current.reset())
|
|
173
89
|
expect(result.current.canUndo).toBe(false)
|
|
174
90
|
expect(result.current.canRedo).toBe(false)
|
|
175
91
|
})
|
|
176
92
|
|
|
177
|
-
it('
|
|
178
|
-
const { result } = renderHook(() => useUndoRedo())
|
|
179
|
-
let r
|
|
180
|
-
act(() => { r = result.current.undo([]) })
|
|
181
|
-
expect(r).toBeNull()
|
|
182
|
-
})
|
|
183
|
-
|
|
184
|
-
it('redo returns null when future is empty', () => {
|
|
185
|
-
const { result } = renderHook(() => useUndoRedo())
|
|
186
|
-
let r
|
|
187
|
-
act(() => { r = result.current.redo([]) })
|
|
188
|
-
expect(r).toBeNull()
|
|
189
|
-
})
|
|
190
|
-
|
|
191
|
-
it('snapshots are deep clones (mutations do not leak)', () => {
|
|
192
|
-
const { result } = renderHook(() => useUndoRedo())
|
|
193
|
-
const widgets = [{ id: '1', props: { text: 'original' } }]
|
|
194
|
-
|
|
195
|
-
act(() => result.current.snapshot(widgets, 'add'))
|
|
196
|
-
|
|
197
|
-
// Mutate the original array
|
|
198
|
-
widgets[0].props.text = 'mutated'
|
|
199
|
-
|
|
200
|
-
let restored
|
|
201
|
-
act(() => { restored = result.current.undo(widgets) })
|
|
202
|
-
expect(restored[0].props.text).toBe('original')
|
|
203
|
-
})
|
|
204
|
-
|
|
205
|
-
it('caps history at 100 entries', () => {
|
|
93
|
+
it('popUndo on empty stack returns null', () => {
|
|
206
94
|
const { result } = renderHook(() => useUndoRedo())
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Should be capped at 100 — undo 100 times, then can't undo further
|
|
213
|
-
let count = 0
|
|
214
|
-
let r = true
|
|
215
|
-
while (r !== null) {
|
|
216
|
-
act(() => { r = result.current.undo([]) })
|
|
217
|
-
if (r !== null) count++
|
|
218
|
-
}
|
|
219
|
-
expect(count).toBe(100)
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
it('snapshots null widgets as empty array (first widget on new canvas)', () => {
|
|
223
|
-
const { result } = renderHook(() => useUndoRedo())
|
|
224
|
-
act(() => result.current.snapshot(null, 'add'))
|
|
225
|
-
expect(result.current.canUndo).toBe(true)
|
|
226
|
-
|
|
227
|
-
let restored
|
|
228
|
-
act(() => { restored = result.current.undo([{ id: '1' }]) })
|
|
229
|
-
expect(restored).toEqual([])
|
|
95
|
+
let popped
|
|
96
|
+
act(() => { popped = result.current.popUndo() })
|
|
97
|
+
expect(popped).toBeNull()
|
|
230
98
|
})
|
|
231
99
|
})
|
|
@@ -1233,6 +1233,78 @@ export default function storyboardDataPlugin() {
|
|
|
1233
1233
|
},
|
|
1234
1234
|
|
|
1235
1235
|
configureServer(server) {
|
|
1236
|
+
// ── Prototypes isolation middleware ────────────────────────────
|
|
1237
|
+
// Serves the prototype-iframe HTML shell so consumers don't need to
|
|
1238
|
+
// ship their own `prototypes.html` + `prototypes-entry.jsx`. Loads
|
|
1239
|
+
// the library-side `prototypesEntry.jsx`, which uses absolute globs
|
|
1240
|
+
// (`/src/prototypes/...`) that Vite resolves against the *consumer's*
|
|
1241
|
+
// project root. See .agents/plans/vite-isolation.md.
|
|
1242
|
+
const prototypesEntryPath = new URL('../canvas/prototypesEntry.jsx', import.meta.url).pathname
|
|
1243
|
+
server.middlewares.use(async (req, res, next) => {
|
|
1244
|
+
if (!req.url) return next()
|
|
1245
|
+
let url = req.url
|
|
1246
|
+
const baseNoTrail = (server.config.base || '/').replace(/\/$/, '')
|
|
1247
|
+
if (baseNoTrail && url.startsWith(baseNoTrail)) {
|
|
1248
|
+
url = url.slice(baseNoTrail.length) || '/'
|
|
1249
|
+
}
|
|
1250
|
+
const cleanUrl = url.split('?')[0].split('#')[0]
|
|
1251
|
+
if (cleanUrl !== '/prototypes.html') return next()
|
|
1252
|
+
|
|
1253
|
+
// Theme bootstrap mirrors index.html so the iframe paints with the
|
|
1254
|
+
// correct color mode on first frame (before mountStoryboardCore
|
|
1255
|
+
// wires up theme sync).
|
|
1256
|
+
const themeBootstrap = [
|
|
1257
|
+
'<script>',
|
|
1258
|
+
'(function() {',
|
|
1259
|
+
' var stored = localStorage.getItem("sb-color-scheme");',
|
|
1260
|
+
' var theme = stored || "system";',
|
|
1261
|
+
' var resolved = theme;',
|
|
1262
|
+
' if (theme === "system") {',
|
|
1263
|
+
' resolved = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";',
|
|
1264
|
+
' }',
|
|
1265
|
+
' var el = document.documentElement;',
|
|
1266
|
+
' el.setAttribute("data-sb-theme", resolved);',
|
|
1267
|
+
' if (theme === "system") {',
|
|
1268
|
+
' el.setAttribute("data-color-mode", "auto");',
|
|
1269
|
+
' el.setAttribute("data-light-theme", "light");',
|
|
1270
|
+
' el.setAttribute("data-dark-theme", "dark");',
|
|
1271
|
+
' } else if (resolved.indexOf("dark") === 0) {',
|
|
1272
|
+
' el.setAttribute("data-color-mode", "dark");',
|
|
1273
|
+
' el.setAttribute("data-dark-theme", resolved);',
|
|
1274
|
+
' el.setAttribute("data-light-theme", "light");',
|
|
1275
|
+
' } else {',
|
|
1276
|
+
' el.setAttribute("data-color-mode", "light");',
|
|
1277
|
+
' el.setAttribute("data-light-theme", resolved);',
|
|
1278
|
+
' el.setAttribute("data-dark-theme", "dark");',
|
|
1279
|
+
' }',
|
|
1280
|
+
'})();',
|
|
1281
|
+
'</script>',
|
|
1282
|
+
].join('\n')
|
|
1283
|
+
|
|
1284
|
+
const rawHtml = [
|
|
1285
|
+
'<!DOCTYPE html>',
|
|
1286
|
+
'<html lang="en"><head>',
|
|
1287
|
+
'<meta charset="UTF-8" />',
|
|
1288
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />',
|
|
1289
|
+
'<title>Storyboard · Prototypes</title>',
|
|
1290
|
+
themeBootstrap,
|
|
1291
|
+
'</head><body>',
|
|
1292
|
+
'<div id="root"></div>',
|
|
1293
|
+
`<script type="module" src="/@fs${prototypesEntryPath}"></script>`,
|
|
1294
|
+
'</body></html>',
|
|
1295
|
+
].join('\n')
|
|
1296
|
+
|
|
1297
|
+
try {
|
|
1298
|
+
const html = await server.transformIndexHtml(req.url, rawHtml)
|
|
1299
|
+
res.writeHead(200, { 'Content-Type': 'text/html' })
|
|
1300
|
+
res.end(html)
|
|
1301
|
+
} catch (err) {
|
|
1302
|
+
console.error('[storyboard] prototypes.html transform failed:', err)
|
|
1303
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' })
|
|
1304
|
+
res.end('prototypes.html failed')
|
|
1305
|
+
}
|
|
1306
|
+
})
|
|
1307
|
+
|
|
1236
1308
|
// ── Component isolate middleware ───────────────────────────────
|
|
1237
1309
|
// Serves a minimal HTML shell for iframe-isolated component widgets.
|
|
1238
1310
|
// The iframe loads componentIsolate.jsx which reads query params
|