@dfosco/storyboard-react 3.11.1-beta.0 → 3.11.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/Viewfinder.jsx +5 -3
- package/src/__mocks__/virtual-storyboard-data-index.js +1 -0
- package/src/canvas/CanvasControls.jsx +2 -59
- package/src/canvas/CanvasControls.module.css +0 -29
- package/src/canvas/CanvasPage.bridge.test.jsx +68 -42
- package/src/canvas/CanvasPage.jsx +801 -68
- package/src/canvas/CanvasPage.module.css +47 -2
- package/src/canvas/CanvasPage.multiselect.test.jsx +345 -0
- package/src/canvas/canvasApi.js +8 -0
- package/src/canvas/computeCanvasBounds.test.js +121 -0
- package/src/canvas/useCanvas.js +2 -1
- package/src/canvas/useUndoRedo.js +86 -0
- package/src/canvas/useUndoRedo.test.js +231 -0
- package/src/canvas/widgets/ComponentWidget.jsx +9 -7
- package/src/canvas/widgets/FigmaEmbed.jsx +195 -0
- package/src/canvas/widgets/FigmaEmbed.module.css +147 -0
- package/src/canvas/widgets/ImageWidget.jsx +115 -0
- package/src/canvas/widgets/ImageWidget.module.css +39 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +25 -8
- package/src/canvas/widgets/MarkdownBlock.test.jsx +53 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +132 -26
- package/src/canvas/widgets/PrototypeEmbed.module.css +66 -2
- package/src/canvas/widgets/StickyNote.jsx +21 -16
- package/src/canvas/widgets/StickyNote.test.jsx +24 -4
- package/src/canvas/widgets/WidgetChrome.jsx +276 -50
- package/src/canvas/widgets/WidgetChrome.module.css +91 -10
- 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 +74 -6
- package/src/canvas/widgets/widgetConfig.test.js +46 -0
- package/src/canvas/widgets/widgetProps.js +2 -0
- package/src/context.jsx +34 -4
- package/src/context.test.jsx +13 -0
|
@@ -49,16 +49,22 @@ describe('StickyNote', () => {
|
|
|
49
49
|
expect(sticky.style.height).toBe('200px')
|
|
50
50
|
})
|
|
51
51
|
|
|
52
|
-
it('renders a resize handle', () => {
|
|
53
|
-
const { container } = render(<StickyNote props={{ text: 'Hi' }} onUpdate={vi.fn()} />)
|
|
52
|
+
it('renders a resize handle when resizable', () => {
|
|
53
|
+
const { container } = render(<StickyNote props={{ text: 'Hi' }} onUpdate={vi.fn()} resizable />)
|
|
54
54
|
const handle = container.querySelector('[role="separator"]')
|
|
55
55
|
expect(handle).not.toBeNull()
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
+
it('does not render a resize handle when not resizable', () => {
|
|
59
|
+
const { container } = render(<StickyNote props={{ text: 'Hi' }} onUpdate={vi.fn()} resizable={false} />)
|
|
60
|
+
const handle = container.querySelector('[role="separator"]')
|
|
61
|
+
expect(handle).toBeNull()
|
|
62
|
+
})
|
|
63
|
+
|
|
58
64
|
it('calls onUpdate with new dimensions on resize drag', () => {
|
|
59
65
|
const onUpdate = vi.fn()
|
|
60
66
|
const { container } = render(
|
|
61
|
-
<StickyNote props={{ text: 'Hi', width: 200, height: 150 }} onUpdate={onUpdate} />
|
|
67
|
+
<StickyNote props={{ text: 'Hi', width: 200, height: 150 }} onUpdate={onUpdate} resizable />
|
|
62
68
|
)
|
|
63
69
|
const handle = container.querySelector('[role="separator"]')
|
|
64
70
|
const sticky = container.querySelector('article')
|
|
@@ -78,7 +84,7 @@ describe('StickyNote', () => {
|
|
|
78
84
|
it('enforces minimum dimensions during resize', () => {
|
|
79
85
|
const onUpdate = vi.fn()
|
|
80
86
|
const { container } = render(
|
|
81
|
-
<StickyNote props={{ text: 'Hi', width: 200, height: 150 }} onUpdate={onUpdate} />
|
|
87
|
+
<StickyNote props={{ text: 'Hi', width: 200, height: 150 }} onUpdate={onUpdate} resizable />
|
|
82
88
|
)
|
|
83
89
|
const handle = container.querySelector('[role="separator"]')
|
|
84
90
|
const sticky = container.querySelector('article')
|
|
@@ -93,4 +99,18 @@ describe('StickyNote', () => {
|
|
|
93
99
|
|
|
94
100
|
expect(onUpdate).toHaveBeenCalledWith({ width: 180, height: 60 })
|
|
95
101
|
})
|
|
102
|
+
|
|
103
|
+
it('does not enter edit mode without onUpdate (read-only/prod)', () => {
|
|
104
|
+
const { container } = render(<StickyNote props={{ text: 'Read me' }} />)
|
|
105
|
+
const text = container.querySelector('p')
|
|
106
|
+
fireEvent.doubleClick(text)
|
|
107
|
+
expect(container.querySelector('textarea')).toBeNull()
|
|
108
|
+
expect(container.querySelector('[data-canvas-allow-text-selection]')).not.toBeNull()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('shows non-editable empty-state text in read-only mode', () => {
|
|
112
|
+
const { container } = render(<StickyNote props={{ text: '' }} />)
|
|
113
|
+
expect(container.textContent).toContain('No content')
|
|
114
|
+
expect(container.textContent).not.toContain('Double-click to edit…')
|
|
115
|
+
})
|
|
96
116
|
})
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { useState, useCallback, useRef } from 'react'
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from 'react'
|
|
2
|
+
import { Tooltip } from '@primer/react'
|
|
3
|
+
import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed } from '@primer/octicons-react'
|
|
2
4
|
import styles from './WidgetChrome.module.css'
|
|
3
5
|
|
|
4
6
|
const STICKY_NOTE_COLORS = {
|
|
@@ -42,18 +44,213 @@ function EditIcon() {
|
|
|
42
44
|
)
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
function OpenExternalIcon() {
|
|
48
|
+
return (
|
|
49
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
50
|
+
<path d="M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z" />
|
|
51
|
+
</svg>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function EyeIcon() {
|
|
56
|
+
return <OcticonEye size={12} />
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function EyeClosedIcon() {
|
|
60
|
+
return <OcticonEyeClosed size={12} />
|
|
61
|
+
}
|
|
62
|
+
|
|
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
|
+
function ChevronDownIcon() {
|
|
89
|
+
return (
|
|
90
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
91
|
+
<path d="M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z" />
|
|
92
|
+
</svg>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function DownloadIcon() {
|
|
97
|
+
return (
|
|
98
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
99
|
+
<path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z" />
|
|
100
|
+
<path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06Z" />
|
|
101
|
+
</svg>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function ExpandIcon() {
|
|
106
|
+
return (
|
|
107
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
108
|
+
<path d="M1.75 10a.75.75 0 0 1 .75.75v2.5c0 .138.112.25.25.25h2.5a.75.75 0 0 1 0 1.5h-2.5A1.75 1.75 0 0 1 1 13.25v-2.5a.75.75 0 0 1 .75-.75Zm12.5 0a.75.75 0 0 1 .75.75v2.5A1.75 1.75 0 0 1 13.25 15h-2.5a.75.75 0 0 1 0-1.5h2.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 .75-.75ZM2.75 1h2.5a.75.75 0 0 1 0 1.5h-2.5a.25.25 0 0 0-.25.25v2.5a.75.75 0 0 1-1.5 0v-2.5C1 1.784 1.784 1 2.75 1Zm10.5 0C14.216 1 15 1.784 15 2.75v2.5a.75.75 0 0 1-1.5 0v-2.5a.25.25 0 0 0-.25-.25h-2.5a.75.75 0 0 1 0-1.5Z" />
|
|
109
|
+
</svg>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Icon registry — maps icon name strings from config to React components. */
|
|
114
|
+
const ICON_REGISTRY = {
|
|
115
|
+
'trash': DeleteIcon,
|
|
47
116
|
'zoom-in': ZoomInIcon,
|
|
48
117
|
'zoom-out': ZoomOutIcon,
|
|
49
118
|
'edit': EditIcon,
|
|
119
|
+
'open-external': OpenExternalIcon,
|
|
120
|
+
'eye': EyeIcon,
|
|
121
|
+
'eye-closed': EyeClosedIcon,
|
|
122
|
+
'copy': CopyIcon,
|
|
123
|
+
'link': LinkIcon,
|
|
124
|
+
'more': MoreIcon,
|
|
125
|
+
'chevron-down': ChevronDownIcon,
|
|
126
|
+
'download': DownloadIcon,
|
|
127
|
+
'expand': ExpandIcon,
|
|
50
128
|
}
|
|
51
129
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
130
|
+
/** Danger-styled actions in the overflow menu. */
|
|
131
|
+
const DANGER_ACTIONS = new Set(['delete'])
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Overflow menu — `...` button that opens a dropdown with menu-only actions.
|
|
135
|
+
*/
|
|
136
|
+
function WidgetOverflowMenu({ widgetId, menuFeatures, onAction }) {
|
|
137
|
+
const [open, setOpen] = useState(false)
|
|
138
|
+
const menuRef = useRef(null)
|
|
139
|
+
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (!open) return
|
|
142
|
+
function handlePointerDown(e) {
|
|
143
|
+
if (menuRef.current && !menuRef.current.contains(e.target)) {
|
|
144
|
+
setOpen(false)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
document.addEventListener('pointerdown', handlePointerDown)
|
|
148
|
+
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
149
|
+
}, [open])
|
|
150
|
+
|
|
151
|
+
const handleItemClick = useCallback((action, e) => {
|
|
152
|
+
e.stopPropagation()
|
|
153
|
+
if (action === 'copy-link') {
|
|
154
|
+
const url = new URL(window.location.href)
|
|
155
|
+
url.searchParams.set('widget', widgetId)
|
|
156
|
+
navigator.clipboard.writeText(url.toString()).catch(() => {})
|
|
157
|
+
} else {
|
|
158
|
+
onAction?.(action)
|
|
159
|
+
}
|
|
160
|
+
setOpen(false)
|
|
161
|
+
}, [widgetId, onAction])
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div ref={menuRef} className={styles.overflowWrapper}>
|
|
165
|
+
<Tooltip text="More actions" direction="n">
|
|
166
|
+
<button
|
|
167
|
+
className={styles.featureBtn}
|
|
168
|
+
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v) }}
|
|
169
|
+
aria-label="More actions"
|
|
170
|
+
aria-expanded={open}
|
|
171
|
+
>
|
|
172
|
+
<MoreIcon />
|
|
173
|
+
</button>
|
|
174
|
+
</Tooltip>
|
|
175
|
+
{open && (
|
|
176
|
+
<div className={styles.overflowMenu}>
|
|
177
|
+
{menuFeatures.map((feature) => {
|
|
178
|
+
const Icon = ICON_REGISTRY[feature.icon]
|
|
179
|
+
const label = feature.label || feature.action
|
|
180
|
+
const isDanger = DANGER_ACTIONS.has(feature.action)
|
|
181
|
+
return (
|
|
182
|
+
<button
|
|
183
|
+
key={feature.id}
|
|
184
|
+
className={`${styles.overflowItem} ${isDanger ? styles.overflowItemDanger : ''}`}
|
|
185
|
+
onClick={(e) => handleItemClick(feature.action, e)}
|
|
186
|
+
>
|
|
187
|
+
{Icon && <Icon />}
|
|
188
|
+
<span>{label}</span>
|
|
189
|
+
</button>
|
|
190
|
+
)
|
|
191
|
+
})}
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Dropdown feature — a chevron button that opens a menu of actions.
|
|
200
|
+
* Items and their icons/labels come from config.
|
|
201
|
+
*/
|
|
202
|
+
function DropdownFeature({ feature, onAction }) {
|
|
203
|
+
const [open, setOpen] = useState(false)
|
|
204
|
+
const menuRef = useRef(null)
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (!open) return
|
|
208
|
+
function handlePointerDown(e) {
|
|
209
|
+
if (menuRef.current && !menuRef.current.contains(e.target)) {
|
|
210
|
+
setOpen(false)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
document.addEventListener('pointerdown', handlePointerDown)
|
|
214
|
+
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
215
|
+
}, [open])
|
|
216
|
+
|
|
217
|
+
const TriggerIcon = ICON_REGISTRY[feature.icon] || ChevronDownIcon
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<div ref={menuRef} className={styles.overflowWrapper}>
|
|
221
|
+
<Tooltip text={feature.label || 'Actions'} direction="n">
|
|
222
|
+
<button
|
|
223
|
+
className={styles.featureBtn}
|
|
224
|
+
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v) }}
|
|
225
|
+
aria-label={feature.label || 'Actions'}
|
|
226
|
+
aria-expanded={open}
|
|
227
|
+
>
|
|
228
|
+
<TriggerIcon />
|
|
229
|
+
</button>
|
|
230
|
+
</Tooltip>
|
|
231
|
+
{open && (
|
|
232
|
+
<div className={styles.overflowMenu}>
|
|
233
|
+
{(feature.items || []).map((item) => {
|
|
234
|
+
const Icon = ICON_REGISTRY[item.icon]
|
|
235
|
+
return (
|
|
236
|
+
<button
|
|
237
|
+
key={item.action}
|
|
238
|
+
className={styles.overflowItem}
|
|
239
|
+
onClick={(e) => {
|
|
240
|
+
e.stopPropagation()
|
|
241
|
+
onAction?.(item.action)
|
|
242
|
+
setOpen(false)
|
|
243
|
+
}}
|
|
244
|
+
>
|
|
245
|
+
{Icon && <Icon />}
|
|
246
|
+
<span>{item.label || item.action}</span>
|
|
247
|
+
</button>
|
|
248
|
+
)
|
|
249
|
+
})}
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
)
|
|
57
254
|
}
|
|
58
255
|
|
|
59
256
|
/**
|
|
@@ -113,19 +310,21 @@ function ColorPickerFeature({ currentColor, options, onColorChange }) {
|
|
|
113
310
|
* non-standard actions (anything other than 'delete').
|
|
114
311
|
*/
|
|
115
312
|
export default function WidgetChrome({
|
|
313
|
+
widgetId,
|
|
116
314
|
features = [],
|
|
117
315
|
selected = false,
|
|
316
|
+
multiSelected = false,
|
|
118
317
|
widgetProps,
|
|
119
318
|
widgetRef,
|
|
120
319
|
onSelect,
|
|
121
|
-
onDeselect,
|
|
320
|
+
onDeselect, // eslint-disable-line no-unused-vars
|
|
122
321
|
onAction,
|
|
123
322
|
onUpdate,
|
|
124
323
|
children,
|
|
324
|
+
readOnly = false,
|
|
125
325
|
}) {
|
|
126
326
|
const [hovered, setHovered] = useState(false)
|
|
127
327
|
const leaveTimer = useRef(null)
|
|
128
|
-
const pointerStartPos = useRef(null)
|
|
129
328
|
|
|
130
329
|
const handleMouseEnter = useCallback(() => {
|
|
131
330
|
clearTimeout(leaveTimer.current)
|
|
@@ -136,30 +335,18 @@ export default function WidgetChrome({
|
|
|
136
335
|
leaveTimer.current = setTimeout(() => setHovered(false), 80)
|
|
137
336
|
}, [])
|
|
138
337
|
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const handleHandlePointerUp = useCallback((e) => {
|
|
145
|
-
if (!pointerStartPos.current) return
|
|
146
|
-
const start = pointerStartPos.current
|
|
147
|
-
pointerStartPos.current = null
|
|
148
|
-
// Only toggle selection if the pointer stayed close (click, not drag)
|
|
149
|
-
const dist = Math.hypot(e.clientX - start.x, e.clientY - start.y)
|
|
150
|
-
if (dist > 10) return
|
|
338
|
+
// Handle select via click — pointer events are intercepted by the drag
|
|
339
|
+
// gate in Draggable, so onPointerDown never reaches React on the handle.
|
|
340
|
+
// onClick fires reliably after pointer up.
|
|
341
|
+
const handleHandleClick = useCallback((e) => {
|
|
151
342
|
e.stopPropagation()
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
} else {
|
|
155
|
-
onSelect?.()
|
|
156
|
-
}
|
|
157
|
-
}, [selected, onSelect, onDeselect])
|
|
343
|
+
onSelect?.(e.shiftKey)
|
|
344
|
+
}, [onSelect])
|
|
158
345
|
|
|
159
346
|
const handleActionClick = useCallback((actionId, e) => {
|
|
160
347
|
e.stopPropagation()
|
|
161
348
|
// Standard actions go through onAction (handled by CanvasPage)
|
|
162
|
-
if (actionId === 'delete') {
|
|
349
|
+
if (actionId === 'delete' || actionId === 'copy') {
|
|
163
350
|
onAction?.(actionId)
|
|
164
351
|
return
|
|
165
352
|
}
|
|
@@ -176,15 +363,16 @@ export default function WidgetChrome({
|
|
|
176
363
|
onUpdate?.({ color })
|
|
177
364
|
}, [onUpdate])
|
|
178
365
|
|
|
179
|
-
const showToolbar = hovered || selected
|
|
366
|
+
const showToolbar = !readOnly && (hovered || selected)
|
|
367
|
+
const showFeatures = showToolbar && !multiSelected
|
|
180
368
|
|
|
181
369
|
return (
|
|
182
370
|
<div
|
|
183
371
|
className={styles.chromeContainer}
|
|
184
|
-
onMouseEnter={handleMouseEnter}
|
|
185
|
-
onMouseLeave={handleMouseLeave}
|
|
372
|
+
onMouseEnter={readOnly ? undefined : handleMouseEnter}
|
|
373
|
+
onMouseLeave={readOnly ? undefined : handleMouseLeave}
|
|
186
374
|
>
|
|
187
|
-
<div className={
|
|
375
|
+
<div className={`tc-drag-surface ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`}>
|
|
188
376
|
{children}
|
|
189
377
|
</div>
|
|
190
378
|
<div
|
|
@@ -197,8 +385,12 @@ export default function WidgetChrome({
|
|
|
197
385
|
|
|
198
386
|
{/* Toolbar content — visible on hover */}
|
|
199
387
|
<div className={`${styles.toolbarContent} ${showToolbar ? styles.toolbarContentVisible : ''}`}>
|
|
388
|
+
{showFeatures && (
|
|
200
389
|
<div className={styles.featureButtons}>
|
|
201
390
|
{features.map((feature) => {
|
|
391
|
+
// Menu features are rendered in WidgetOverflowMenu
|
|
392
|
+
if (feature.menu) return null
|
|
393
|
+
|
|
202
394
|
if (feature.type === 'color-picker') {
|
|
203
395
|
return (
|
|
204
396
|
<ColorPickerFeature
|
|
@@ -211,32 +403,66 @@ export default function WidgetChrome({
|
|
|
211
403
|
}
|
|
212
404
|
|
|
213
405
|
if (feature.type === 'action') {
|
|
214
|
-
|
|
406
|
+
let Icon = ICON_REGISTRY[feature.icon]
|
|
407
|
+
let label = feature.label || feature.action
|
|
408
|
+
|
|
409
|
+
// Toggle-private: swap icon/label based on current state
|
|
410
|
+
if (feature.action === 'toggle-private') {
|
|
411
|
+
if (widgetProps?.private) {
|
|
412
|
+
Icon = ICON_REGISTRY['eye-closed']
|
|
413
|
+
label = 'Private image — only visible locally'
|
|
414
|
+
} else {
|
|
415
|
+
label = 'Published image — deployed with canvas'
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return (
|
|
420
|
+
<Tooltip key={feature.id} text={label} direction="n">
|
|
421
|
+
<button
|
|
422
|
+
className={styles.featureBtn}
|
|
423
|
+
onClick={(e) => handleActionClick(feature.action, e)}
|
|
424
|
+
aria-label={label}
|
|
425
|
+
>
|
|
426
|
+
{Icon ? <Icon /> : feature.action}
|
|
427
|
+
</button>
|
|
428
|
+
</Tooltip>
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (feature.type === 'dropdown') {
|
|
215
433
|
return (
|
|
216
|
-
<
|
|
434
|
+
<DropdownFeature
|
|
217
435
|
key={feature.id}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
436
|
+
feature={feature}
|
|
437
|
+
onAction={(actionId) => {
|
|
438
|
+
if (widgetRef?.current?.handleAction) {
|
|
439
|
+
widgetRef.current.handleAction(actionId)
|
|
440
|
+
} else {
|
|
441
|
+
onAction?.(actionId)
|
|
442
|
+
}
|
|
443
|
+
}}
|
|
444
|
+
/>
|
|
225
445
|
)
|
|
226
446
|
}
|
|
227
447
|
|
|
228
448
|
return null
|
|
229
449
|
})}
|
|
450
|
+
<WidgetOverflowMenu
|
|
451
|
+
widgetId={widgetId}
|
|
452
|
+
menuFeatures={features.filter((f) => f.menu)}
|
|
453
|
+
onAction={onAction}
|
|
454
|
+
/>
|
|
230
455
|
</div>
|
|
456
|
+
)}
|
|
231
457
|
|
|
232
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
458
|
+
<Tooltip text={selected ? "Click and drag to move" : "Select"} direction="n">
|
|
459
|
+
<button
|
|
460
|
+
className={`tc-drag-handle ${styles.selectHandle} ${selected ? styles.selectHandleActive : ''}`}
|
|
461
|
+
onClick={handleHandleClick}
|
|
462
|
+
aria-label={selected ? "Drag to move widget" : "Select widget"}
|
|
463
|
+
aria-pressed={selected}
|
|
464
|
+
/>
|
|
465
|
+
</Tooltip>
|
|
240
466
|
</div>
|
|
241
467
|
</div>
|
|
242
468
|
</div>
|
|
@@ -11,11 +11,15 @@
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
.widgetSlotSelected {
|
|
14
|
-
outline:
|
|
14
|
+
outline: 4px solid var(--bgColor-accent-emphasis, #2f81f7);
|
|
15
15
|
outline-offset: 2px;
|
|
16
16
|
border-radius: 4px;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
.widgetSlotMultiSelected {
|
|
20
|
+
outline-style: solid;
|
|
21
|
+
}
|
|
22
|
+
|
|
19
23
|
/* Toolbar — absolutely positioned below the widget so it doesn't affect
|
|
20
24
|
the draggable box dimensions (tiny-canvas measures children for drag). */
|
|
21
25
|
.toolbar {
|
|
@@ -26,7 +30,7 @@
|
|
|
26
30
|
position: absolute;
|
|
27
31
|
left: 0;
|
|
28
32
|
right: 0;
|
|
29
|
-
top: calc(100% +
|
|
33
|
+
top: calc(100% + 10px);
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
/* Trigger dot — centered, visible at rest */
|
|
@@ -57,7 +61,7 @@
|
|
|
57
61
|
.toolbarContent {
|
|
58
62
|
display: flex;
|
|
59
63
|
align-items: center;
|
|
60
|
-
justify-content:
|
|
64
|
+
justify-content: flex-start;
|
|
61
65
|
width: 100%;
|
|
62
66
|
opacity: 0;
|
|
63
67
|
pointer-events: none;
|
|
@@ -115,13 +119,14 @@
|
|
|
115
119
|
.selectHandle {
|
|
116
120
|
all: unset;
|
|
117
121
|
cursor: grab;
|
|
118
|
-
width:
|
|
119
|
-
height:
|
|
122
|
+
width: 16px;
|
|
123
|
+
height: 16px;
|
|
120
124
|
border-radius: 4px;
|
|
121
125
|
border: 1.6px solid var(--borderColor-muted, #d0d7de);
|
|
122
126
|
background: var(--bgColor-default, #ffffff);
|
|
123
127
|
transition: background 100ms, border-color 100ms;
|
|
124
128
|
flex-shrink: 0;
|
|
129
|
+
margin-left: auto;
|
|
125
130
|
}
|
|
126
131
|
|
|
127
132
|
:global([data-sb-canvas-theme^='dark']) .selectHandle {
|
|
@@ -133,12 +138,14 @@
|
|
|
133
138
|
border-color: var(--bgColor-accent-emphasis, #2f81f7);
|
|
134
139
|
}
|
|
135
140
|
|
|
136
|
-
.selectHandleActive
|
|
141
|
+
.selectHandleActive,
|
|
142
|
+
:global([data-sb-canvas-theme^='dark']) .selectHandleActive {
|
|
137
143
|
background: var(--bgColor-accent-emphasis, #2f81f7);
|
|
138
144
|
border-color: var(--bgColor-accent-emphasis, #2f81f7);
|
|
139
145
|
}
|
|
140
146
|
|
|
141
|
-
.selectHandleActive:hover
|
|
147
|
+
.selectHandleActive:hover,
|
|
148
|
+
:global([data-sb-canvas-theme^='dark']) .selectHandleActive:hover {
|
|
142
149
|
background: var(--bgColor-accent-emphasis, #388bfd);
|
|
143
150
|
border-color: var(--bgColor-accent-emphasis, #388bfd);
|
|
144
151
|
}
|
|
@@ -159,9 +166,8 @@
|
|
|
159
166
|
|
|
160
167
|
.colorPopup {
|
|
161
168
|
position: absolute;
|
|
162
|
-
|
|
163
|
-
left:
|
|
164
|
-
transform: translateX(-50%);
|
|
169
|
+
top: calc(100% + 2px);
|
|
170
|
+
left: -4px;
|
|
165
171
|
display: flex;
|
|
166
172
|
gap: 5px;
|
|
167
173
|
padding: 6px 10px;
|
|
@@ -177,6 +183,17 @@
|
|
|
177
183
|
white-space: nowrap;
|
|
178
184
|
}
|
|
179
185
|
|
|
186
|
+
/* Invisible bridge from the trigger button to the popup so mouse
|
|
187
|
+
travel doesn't create a gap that closes the picker. */
|
|
188
|
+
.colorPopup::before {
|
|
189
|
+
content: '';
|
|
190
|
+
position: absolute;
|
|
191
|
+
bottom: 100%;
|
|
192
|
+
left: 0;
|
|
193
|
+
right: 0;
|
|
194
|
+
height: 8px;
|
|
195
|
+
}
|
|
196
|
+
|
|
180
197
|
:global([data-sb-canvas-theme^='dark']) .colorPopup {
|
|
181
198
|
background: var(--bgColor-muted, #161b22);
|
|
182
199
|
box-shadow:
|
|
@@ -207,3 +224,67 @@
|
|
|
207
224
|
border-color: currentColor;
|
|
208
225
|
box-shadow: 0 0 0 1px currentColor;
|
|
209
226
|
}
|
|
227
|
+
|
|
228
|
+
/* Overflow menu */
|
|
229
|
+
.overflowWrapper {
|
|
230
|
+
position: relative;
|
|
231
|
+
display: flex;
|
|
232
|
+
align-items: center;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.overflowMenu {
|
|
236
|
+
position: absolute;
|
|
237
|
+
top: calc(100% + 10px);
|
|
238
|
+
right: 0;
|
|
239
|
+
min-width: 180px;
|
|
240
|
+
padding: 4px;
|
|
241
|
+
background: var(--bgColor-default, #ffffff);
|
|
242
|
+
border-radius: 10px;
|
|
243
|
+
box-shadow:
|
|
244
|
+
0 0 0 1px rgba(0, 0, 0, 0.08),
|
|
245
|
+
0 4px 12px rgba(0, 0, 0, 0.12);
|
|
246
|
+
z-index: 10;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
:global([data-sb-canvas-theme^='dark']) .overflowMenu {
|
|
250
|
+
background: var(--bgColor-muted, #161b22);
|
|
251
|
+
box-shadow:
|
|
252
|
+
0 0 0 1px rgba(255, 255, 255, 0.08),
|
|
253
|
+
0 4px 12px rgba(0, 0, 0, 0.45);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.overflowItem {
|
|
257
|
+
all: unset;
|
|
258
|
+
cursor: pointer;
|
|
259
|
+
display: flex;
|
|
260
|
+
align-items: center;
|
|
261
|
+
gap: 8px;
|
|
262
|
+
width: 100%;
|
|
263
|
+
padding: 6px 10px;
|
|
264
|
+
font-size: 12px;
|
|
265
|
+
color: var(--fgColor-default, #1f2328);
|
|
266
|
+
border-radius: 6px;
|
|
267
|
+
box-sizing: border-box;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
:global([data-sb-canvas-theme^='dark']) .overflowItem {
|
|
271
|
+
color: var(--fgColor-default, #e6edf3);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.overflowItem:hover {
|
|
275
|
+
background: var(--bgColor-neutral-muted, #eaeef2);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
:global([data-sb-canvas-theme^='dark']) .overflowItem:hover {
|
|
279
|
+
background: var(--bgColor-neutral-muted, #272c33);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.overflowItemDanger:hover {
|
|
283
|
+
background: var(--bgColor-danger-muted, #ffebe9);
|
|
284
|
+
color: var(--fgColor-danger, #d1242f);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
:global([data-sb-canvas-theme^='dark']) .overflowItemDanger:hover {
|
|
288
|
+
background: var(--bgColor-danger-muted, #490202);
|
|
289
|
+
color: var(--fgColor-danger, #f85149);
|
|
290
|
+
}
|