@dfosco/storyboard 0.6.12 → 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.
@@ -1,11 +1,14 @@
1
1
  import { fireEvent, render, screen, act } from '@testing-library/react'
2
2
  import CanvasPage from './CanvasPage.jsx'
3
- import { updateCanvas, removeWidget } from './canvasApi.js'
3
+ import { updateCanvas, removeWidget, batchOperations } from './canvasApi.js'
4
4
 
5
5
  const MOCK_UNDO_REDO = {
6
- snapshot: vi.fn(),
7
- undo: vi.fn(),
8
- redo: vi.fn(),
6
+ track: vi.fn(),
7
+ trackMany: vi.fn(),
8
+ popUndo: vi.fn(),
9
+ popRedo: vi.fn(),
10
+ pushRedo: vi.fn(),
11
+ pushUndo: vi.fn(),
9
12
  reset: vi.fn(),
10
13
  canUndo: false,
11
14
  canRedo: false,
@@ -117,8 +120,19 @@ vi.mock('./canvasApi.js', () => ({
117
120
  checkGitHubCliAvailable: vi.fn(() => Promise.resolve({ available: true })),
118
121
  fetchGitHubEmbed: vi.fn(() => Promise.resolve({ success: false })),
119
122
  updateCanvas: vi.fn(() => Promise.resolve({ success: true })),
120
- removeWidget: vi.fn(() => Promise.resolve({ success: true })),
123
+ patchWidget: vi.fn(() => Promise.resolve({ success: true, eventIds: ['evt_patch'] })),
124
+ removeWidget: vi.fn(() => Promise.resolve({ success: true, eventId: 'evt_remove' })),
125
+ undoEvent: vi.fn(() => Promise.resolve({ status: 200, ok: true, data: { success: true, eventId: 'evt_undo' } })),
126
+ redoEvent: vi.fn(() => Promise.resolve({ status: 200, ok: true, data: { success: true, eventId: 'evt_redo' } })),
121
127
  uploadImage: vi.fn(),
128
+ batchOperations: vi.fn(() => Promise.resolve({
129
+ success: true,
130
+ results: [
131
+ { op: 'move-widget', widgetId: 'w1', eventId: 'evt_move_w1' },
132
+ { op: 'move-widget', widgetId: 'w2', eventId: 'evt_move_w2' },
133
+ { op: 'move-widget', widgetId: 'w3', eventId: 'evt_move_w3' },
134
+ ],
135
+ })),
122
136
  }))
123
137
 
124
138
  describe('CanvasPage multi-select', () => {
@@ -231,8 +245,8 @@ describe('CanvasPage multi-select', () => {
231
245
  expect(removeWidget).toHaveBeenCalledTimes(2)
232
246
  // updateCanvas (widgets_replaced) is NOT used for multi-delete
233
247
  expect(updateCanvas).not.toHaveBeenCalled()
234
- // Should snapshot for undo
235
- expect(MOCK_UNDO_REDO.snapshot).toHaveBeenCalled()
248
+ // Each remove call's eventId is tracked for undo
249
+ expect(MOCK_UNDO_REDO.track).toHaveBeenCalledWith('evt_remove')
236
250
  })
237
251
 
238
252
  it('single-select Delete uses removeWidget API', async () => {
@@ -262,18 +276,17 @@ describe('CanvasPage multi-select', () => {
262
276
  })
263
277
  await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
264
278
 
265
- // w1 → (150, 200), w2 → (300+50, 100+100) = (350, 200)
266
- expect(updateCanvas).toHaveBeenCalledWith(
279
+ // w1 → (150, 200), w2 → (300+50, 100+100) = (350, 200).
280
+ // Multi-move is now persisted as a batch of widget_moved ops instead of
281
+ // a widgets_replaced bomb — fixes the rollback class of bugs.
282
+ expect(batchOperations).toHaveBeenCalledWith(
267
283
  'test-canvas',
268
- expect.objectContaining({
269
- widgets: expect.arrayContaining([
270
- expect.objectContaining({ id: 'w1', position: { x: 150, y: 200 } }),
271
- expect.objectContaining({ id: 'w2', position: { x: 350, y: 200 } }),
272
- // w3 unchanged
273
- expect.objectContaining({ id: 'w3', position: { x: 500, y: 200 } }),
274
- ]),
275
- })
284
+ expect.arrayContaining([
285
+ expect.objectContaining({ op: 'move-widget', widgetId: 'w1', position: { x: 150, y: 200 } }),
286
+ expect.objectContaining({ op: 'move-widget', widgetId: 'w2', position: { x: 350, y: 200 } }),
287
+ ])
276
288
  )
289
+ expect(updateCanvas).not.toHaveBeenCalled()
277
290
  })
278
291
 
279
292
  it('multi-select drag captures peer articles on drag start', async () => {
@@ -342,16 +355,16 @@ describe('CanvasPage multi-select', () => {
342
355
  })
343
356
  await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
344
357
 
345
- // All selected widgets should move by the same delta (+50, +50)
346
- expect(updateCanvas).toHaveBeenCalledWith(
358
+ // All selected widgets should move by the same delta (+50, +50) via the
359
+ // batch endpoint.
360
+ expect(batchOperations).toHaveBeenCalledWith(
347
361
  'test-canvas',
348
- expect.objectContaining({
349
- widgets: expect.arrayContaining([
350
- expect.objectContaining({ id: 'w1', position: { x: 150, y: 150 } }),
351
- expect.objectContaining({ id: 'w2', position: { x: 350, y: 150 } }),
352
- expect.objectContaining({ id: 'w3', position: { x: 550, y: 250 } }),
353
- ]),
354
- })
362
+ expect.arrayContaining([
363
+ expect.objectContaining({ op: 'move-widget', widgetId: 'w1', position: { x: 150, y: 150 } }),
364
+ expect.objectContaining({ op: 'move-widget', widgetId: 'w2', position: { x: 350, y: 150 } }),
365
+ expect.objectContaining({ op: 'move-widget', widgetId: 'w3', position: { x: 550, y: 250 } }),
366
+ ])
355
367
  )
368
+ expect(updateCanvas).not.toHaveBeenCalled()
356
369
  })
357
370
  })
@@ -20,6 +20,23 @@ async function request(path, method, body) {
20
20
  return res.json()
21
21
  }
22
22
 
23
+ /**
24
+ * Like `request` but also returns the HTTP status code so callers can
25
+ * distinguish between "endpoint succeeded but logical no-op" and "the
26
+ * target resource is gone" (which undo/redo handle differently after
27
+ * compaction wipes event ids).
28
+ */
29
+ async function requestWithStatus(path, method, body) {
30
+ const url = getApiBase() + path
31
+ const res = await fetch(url, {
32
+ method,
33
+ headers: body ? { 'Content-Type': 'application/json' } : undefined,
34
+ body: body ? JSON.stringify(body) : undefined,
35
+ })
36
+ const data = await res.json().catch(() => ({}))
37
+ return { status: res.status, ok: res.ok, data }
38
+ }
39
+
23
40
  export function listCanvases() {
24
41
  return request('/list', 'GET')
25
42
  }
@@ -32,6 +49,18 @@ export function updateCanvas(canvasId, { widgets, sources, settings, connectors
32
49
  return request('/update', 'PUT', { name: canvasId, widgets, sources, settings, connectors })
33
50
  }
34
51
 
52
+ export function patchWidget(canvasId, widgetId, { props, position } = {}) {
53
+ return request('/widget', 'PATCH', { name: canvasId, widgetId, props, position })
54
+ }
55
+
56
+ export function undoEvent(canvasId, eventId) {
57
+ return requestWithStatus('/undo', 'POST', { name: canvasId, eventId })
58
+ }
59
+
60
+ export function redoEvent(canvasId, eventId) {
61
+ return requestWithStatus('/redo', 'POST', { name: canvasId, eventId })
62
+ }
63
+
35
64
  export function addWidget(canvasId, { type, props, position }) {
36
65
  return request('/widget', 'POST', { name: canvasId, type, props, position })
37
66
  }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Library-side prototype routes builder for the isolated prototypes iframe.
3
+ *
4
+ * Mirrors the consumer-scaffold convention (src/routes.jsx) but lives inside
5
+ * the library so consumers no longer need to maintain their own
6
+ * prototypes-entry plumbing. Uses absolute globs (`/src/prototypes/...`),
7
+ * which Vite resolves against the *consumer's* project root regardless of
8
+ * where this file physically lives.
9
+ *
10
+ * Used by `prototypesEntry.jsx`, which is the iframe entry served by the
11
+ * `prototypes.html` middleware in `data-plugin.js`.
12
+ */
13
+ import { Fragment, Suspense } from 'react'
14
+ import { Outlet, useLocation } from 'react-router-dom'
15
+ import {
16
+ generatePreservedRoutes,
17
+ generateRegularRoutes,
18
+ generateModalRoutes,
19
+ patterns,
20
+ } from '@generouted/react-router/core'
21
+ import PrototypeErrorBoundary, { ImportErrorFallback } from '../PrototypeErrorBoundary.jsx'
22
+
23
+ // Strip src/prototypes/, .folder/, and `drafts/` segments from route paths.
24
+ // A `drafts/` dir is a gitignored scratch root — prototypes inside route at
25
+ // the same URL as a non-prefixed sibling. The lookbehind ensures we only
26
+ // match `drafts/` as a directory segment, not as part of a filename.
27
+ patterns.route = [/^.*\/src\/prototypes\/|^\/prototypes\/|[^/]*\.folder\/|(?<=^|\/)drafts\/|\.(jsx|tsx|mdx)$/g, '']
28
+
29
+ const PRESERVED = import.meta.glob('/src/prototypes/(_app|404).{jsx,tsx}', { eager: true })
30
+ const MODALS = import.meta.glob('/src/prototypes/**/[+]*.{jsx,tsx}', { eager: true })
31
+
32
+ // Lazy-load prototype routes — only the matched route's module is fetched.
33
+ // This prevents story/canvas URLs from paying for every prototype page's
34
+ // imports (Primer, Reshaped, user components, etc.) on first load.
35
+ const ROUTES = import.meta.glob(
36
+ ['/src/prototypes/**/[\\w[-]*.{jsx,tsx,mdx}', '!/src/prototypes/**/(_!(layout)*(/*)?|_app|404)*'],
37
+ )
38
+
39
+ const preservedRoutes = generatePreservedRoutes(PRESERVED)
40
+ const modalRoutes = generateModalRoutes(MODALS)
41
+
42
+ const regularRoutes = generateRegularRoutes(ROUTES, (importFn, key) => {
43
+ const index = /index\.(jsx|tsx|mdx)$/.test(key) && !key.includes('prototypes/index') ? { index: true } : {}
44
+ return {
45
+ ...index,
46
+ lazy: async () => {
47
+ try {
48
+ const module = await importFn()
49
+ const Default = module?.default || Fragment
50
+ const Page = () =>
51
+ module?.Pending ? (
52
+ <Suspense fallback={<module.Pending />}>
53
+ <Default />
54
+ </Suspense>
55
+ ) : (
56
+ <Default />
57
+ )
58
+ return {
59
+ Component: Page,
60
+ ErrorBoundary: module?.Catch || PrototypeErrorBoundary,
61
+ loader: module?.Loader,
62
+ action: module?.Action,
63
+ }
64
+ } catch (err) {
65
+ return {
66
+ Component: () => <ImportErrorFallback error={err} route={key} />,
67
+ }
68
+ }
69
+ },
70
+ }
71
+ })
72
+
73
+ const _app = preservedRoutes?.['_app']
74
+ const _404 = preservedRoutes?.['404']
75
+ const Default = _app?.default || Outlet
76
+
77
+ // eslint-disable-next-line react-refresh/only-export-components
78
+ const Modals_ = () => {
79
+ const Modal = modalRoutes[useLocation().state?.modal] || Fragment
80
+ return <Modal />
81
+ }
82
+
83
+ // eslint-disable-next-line react-refresh/only-export-components
84
+ const Layout = () => (
85
+ <>
86
+ <Default /> <Modals_ />
87
+ </>
88
+ )
89
+
90
+ // eslint-disable-next-line react-refresh/only-export-components
91
+ const App = () =>
92
+ _app?.Pending ? (
93
+ <Suspense fallback={<_app.Pending />}>
94
+ <Layout />
95
+ </Suspense>
96
+ ) : (
97
+ <Layout />
98
+ )
99
+
100
+ const app = { Component: _app?.default ? App : Layout, ErrorBoundary: _app?.Catch, loader: _app?.Loader }
101
+ const fallback = { path: '*', Component: _404?.default || Fragment }
102
+
103
+ export const routes = [{ ...app, children: [...regularRoutes, fallback] }]
104
+
105
+ /**
106
+ * Return a routes tree filtered to a single prototype subtree. Used by the
107
+ * per-prototype iframe entry — filtering at this layer keeps a broken
108
+ * sibling prototype from ever appearing in the matched route's lazy() chain
109
+ * (only the requested prototype's modules are reachable from the router).
110
+ */
111
+ export function getRoutesForProto(proto) {
112
+ if (!proto) return routes
113
+ const matchesProto = (route) => {
114
+ const seg = (route.path || '').split('/').filter(Boolean)[0]
115
+ return seg === proto
116
+ }
117
+ const filter = (children) => children
118
+ .map((r) => {
119
+ if (r.path === '*') return r
120
+ if (!r.path) {
121
+ if (r.children) return { ...r, children: filter(r.children) }
122
+ return r
123
+ }
124
+ if (matchesProto(r)) {
125
+ return r.children ? { ...r, children: filter(r.children) } : r
126
+ }
127
+ return null
128
+ })
129
+ .filter(Boolean)
130
+ return [{ ...routes[0], children: filter(routes[0].children || []) }]
131
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Library-provided entry for the isolated prototypes iframe.
3
+ *
4
+ * Loaded by the `prototypes.html` middleware in `data-plugin.js` — replaces
5
+ * the consumer-side `src/prototypes-entry.jsx` so consumers don't need to
6
+ * maintain the iframe entry themselves.
7
+ *
8
+ * Uses `createHashRouter` so prototype routes live in the URL hash
9
+ * (`prototypes.html#/MyProto/SignupForm`). Filters routes by `?proto=` so a
10
+ * broken sibling prototype never appears in the matched route's lazy()
11
+ * chain (module-graph isolation — see .agents/plans/vite-isolation.md).
12
+ *
13
+ * CSS chain: relies on `mountStoryboardCore` to inject the compiled
14
+ * ui-runtime stylesheet (matches index.jsx's mount) — this restores stock
15
+ * Tailwind utility colors that the Primer adapter would otherwise invert in
16
+ * dark mode. Also opportunistically pulls in consumer-side global stylesheets
17
+ * (`/src/{reset,fonts,globals,tailwind}.css`) when present, mirroring what
18
+ * the scaffolded `src/index.jsx` imports.
19
+ */
20
+ import { StrictMode } from 'react'
21
+ import { createRoot } from 'react-dom/client'
22
+ import { RouterProvider, createHashRouter } from 'react-router-dom'
23
+ import { ThemeProvider, BaseStyles } from '@primer/react'
24
+ import { routes, getRoutesForProto } from './prototypeRoutes.jsx'
25
+ import ThemeSync from '../../primer/ThemeSync.jsx'
26
+ import { mountStoryboardCore } from '../../core/index.js'
27
+ import '../../core/comments/ui/comment-layout.css'
28
+
29
+ // Pull in consumer global stylesheets when they exist. import.meta.glob is
30
+ // resolved against the *consumer's* project root and silently skips missing
31
+ // files, so this is safe in projects that don't ship the scaffold defaults.
32
+ import.meta.glob(
33
+ ['/src/reset.css', '/src/fonts.css', '/src/globals.css', '/src/tailwind.css'],
34
+ { eager: true },
35
+ )
36
+
37
+ const configModules = import.meta.glob('/storyboard.config.json', { eager: true })
38
+ const storyboardConfig = Object.values(configModules)[0]?.default || {}
39
+
40
+ // Mount storyboard core. In embed mode (`_sb_embed` in the URL — always true
41
+ // for canvas iframes) this short-circuits past UI mounting and only:
42
+ // - injects the compiled ui-runtime stylesheet (matches index.jsx and
43
+ // prevents the Tailwind→Primer adapter from inverting fixed dark colors
44
+ // like bg-neutral-900 when the parent canvas is in dark mode)
45
+ // - applies the canvas-aware theme (_sb_theme_target, _sb_canvas_theme)
46
+ // - sets up the iframe→parent postMessage navigation/wheel bridge
47
+ mountStoryboardCore(storyboardConfig, { basePath: import.meta.env.BASE_URL })
48
+
49
+ const protoParam = new URLSearchParams(window.location.search).get('proto')
50
+ const activeRoutes = protoParam ? getRoutesForProto(protoParam) : routes
51
+
52
+ const router = createHashRouter(activeRoutes)
53
+
54
+ const rootElement = document.getElementById('root')
55
+ const root = createRoot(rootElement)
56
+
57
+ root.render(
58
+ <StrictMode>
59
+ <ThemeProvider colorMode="auto">
60
+ <BaseStyles>
61
+ <ThemeSync />
62
+ <RouterProvider router={router} />
63
+ </BaseStyles>
64
+ </ThemeProvider>
65
+ </StrictMode>,
66
+ )
@@ -1,86 +1,101 @@
1
1
  import { useRef, useState, useCallback } from 'react'
2
2
 
3
3
  const MAX_HISTORY = 100
4
- const COALESCE_MS = 2000
5
4
 
6
5
  /**
7
- * Snapshot-based undo/redo history for canvas widgets.
6
+ * Per-tab undo/redo history for canvas events.
8
7
  *
9
- * Tracks past/future stacks of widget array clones. The present state
10
- * is always the live `localWidgets` this hook only manages history.
8
+ * Maintains two stacks of event IDs:
9
+ * undoStack ids of user-initiated forward events, newest last.
10
+ * redoStack — ids of undo events (the inverse events appended by the
11
+ * server's POST /undo), newest last.
11
12
  *
12
- * Edit coalescing: continuous edits to the same widget within COALESCE_MS
13
- * are merged into one undo step (like Figma).
13
+ * Workflow:
14
+ * Every user-initiated mutation that succeeds calls `track(eventId)`
15
+ * pushes onto undoStack and clears redoStack.
16
+ * • `popUndo()` returns the next id to undo. Caller POSTs /undo and on
17
+ * success calls `pushRedo(inverseId)`.
18
+ * • `popRedo()` returns the next id to redo. Caller POSTs /redo and on
19
+ * success calls `pushUndo(inverseId)` (so the redo is itself undoable).
20
+ *
21
+ * This hook holds *no* widget data — undo state lives in the JSONL. The
22
+ * stacks are per-tab and ephemeral; refreshing the page resets them.
14
23
  */
15
24
  export default function useUndoRedo() {
16
- const pastRef = useRef([])
17
- const futureRef = useRef([])
18
- const lastActionRef = useRef({ type: null, widgetId: null, time: 0 })
19
- // State counter drives canUndo/canRedo re-renders without cloning the stacks
20
- const [counts, setCounts] = useState({ past: 0, future: 0 })
25
+ const undoStackRef = useRef([])
26
+ const redoStackRef = useRef([])
27
+ const [counts, setCounts] = useState({ undo: 0, redo: 0 })
28
+
29
+ const syncCounts = useCallback(() => {
30
+ setCounts({ undo: undoStackRef.current.length, redo: redoStackRef.current.length })
31
+ }, [])
21
32
 
22
- const snapshot = useCallback((currentWidgets, actionType, widgetId) => {
23
- const widgets = currentWidgets ?? []
33
+ const track = useCallback((eventId) => {
34
+ if (!eventId || typeof eventId !== 'string') return
35
+ undoStackRef.current.push(eventId)
36
+ if (undoStackRef.current.length > MAX_HISTORY) undoStackRef.current.shift()
37
+ // New mutation invalidates the redo chain
38
+ redoStackRef.current = []
39
+ syncCounts()
40
+ }, [syncCounts])
24
41
 
25
- // Edit coalescing skip snapshot if same edit target within timeout
26
- if (actionType === 'edit' && widgetId) {
27
- const last = lastActionRef.current
28
- const now = Date.now()
29
- if (
30
- last.type === 'edit' &&
31
- last.widgetId === widgetId &&
32
- now - last.time < COALESCE_MS
33
- ) {
34
- lastActionRef.current = { type: 'edit', widgetId, time: now }
35
- return
42
+ const trackMany = useCallback((eventIds) => {
43
+ if (!Array.isArray(eventIds)) return
44
+ for (const id of eventIds) {
45
+ if (id && typeof id === 'string') {
46
+ undoStackRef.current.push(id)
47
+ if (undoStackRef.current.length > MAX_HISTORY) undoStackRef.current.shift()
36
48
  }
37
49
  }
50
+ redoStackRef.current = []
51
+ syncCounts()
52
+ }, [syncCounts])
38
53
 
39
- pastRef.current.push(structuredClone(widgets))
40
- if (pastRef.current.length > MAX_HISTORY) pastRef.current.shift()
41
- futureRef.current = []
42
- lastActionRef.current = {
43
- type: actionType,
44
- widgetId: widgetId || null,
45
- time: Date.now(),
46
- }
47
- setCounts({ past: pastRef.current.length, future: 0 })
48
- }, [])
54
+ const popUndo = useCallback(() => {
55
+ const id = undoStackRef.current.pop()
56
+ syncCounts()
57
+ return id ?? null
58
+ }, [syncCounts])
49
59
 
50
- const undo = useCallback((currentWidgets) => {
51
- if (pastRef.current.length === 0) return null
52
- futureRef.current.push(structuredClone(currentWidgets))
53
- const previous = pastRef.current.pop()
54
- lastActionRef.current = { type: 'undo', widgetId: null, time: Date.now() }
55
- setCounts({ past: pastRef.current.length, future: futureRef.current.length })
56
- return previous
57
- }, [])
60
+ const popRedo = useCallback(() => {
61
+ const id = redoStackRef.current.pop()
62
+ syncCounts()
63
+ return id ?? null
64
+ }, [syncCounts])
58
65
 
59
- const redo = useCallback((currentWidgets) => {
60
- if (futureRef.current.length === 0) return null
61
- pastRef.current.push(structuredClone(currentWidgets))
62
- const next = futureRef.current.pop()
63
- lastActionRef.current = { type: 'redo', widgetId: null, time: Date.now() }
64
- setCounts({ past: pastRef.current.length, future: futureRef.current.length })
65
- return next
66
- }, [])
66
+ const pushRedo = useCallback((eventId) => {
67
+ if (!eventId || typeof eventId !== 'string') return
68
+ redoStackRef.current.push(eventId)
69
+ if (redoStackRef.current.length > MAX_HISTORY) redoStackRef.current.shift()
70
+ syncCounts()
71
+ }, [syncCounts])
72
+
73
+ const pushUndo = useCallback((eventId) => {
74
+ if (!eventId || typeof eventId !== 'string') return
75
+ undoStackRef.current.push(eventId)
76
+ if (undoStackRef.current.length > MAX_HISTORY) undoStackRef.current.shift()
77
+ syncCounts()
78
+ }, [syncCounts])
67
79
 
68
80
  const reset = useCallback(() => {
69
- pastRef.current = []
70
- futureRef.current = []
71
- lastActionRef.current = { type: null, widgetId: null, time: 0 }
81
+ undoStackRef.current = []
82
+ redoStackRef.current = []
72
83
  setCounts((prev) => {
73
- if (prev.past === 0 && prev.future === 0) return prev
74
- return { past: 0, future: 0 }
84
+ if (prev.undo === 0 && prev.redo === 0) return prev
85
+ return { undo: 0, redo: 0 }
75
86
  })
76
87
  }, [])
77
88
 
78
89
  return {
79
- snapshot,
80
- undo,
81
- redo,
90
+ track,
91
+ trackMany,
92
+ popUndo,
93
+ popRedo,
94
+ pushRedo,
95
+ pushUndo,
82
96
  reset,
83
- canUndo: counts.past > 0,
84
- canRedo: counts.future > 0,
97
+ canUndo: counts.undo > 0,
98
+ canRedo: counts.redo > 0,
85
99
  }
86
100
  }
101
+