@dfosco/storyboard-react 3.10.0 → 3.11.0-beta.1
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.bridge.test.jsx +4 -0
- package/src/canvas/CanvasPage.jsx +416 -22
- package/src/canvas/canvasApi.js +8 -0
- 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/FigmaEmbed.jsx +106 -0
- package/src/canvas/widgets/FigmaEmbed.module.css +83 -0
- package/src/canvas/widgets/ImageWidget.jsx +91 -0
- package/src/canvas/widgets/ImageWidget.module.css +39 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +20 -1
- package/src/canvas/widgets/WidgetChrome.jsx +67 -26
- package/src/canvas/widgets/WidgetChrome.module.css +15 -5
- package/src/canvas/widgets/figmaUrl.js +118 -0
- package/src/canvas/widgets/figmaUrl.test.js +139 -0
- package/src/canvas/widgets/index.js +4 -0
- package/src/canvas/widgets/widgetConfig.js +2 -2
- package/src/canvas/widgets/widgetProps.js +2 -0
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { forwardRef, useImperativeHandle, useMemo, useCallback, useState } from 'react'
|
|
2
|
+
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
3
|
+
import { readProp } from './widgetProps.js'
|
|
4
|
+
import { schemas } from './widgetConfig.js'
|
|
5
|
+
import { toFigmaEmbedUrl, getFigmaTitle, getFigmaType, isFigmaUrl } from './figmaUrl.js'
|
|
6
|
+
import styles from './FigmaEmbed.module.css'
|
|
7
|
+
|
|
8
|
+
const figmaEmbedSchema = schemas['figma-embed']
|
|
9
|
+
|
|
10
|
+
/** Inline Figma logo SVG */
|
|
11
|
+
function FigmaLogo() {
|
|
12
|
+
return (
|
|
13
|
+
<svg className={styles.figmaLogo} viewBox="0 0 38 57" fill="none" aria-hidden="true">
|
|
14
|
+
<path d="M19 28.5a9.5 9.5 0 1 1 19 0 9.5 9.5 0 0 1-19 0z" fill="#1ABCFE" />
|
|
15
|
+
<path d="M0 47.5A9.5 9.5 0 0 1 9.5 38H19v9.5a9.5 9.5 0 1 1-19 0z" fill="#0ACF83" />
|
|
16
|
+
<path d="M19 0v19h9.5a9.5 9.5 0 1 0 0-19H19z" fill="#FF7262" />
|
|
17
|
+
<path d="M0 9.5A9.5 9.5 0 0 0 9.5 19H19V0H9.5A9.5 9.5 0 0 0 0 9.5z" fill="#F24E1E" />
|
|
18
|
+
<path d="M0 28.5A9.5 9.5 0 0 0 9.5 38H19V19H9.5A9.5 9.5 0 0 0 0 28.5z" fill="#A259FF" />
|
|
19
|
+
</svg>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const TYPE_LABELS = { board: 'Board', design: 'Design', proto: 'Prototype' }
|
|
24
|
+
|
|
25
|
+
export default forwardRef(function FigmaEmbed({ props, onUpdate }, ref) {
|
|
26
|
+
const url = readProp(props, 'url', figmaEmbedSchema)
|
|
27
|
+
const width = readProp(props, 'width', figmaEmbedSchema)
|
|
28
|
+
const height = readProp(props, 'height', figmaEmbedSchema)
|
|
29
|
+
|
|
30
|
+
const [interactive, setInteractive] = useState(false)
|
|
31
|
+
|
|
32
|
+
// Validate URL at render time — only embed known Figma URLs
|
|
33
|
+
const isValid = useMemo(() => isFigmaUrl(url), [url])
|
|
34
|
+
const embedUrl = useMemo(() => (isValid ? toFigmaEmbedUrl(url) : ''), [url, isValid])
|
|
35
|
+
const title = useMemo(() => (url ? getFigmaTitle(url) : 'Figma'), [url])
|
|
36
|
+
const figmaType = useMemo(() => getFigmaType(url), [url])
|
|
37
|
+
const typeLabel = figmaType ? TYPE_LABELS[figmaType] : 'Figma'
|
|
38
|
+
|
|
39
|
+
const enterInteractive = useCallback(() => setInteractive(true), [])
|
|
40
|
+
|
|
41
|
+
useImperativeHandle(ref, () => ({
|
|
42
|
+
handleAction(actionId) {
|
|
43
|
+
if (actionId === 'open-external') {
|
|
44
|
+
if (url) window.open(url, '_blank', 'noopener')
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
}), [url])
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<WidgetWrapper>
|
|
51
|
+
<div className={styles.embed} style={{ width, height }}>
|
|
52
|
+
<div className={styles.header}>
|
|
53
|
+
<FigmaLogo />
|
|
54
|
+
<span className={styles.headerTitle}>{title}</span>
|
|
55
|
+
</div>
|
|
56
|
+
{embedUrl ? (
|
|
57
|
+
<>
|
|
58
|
+
<div className={styles.iframeContainer}>
|
|
59
|
+
<iframe
|
|
60
|
+
src={embedUrl}
|
|
61
|
+
className={styles.iframe}
|
|
62
|
+
title={`Figma ${typeLabel}: ${title}`}
|
|
63
|
+
allowFullScreen
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
{!interactive && (
|
|
67
|
+
<div
|
|
68
|
+
className={styles.dragOverlay}
|
|
69
|
+
onDoubleClick={enterInteractive}
|
|
70
|
+
/>
|
|
71
|
+
)}
|
|
72
|
+
</>
|
|
73
|
+
) : (
|
|
74
|
+
<div className={styles.iframeContainer} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
75
|
+
<p style={{ color: 'var(--fgColor-muted, #656d76)', fontSize: 14, fontStyle: 'italic' }}>
|
|
76
|
+
No Figma URL
|
|
77
|
+
</p>
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
<div
|
|
82
|
+
className={styles.resizeHandle}
|
|
83
|
+
onMouseDown={(e) => {
|
|
84
|
+
e.stopPropagation()
|
|
85
|
+
e.preventDefault()
|
|
86
|
+
const startX = e.clientX
|
|
87
|
+
const startY = e.clientY
|
|
88
|
+
const startW = width
|
|
89
|
+
const startH = height
|
|
90
|
+
function onMove(ev) {
|
|
91
|
+
const newW = Math.max(200, startW + ev.clientX - startX)
|
|
92
|
+
const newH = Math.max(150, startH + ev.clientY - startY)
|
|
93
|
+
onUpdate?.({ width: newW, height: newH })
|
|
94
|
+
}
|
|
95
|
+
function onUp() {
|
|
96
|
+
document.removeEventListener('mousemove', onMove)
|
|
97
|
+
document.removeEventListener('mouseup', onUp)
|
|
98
|
+
}
|
|
99
|
+
document.addEventListener('mousemove', onMove)
|
|
100
|
+
document.addEventListener('mouseup', onUp)
|
|
101
|
+
}}
|
|
102
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
103
|
+
/>
|
|
104
|
+
</WidgetWrapper>
|
|
105
|
+
)
|
|
106
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
.embed {
|
|
2
|
+
position: relative;
|
|
3
|
+
overflow: hidden;
|
|
4
|
+
background: var(--bgColor-default, #ffffff);
|
|
5
|
+
border: 3px solid var(--borderColor-default, #d0d7de);
|
|
6
|
+
border-radius: 8px;
|
|
7
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.header {
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
gap: 6px;
|
|
14
|
+
padding: 6px 10px;
|
|
15
|
+
font-size: 12px;
|
|
16
|
+
font-weight: 500;
|
|
17
|
+
color: var(--fgColor-muted, #656d76);
|
|
18
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
19
|
+
border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
|
|
20
|
+
white-space: nowrap;
|
|
21
|
+
overflow: hidden;
|
|
22
|
+
text-overflow: ellipsis;
|
|
23
|
+
user-select: none;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.figmaLogo {
|
|
27
|
+
width: 14px;
|
|
28
|
+
height: 14px;
|
|
29
|
+
flex-shrink: 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.headerTitle {
|
|
33
|
+
overflow: hidden;
|
|
34
|
+
text-overflow: ellipsis;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.iframeContainer {
|
|
38
|
+
width: 100%;
|
|
39
|
+
height: calc(100% - 31px); /* subtract header height */
|
|
40
|
+
overflow: hidden;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.iframe {
|
|
44
|
+
width: 100%;
|
|
45
|
+
height: calc(100% + 24px); /* clip Figma's built-in bottom toolbar */
|
|
46
|
+
border: none;
|
|
47
|
+
display: block;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.dragOverlay {
|
|
51
|
+
position: absolute;
|
|
52
|
+
inset: 0;
|
|
53
|
+
z-index: 1;
|
|
54
|
+
cursor: grab;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.resizeHandle {
|
|
58
|
+
position: absolute;
|
|
59
|
+
bottom: 0;
|
|
60
|
+
right: 0;
|
|
61
|
+
width: 16px;
|
|
62
|
+
height: 16px;
|
|
63
|
+
cursor: nwse-resize;
|
|
64
|
+
background: linear-gradient(
|
|
65
|
+
135deg,
|
|
66
|
+
transparent 40%,
|
|
67
|
+
var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 40%,
|
|
68
|
+
var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 50%,
|
|
69
|
+
transparent 50%,
|
|
70
|
+
transparent 65%,
|
|
71
|
+
var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 65%,
|
|
72
|
+
var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 75%,
|
|
73
|
+
transparent 75%
|
|
74
|
+
);
|
|
75
|
+
opacity: 0;
|
|
76
|
+
transition: opacity 150ms;
|
|
77
|
+
z-index: 2;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.embed:hover ~ .resizeHandle,
|
|
81
|
+
.resizeHandle:hover {
|
|
82
|
+
opacity: 1;
|
|
83
|
+
}
|