@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.
@@ -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
- const ACTION_ICONS = {
64
- 'delete': DeleteIcon,
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
- 'toggle-private': EyeIcon,
95
+ 'eye': EyeIcon,
96
+ 'eye-closed': EyeClosedIcon,
97
+ 'copy': CopyIcon,
98
+ 'link': LinkIcon,
99
+ 'more': MoreIcon,
70
100
  }
71
101
 
72
- const ACTION_LABELS = {
73
- 'delete': 'Delete widget',
74
- 'zoom-in': 'Zoom in',
75
- 'zoom-out': 'Zoom out',
76
- 'edit': 'Edit',
77
- 'open-external': 'Open in new tab',
78
- 'toggle-private': 'Make private',
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 = ACTION_ICONS[feature.action]
232
- let label = ACTION_LABELS[feature.action] || feature.action
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 = EyeClosedIcon
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">