@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.
- package/dist/storyboard-ui.css +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +6 -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/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,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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
235
|
-
expect(MOCK_UNDO_REDO.
|
|
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
|
-
|
|
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.
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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.
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
*
|
|
6
|
+
* Per-tab undo/redo history for canvas events.
|
|
8
7
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
const
|
|
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
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
lastActionRef.current = { type: null, widgetId: null, time: 0 }
|
|
81
|
+
undoStackRef.current = []
|
|
82
|
+
redoStackRef.current = []
|
|
72
83
|
setCounts((prev) => {
|
|
73
|
-
if (prev.
|
|
74
|
-
return {
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
90
|
+
track,
|
|
91
|
+
trackMany,
|
|
92
|
+
popUndo,
|
|
93
|
+
popRedo,
|
|
94
|
+
pushRedo,
|
|
95
|
+
pushUndo,
|
|
82
96
|
reset,
|
|
83
|
-
canUndo: counts.
|
|
84
|
-
canRedo: counts.
|
|
97
|
+
canUndo: counts.undo > 0,
|
|
98
|
+
canRedo: counts.redo > 0,
|
|
85
99
|
}
|
|
86
100
|
}
|
|
101
|
+
|