@dfosco/storyboard-react 3.11.0-beta.0 → 3.11.0-beta.2
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/CanvasControls.jsx +2 -59
- package/src/canvas/CanvasControls.module.css +0 -29
- package/src/canvas/CanvasPage.jsx +264 -17
- package/src/canvas/computeCanvasBounds.test.js +121 -0
- package/src/canvas/useUndoRedo.js +86 -0
- package/src/canvas/useUndoRedo.test.js +231 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +17 -0
- package/src/canvas/widgets/WidgetChrome.jsx +113 -15
- package/src/canvas/widgets/WidgetChrome.module.css +64 -0
- package/src/canvas/widgets/widgetConfig.js +44 -3
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// computeCanvasBounds is not exported, so we replicate it here for unit testing.
|
|
4
|
+
// This keeps the test decoupled from CanvasPage internals while validating the algorithm.
|
|
5
|
+
|
|
6
|
+
const WIDGET_FALLBACK_SIZES = {
|
|
7
|
+
'sticky-note': { width: 180, height: 60 },
|
|
8
|
+
'markdown': { width: 360, height: 200 },
|
|
9
|
+
'prototype': { width: 800, height: 600 },
|
|
10
|
+
'link-preview': { width: 320, height: 120 },
|
|
11
|
+
'figma-embed': { width: 800, height: 450 },
|
|
12
|
+
'component': { width: 200, height: 150 },
|
|
13
|
+
'image': { width: 400, height: 300 },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function computeCanvasBounds(widgets, sources, jsxExports) {
|
|
17
|
+
let minX = Infinity
|
|
18
|
+
let minY = Infinity
|
|
19
|
+
let maxX = -Infinity
|
|
20
|
+
let maxY = -Infinity
|
|
21
|
+
let hasItems = false
|
|
22
|
+
|
|
23
|
+
for (const w of (widgets ?? [])) {
|
|
24
|
+
const x = w?.position?.x ?? 0
|
|
25
|
+
const y = w?.position?.y ?? 0
|
|
26
|
+
const fallback = WIDGET_FALLBACK_SIZES[w.type] || { width: 200, height: 150 }
|
|
27
|
+
const width = w.props?.width ?? fallback.width
|
|
28
|
+
const height = w.props?.height ?? fallback.height
|
|
29
|
+
minX = Math.min(minX, x)
|
|
30
|
+
minY = Math.min(minY, y)
|
|
31
|
+
maxX = Math.max(maxX, x + width)
|
|
32
|
+
maxY = Math.max(maxY, y + height)
|
|
33
|
+
hasItems = true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const sourceMap = Object.fromEntries(
|
|
37
|
+
(sources || []).filter((s) => s?.export).map((s) => [s.export, s])
|
|
38
|
+
)
|
|
39
|
+
if (jsxExports) {
|
|
40
|
+
for (const exportName of Object.keys(jsxExports)) {
|
|
41
|
+
const sourceData = sourceMap[exportName] || {}
|
|
42
|
+
const x = sourceData.position?.x ?? 0
|
|
43
|
+
const y = sourceData.position?.y ?? 0
|
|
44
|
+
const fallback = WIDGET_FALLBACK_SIZES['component']
|
|
45
|
+
const width = sourceData.width ?? fallback.width
|
|
46
|
+
const height = sourceData.height ?? fallback.height
|
|
47
|
+
minX = Math.min(minX, x)
|
|
48
|
+
minY = Math.min(minY, y)
|
|
49
|
+
maxX = Math.max(maxX, x + width)
|
|
50
|
+
maxY = Math.max(maxY, y + height)
|
|
51
|
+
hasItems = true
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return hasItems ? { minX, minY, maxX, maxY } : null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('computeCanvasBounds', () => {
|
|
59
|
+
it('returns null for empty canvas', () => {
|
|
60
|
+
expect(computeCanvasBounds([], [], null)).toBeNull()
|
|
61
|
+
expect(computeCanvasBounds(null, null, null)).toBeNull()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('computes bounds for a single widget using props dimensions', () => {
|
|
65
|
+
const widgets = [
|
|
66
|
+
{ type: 'sticky-note', position: { x: 100, y: 200 }, props: { width: 300, height: 100 } },
|
|
67
|
+
]
|
|
68
|
+
const bounds = computeCanvasBounds(widgets, [], null)
|
|
69
|
+
expect(bounds).toEqual({ minX: 100, minY: 200, maxX: 400, maxY: 300 })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('uses fallback dimensions when props are missing', () => {
|
|
73
|
+
const widgets = [
|
|
74
|
+
{ type: 'sticky-note', position: { x: 50, y: 50 }, props: {} },
|
|
75
|
+
]
|
|
76
|
+
const bounds = computeCanvasBounds(widgets, [], null)
|
|
77
|
+
expect(bounds).toEqual({ minX: 50, minY: 50, maxX: 230, maxY: 110 }) // 50+180, 50+60
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('computes bounds spanning multiple widgets', () => {
|
|
81
|
+
const widgets = [
|
|
82
|
+
{ type: 'sticky-note', position: { x: 0, y: 0 }, props: { width: 180, height: 60 } },
|
|
83
|
+
{ type: 'markdown', position: { x: 500, y: 300 }, props: { width: 360, height: 200 } },
|
|
84
|
+
]
|
|
85
|
+
const bounds = computeCanvasBounds(widgets, [], null)
|
|
86
|
+
expect(bounds).toEqual({ minX: 0, minY: 0, maxX: 860, maxY: 500 })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('includes JSX sources in bounds', () => {
|
|
90
|
+
const sources = [{ export: 'Hero', position: { x: -100, y: -50 }, width: 400, height: 300 }]
|
|
91
|
+
const jsxExports = { Hero: () => null }
|
|
92
|
+
const bounds = computeCanvasBounds([], sources, jsxExports)
|
|
93
|
+
expect(bounds).toEqual({ minX: -100, minY: -50, maxX: 300, maxY: 250 })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('combines widgets and JSX sources', () => {
|
|
97
|
+
const widgets = [
|
|
98
|
+
{ type: 'sticky-note', position: { x: 200, y: 200 }, props: { width: 180, height: 60 } },
|
|
99
|
+
]
|
|
100
|
+
const sources = [{ export: 'Nav', position: { x: 0, y: 0 }, width: 100, height: 100 }]
|
|
101
|
+
const jsxExports = { Nav: () => null }
|
|
102
|
+
const bounds = computeCanvasBounds(widgets, sources, jsxExports)
|
|
103
|
+
expect(bounds).toEqual({ minX: 0, minY: 0, maxX: 380, maxY: 260 })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('handles widgets with missing position (defaults to 0,0)', () => {
|
|
107
|
+
const widgets = [
|
|
108
|
+
{ type: 'sticky-note', props: { width: 180, height: 60 } },
|
|
109
|
+
]
|
|
110
|
+
const bounds = computeCanvasBounds(widgets, [], null)
|
|
111
|
+
expect(bounds).toEqual({ minX: 0, minY: 0, maxX: 180, maxY: 60 })
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('uses component fallback for JSX sources without explicit size', () => {
|
|
115
|
+
const sources = [{ export: 'Card', position: { x: 10, y: 10 } }]
|
|
116
|
+
const jsxExports = { Card: () => null }
|
|
117
|
+
const bounds = computeCanvasBounds([], sources, jsxExports)
|
|
118
|
+
// component fallback: 200x150
|
|
119
|
+
expect(bounds).toEqual({ minX: 10, minY: 10, maxX: 210, maxY: 160 })
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useRef, useState, useCallback } from 'react'
|
|
2
|
+
|
|
3
|
+
const MAX_HISTORY = 100
|
|
4
|
+
const COALESCE_MS = 2000
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Snapshot-based undo/redo history for canvas widgets.
|
|
8
|
+
*
|
|
9
|
+
* Tracks past/future stacks of widget array clones. The present state
|
|
10
|
+
* is always the live `localWidgets` — this hook only manages history.
|
|
11
|
+
*
|
|
12
|
+
* Edit coalescing: continuous edits to the same widget within COALESCE_MS
|
|
13
|
+
* are merged into one undo step (like Figma).
|
|
14
|
+
*/
|
|
15
|
+
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 })
|
|
21
|
+
|
|
22
|
+
const snapshot = useCallback((currentWidgets, actionType, widgetId) => {
|
|
23
|
+
const widgets = currentWidgets ?? []
|
|
24
|
+
|
|
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
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
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
|
+
}, [])
|
|
49
|
+
|
|
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
|
+
}, [])
|
|
58
|
+
|
|
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
|
+
}, [])
|
|
67
|
+
|
|
68
|
+
const reset = useCallback(() => {
|
|
69
|
+
pastRef.current = []
|
|
70
|
+
futureRef.current = []
|
|
71
|
+
lastActionRef.current = { type: null, widgetId: null, time: 0 }
|
|
72
|
+
setCounts((prev) => {
|
|
73
|
+
if (prev.past === 0 && prev.future === 0) return prev
|
|
74
|
+
return { past: 0, future: 0 }
|
|
75
|
+
})
|
|
76
|
+
}, [])
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
snapshot,
|
|
80
|
+
undo,
|
|
81
|
+
redo,
|
|
82
|
+
reset,
|
|
83
|
+
canUndo: counts.past > 0,
|
|
84
|
+
canRedo: counts.future > 0,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react'
|
|
2
|
+
import useUndoRedo from './useUndoRedo.js'
|
|
3
|
+
|
|
4
|
+
describe('useUndoRedo', () => {
|
|
5
|
+
it('starts with canUndo and canRedo as false', () => {
|
|
6
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
7
|
+
expect(result.current.canUndo).toBe(false)
|
|
8
|
+
expect(result.current.canRedo).toBe(false)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('can undo after a snapshot', () => {
|
|
12
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
13
|
+
const widgets = [{ id: '1', type: 'sticky-note', props: { text: 'a' }, position: { x: 0, y: 0 } }]
|
|
14
|
+
|
|
15
|
+
act(() => result.current.snapshot(widgets, 'add'))
|
|
16
|
+
|
|
17
|
+
expect(result.current.canUndo).toBe(true)
|
|
18
|
+
expect(result.current.canRedo).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('undo returns the previous state and enables redo', () => {
|
|
22
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
23
|
+
const before = [{ id: '1', props: { text: 'a' } }]
|
|
24
|
+
const after = [{ id: '1', props: { text: 'b' } }]
|
|
25
|
+
|
|
26
|
+
act(() => result.current.snapshot(before, 'edit', '1'))
|
|
27
|
+
|
|
28
|
+
let restored
|
|
29
|
+
act(() => { restored = result.current.undo(after) })
|
|
30
|
+
|
|
31
|
+
expect(restored).toEqual(before)
|
|
32
|
+
expect(result.current.canUndo).toBe(false)
|
|
33
|
+
expect(result.current.canRedo).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('redo returns the next state', () => {
|
|
37
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
38
|
+
const before = [{ id: '1', props: { text: 'a' } }]
|
|
39
|
+
const after = [{ id: '1', props: { text: 'b' } }]
|
|
40
|
+
|
|
41
|
+
act(() => result.current.snapshot(before, 'edit', '1'))
|
|
42
|
+
act(() => { result.current.undo(after) })
|
|
43
|
+
|
|
44
|
+
let redone
|
|
45
|
+
act(() => { redone = result.current.redo(before) })
|
|
46
|
+
|
|
47
|
+
expect(redone).toEqual(after)
|
|
48
|
+
expect(result.current.canUndo).toBe(true)
|
|
49
|
+
expect(result.current.canRedo).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('new mutation after undo clears the redo chain', () => {
|
|
53
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
54
|
+
const s0 = [{ id: '1' }]
|
|
55
|
+
const s1 = [{ id: '1' }, { id: '2' }]
|
|
56
|
+
const s2 = [{ id: '1' }, { id: '3' }]
|
|
57
|
+
|
|
58
|
+
act(() => result.current.snapshot(s0, 'add'))
|
|
59
|
+
act(() => result.current.undo(s1))
|
|
60
|
+
expect(result.current.canRedo).toBe(true)
|
|
61
|
+
|
|
62
|
+
// New mutation breaks redo
|
|
63
|
+
act(() => result.current.snapshot(s0, 'add'))
|
|
64
|
+
expect(result.current.canRedo).toBe(false)
|
|
65
|
+
expect(result.current.canUndo).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('supports multi-step undo-redo-undo chains', () => {
|
|
69
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
70
|
+
const s0 = [{ id: '1' }]
|
|
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'))
|
|
122
|
+
expect(result.current.canUndo).toBe(true)
|
|
123
|
+
|
|
124
|
+
// Second edit to same widget within 2s — coalesced, no new snapshot
|
|
125
|
+
act(() => result.current.snapshot(s0, 'edit', '1'))
|
|
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)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('does NOT coalesce edits to different widgets', () => {
|
|
135
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
136
|
+
const s0 = [{ id: '1' }, { id: '2' }]
|
|
137
|
+
const s1 = [{ id: '1', props: { text: 'a' } }, { id: '2' }]
|
|
138
|
+
|
|
139
|
+
act(() => result.current.snapshot(s0, 'edit', '1'))
|
|
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([]) })
|
|
147
|
+
expect(result.current.canUndo).toBe(false)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('does NOT coalesce edit after a different action type', () => {
|
|
151
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
152
|
+
const s0 = [{ id: '1' }]
|
|
153
|
+
const s1 = [{ id: '1' }, { id: '2' }]
|
|
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)
|
|
171
|
+
|
|
172
|
+
act(() => result.current.reset())
|
|
173
|
+
expect(result.current.canUndo).toBe(false)
|
|
174
|
+
expect(result.current.canRedo).toBe(false)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('undo returns null when history is empty', () => {
|
|
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', () => {
|
|
206
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
207
|
+
|
|
208
|
+
for (let i = 0; i < 110; i++) {
|
|
209
|
+
act(() => result.current.snapshot([{ id: String(i) }], 'add'))
|
|
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([])
|
|
230
|
+
})
|
|
231
|
+
})
|
|
@@ -56,6 +56,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
|
|
|
56
56
|
const inputRef = useRef(null)
|
|
57
57
|
const filterRef = useRef(null)
|
|
58
58
|
const embedRef = useRef(null)
|
|
59
|
+
const iframeRef = useRef(null)
|
|
59
60
|
|
|
60
61
|
const iframeSrc = useMemo(() => {
|
|
61
62
|
if (!rawSrc) return ''
|
|
@@ -177,6 +178,21 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
|
|
|
177
178
|
return () => document.removeEventListener('storyboard:theme:changed', readToolbarTheme)
|
|
178
179
|
}, [])
|
|
179
180
|
|
|
181
|
+
// Listen for navigation events from the embedded prototype iframe
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
function handleMessage(e) {
|
|
184
|
+
if (e.source !== iframeRef.current?.contentWindow) return
|
|
185
|
+
if (e.data?.type !== 'storyboard:embed:navigate') return
|
|
186
|
+
const newSrc = e.data.src
|
|
187
|
+
if (newSrc && newSrc !== src) {
|
|
188
|
+
const originalSrc = readProp(props, 'originalSrc', prototypeEmbedSchema)
|
|
189
|
+
onUpdate?.({ src: newSrc, originalSrc: originalSrc || src })
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
window.addEventListener('message', handleMessage)
|
|
193
|
+
return () => window.removeEventListener('message', handleMessage)
|
|
194
|
+
}, [src, props, onUpdate])
|
|
195
|
+
|
|
180
196
|
const chromeVars = useMemo(() => getEmbedChromeVars(canvasTheme), [canvasTheme])
|
|
181
197
|
|
|
182
198
|
const enterInteractive = useCallback(() => setInteractive(true), [])
|
|
@@ -309,6 +325,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
|
|
|
309
325
|
<>
|
|
310
326
|
<div className={styles.iframeContainer}>
|
|
311
327
|
<iframe
|
|
328
|
+
ref={iframeRef}
|
|
312
329
|
src={iframeSrc}
|
|
313
330
|
className={styles.iframe}
|
|
314
331
|
style={{
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback, useRef } from 'react'
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from 'react'
|
|
2
2
|
import { Tooltip } from '@primer/react'
|
|
3
3
|
import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed } from '@primer/octicons-react'
|
|
4
4
|
import styles from './WidgetChrome.module.css'
|
|
@@ -60,22 +60,111 @@ function EyeClosedIcon() {
|
|
|
60
60
|
return <OcticonEyeClosed size={12} />
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
function CopyIcon() {
|
|
64
|
+
return (
|
|
65
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
66
|
+
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z" />
|
|
67
|
+
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" />
|
|
68
|
+
</svg>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function MoreIcon() {
|
|
73
|
+
return (
|
|
74
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
75
|
+
<path d="M8 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM1.5 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm13 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
|
|
76
|
+
</svg>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function LinkIcon() {
|
|
81
|
+
return (
|
|
82
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
83
|
+
<path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z" />
|
|
84
|
+
</svg>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Icon registry — maps icon name strings from config to React components. */
|
|
89
|
+
const ICON_REGISTRY = {
|
|
90
|
+
'trash': DeleteIcon,
|
|
65
91
|
'zoom-in': ZoomInIcon,
|
|
66
92
|
'zoom-out': ZoomOutIcon,
|
|
67
93
|
'edit': EditIcon,
|
|
68
94
|
'open-external': OpenExternalIcon,
|
|
69
|
-
'
|
|
95
|
+
'eye': EyeIcon,
|
|
96
|
+
'eye-closed': EyeClosedIcon,
|
|
97
|
+
'copy': CopyIcon,
|
|
98
|
+
'link': LinkIcon,
|
|
99
|
+
'more': MoreIcon,
|
|
70
100
|
}
|
|
71
101
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
102
|
+
/** Danger-styled actions in the overflow menu. */
|
|
103
|
+
const DANGER_ACTIONS = new Set(['delete'])
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Overflow menu — `...` button that opens a dropdown with menu-only actions.
|
|
107
|
+
*/
|
|
108
|
+
function WidgetOverflowMenu({ widgetId, menuFeatures, onAction }) {
|
|
109
|
+
const [open, setOpen] = useState(false)
|
|
110
|
+
const menuRef = useRef(null)
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!open) return
|
|
114
|
+
function handlePointerDown(e) {
|
|
115
|
+
if (menuRef.current && !menuRef.current.contains(e.target)) {
|
|
116
|
+
setOpen(false)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
document.addEventListener('pointerdown', handlePointerDown)
|
|
120
|
+
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
121
|
+
}, [open])
|
|
122
|
+
|
|
123
|
+
const handleItemClick = useCallback((action, e) => {
|
|
124
|
+
e.stopPropagation()
|
|
125
|
+
if (action === 'copy-link') {
|
|
126
|
+
const url = new URL(window.location.href)
|
|
127
|
+
url.searchParams.set('widget', widgetId)
|
|
128
|
+
navigator.clipboard.writeText(url.toString()).catch(() => {})
|
|
129
|
+
} else {
|
|
130
|
+
onAction?.(action)
|
|
131
|
+
}
|
|
132
|
+
setOpen(false)
|
|
133
|
+
}, [widgetId, onAction])
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div ref={menuRef} className={styles.overflowWrapper}>
|
|
137
|
+
<Tooltip text="More actions" direction="n">
|
|
138
|
+
<button
|
|
139
|
+
className={styles.featureBtn}
|
|
140
|
+
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v) }}
|
|
141
|
+
aria-label="More actions"
|
|
142
|
+
aria-expanded={open}
|
|
143
|
+
>
|
|
144
|
+
<MoreIcon />
|
|
145
|
+
</button>
|
|
146
|
+
</Tooltip>
|
|
147
|
+
{open && (
|
|
148
|
+
<div className={styles.overflowMenu}>
|
|
149
|
+
{menuFeatures.map((feature) => {
|
|
150
|
+
const Icon = ICON_REGISTRY[feature.icon]
|
|
151
|
+
const label = feature.label || feature.action
|
|
152
|
+
const isDanger = DANGER_ACTIONS.has(feature.action)
|
|
153
|
+
return (
|
|
154
|
+
<button
|
|
155
|
+
key={feature.id}
|
|
156
|
+
className={`${styles.overflowItem} ${isDanger ? styles.overflowItemDanger : ''}`}
|
|
157
|
+
onClick={(e) => handleItemClick(feature.action, e)}
|
|
158
|
+
>
|
|
159
|
+
{Icon && <Icon />}
|
|
160
|
+
<span>{label}</span>
|
|
161
|
+
</button>
|
|
162
|
+
)
|
|
163
|
+
})}
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
)
|
|
79
168
|
}
|
|
80
169
|
|
|
81
170
|
/**
|
|
@@ -135,6 +224,7 @@ function ColorPickerFeature({ currentColor, options, onColorChange }) {
|
|
|
135
224
|
* non-standard actions (anything other than 'delete').
|
|
136
225
|
*/
|
|
137
226
|
export default function WidgetChrome({
|
|
227
|
+
widgetId,
|
|
138
228
|
features = [],
|
|
139
229
|
selected = false,
|
|
140
230
|
widgetProps,
|
|
@@ -176,7 +266,7 @@ export default function WidgetChrome({
|
|
|
176
266
|
const handleActionClick = useCallback((actionId, e) => {
|
|
177
267
|
e.stopPropagation()
|
|
178
268
|
// Standard actions go through onAction (handled by CanvasPage)
|
|
179
|
-
if (actionId === 'delete') {
|
|
269
|
+
if (actionId === 'delete' || actionId === 'copy') {
|
|
180
270
|
onAction?.(actionId)
|
|
181
271
|
return
|
|
182
272
|
}
|
|
@@ -216,6 +306,9 @@ export default function WidgetChrome({
|
|
|
216
306
|
<div className={`${styles.toolbarContent} ${showToolbar ? styles.toolbarContentVisible : ''}`}>
|
|
217
307
|
<div className={styles.featureButtons}>
|
|
218
308
|
{features.map((feature) => {
|
|
309
|
+
// Menu features are rendered in WidgetOverflowMenu
|
|
310
|
+
if (feature.menu) return null
|
|
311
|
+
|
|
219
312
|
if (feature.type === 'color-picker') {
|
|
220
313
|
return (
|
|
221
314
|
<ColorPickerFeature
|
|
@@ -228,13 +321,13 @@ export default function WidgetChrome({
|
|
|
228
321
|
}
|
|
229
322
|
|
|
230
323
|
if (feature.type === 'action') {
|
|
231
|
-
let Icon =
|
|
232
|
-
let label =
|
|
324
|
+
let Icon = ICON_REGISTRY[feature.icon]
|
|
325
|
+
let label = feature.label || feature.action
|
|
233
326
|
|
|
234
327
|
// Toggle-private: swap icon/label based on current state
|
|
235
328
|
if (feature.action === 'toggle-private') {
|
|
236
329
|
if (widgetProps?.private) {
|
|
237
|
-
Icon =
|
|
330
|
+
Icon = ICON_REGISTRY['eye-closed']
|
|
238
331
|
label = 'Private image — only visible locally'
|
|
239
332
|
} else {
|
|
240
333
|
label = 'Published image — deployed with canvas'
|
|
@@ -256,6 +349,11 @@ export default function WidgetChrome({
|
|
|
256
349
|
|
|
257
350
|
return null
|
|
258
351
|
})}
|
|
352
|
+
<WidgetOverflowMenu
|
|
353
|
+
widgetId={widgetId}
|
|
354
|
+
menuFeatures={features.filter((f) => f.menu)}
|
|
355
|
+
onAction={onAction}
|
|
356
|
+
/>
|
|
259
357
|
</div>
|
|
260
358
|
|
|
261
359
|
<Tooltip text="Select" direction="n">
|