@dfosco/storyboard-react 3.10.0-beta.0 → 3.10.0
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 -5
- package/src/canvas/CanvasPage.jsx +216 -28
- package/src/canvas/CanvasPage.module.css +1 -5
- package/src/canvas/CanvasToolbar.jsx +2 -5
- package/src/canvas/widgets/ComponentWidget.jsx +51 -3
- package/src/canvas/widgets/ComponentWidget.module.css +18 -0
- package/src/canvas/widgets/MarkdownBlock.module.css +1 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +23 -45
- package/src/canvas/widgets/StickyNote.jsx +0 -31
- package/src/canvas/widgets/StickyNote.module.css +0 -70
- package/src/canvas/widgets/WidgetChrome.jsx +244 -0
- package/src/canvas/widgets/WidgetChrome.module.css +209 -0
- package/src/canvas/widgets/widgetConfig.js +79 -0
- package/src/canvas/widgets/widgetProps.js +13 -37
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "3.10.0
|
|
3
|
+
"version": "3.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "3.10.0
|
|
7
|
-
"@dfosco/tiny-canvas": "3.10.0
|
|
6
|
+
"@dfosco/storyboard-core": "3.10.0",
|
|
7
|
+
"@dfosco/tiny-canvas": "3.10.0",
|
|
8
8
|
"@neodrag/react": "^2.3.1",
|
|
9
9
|
"glob": "^11.0.0",
|
|
10
10
|
"jsonc-parser": "^3.3.1"
|
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
2
|
+
import { getMenuWidgetTypes } from './widgets/widgetConfig.js'
|
|
2
3
|
import styles from './CanvasControls.module.css'
|
|
3
4
|
|
|
4
5
|
const ZOOM_STEPS = [25, 50, 75, 100, 125, 150, 200]
|
|
5
6
|
export const ZOOM_MIN = ZOOM_STEPS[0]
|
|
6
7
|
export const ZOOM_MAX = ZOOM_STEPS[ZOOM_STEPS.length - 1]
|
|
7
8
|
|
|
8
|
-
const WIDGET_TYPES =
|
|
9
|
-
{ type: 'sticky-note', label: 'Sticky Note' },
|
|
10
|
-
{ type: 'markdown', label: 'Markdown' },
|
|
11
|
-
{ type: 'prototype', label: 'Prototype embed' },
|
|
12
|
-
]
|
|
9
|
+
const WIDGET_TYPES = getMenuWidgetTypes()
|
|
13
10
|
|
|
14
11
|
/**
|
|
15
12
|
* Focused canvas toolbar — bottom-left controls for zoom and widget creation.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createElement, useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { flushSync } from 'react-dom'
|
|
2
3
|
import { Canvas } from '@dfosco/tiny-canvas'
|
|
3
4
|
import '@dfosco/tiny-canvas/style.css'
|
|
4
5
|
import { useCanvas } from './useCanvas.js'
|
|
@@ -6,6 +7,8 @@ import { shouldPreventCanvasTextSelection } from './textSelection.js'
|
|
|
6
7
|
import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
|
|
7
8
|
import { getWidgetComponent } from './widgets/index.js'
|
|
8
9
|
import { schemas, getDefaults } from './widgets/widgetProps.js'
|
|
10
|
+
import { getFeatures } from './widgets/widgetConfig.js'
|
|
11
|
+
import WidgetChrome from './widgets/WidgetChrome.jsx'
|
|
9
12
|
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
10
13
|
import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi } from './canvasApi.js'
|
|
11
14
|
import styles from './CanvasPage.module.css'
|
|
@@ -15,6 +18,9 @@ const ZOOM_MAX = 200
|
|
|
15
18
|
|
|
16
19
|
const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
|
|
17
20
|
|
|
21
|
+
/** Matches branch-deploy base path prefixes like /branch--my-feature/ */
|
|
22
|
+
const BRANCH_PREFIX_RE = /^\/branch--[^/]+/
|
|
23
|
+
|
|
18
24
|
function getToolbarColorMode(theme) {
|
|
19
25
|
return String(theme || 'light').startsWith('dark') ? 'dark' : 'light'
|
|
20
26
|
}
|
|
@@ -51,12 +57,41 @@ function debounce(fn, ms) {
|
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
/**
|
|
54
|
-
* Get viewport-center coordinates for placing a new widget.
|
|
60
|
+
* Get viewport-center coordinates in canvas space for placing a new widget.
|
|
61
|
+
* Converts the visible center of the scroll container to unscaled canvas coordinates.
|
|
55
62
|
*/
|
|
56
|
-
function getViewportCenter() {
|
|
63
|
+
function getViewportCenter(scrollEl, scale) {
|
|
64
|
+
if (!scrollEl) {
|
|
65
|
+
return { x: 0, y: 0 }
|
|
66
|
+
}
|
|
67
|
+
const cx = scrollEl.scrollLeft + scrollEl.clientWidth / 2
|
|
68
|
+
const cy = scrollEl.scrollTop + scrollEl.clientHeight / 2
|
|
57
69
|
return {
|
|
58
|
-
x: Math.round(
|
|
59
|
-
y: Math.round(
|
|
70
|
+
x: Math.round(cx / scale),
|
|
71
|
+
y: Math.round(cy / scale),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Fallback sizes for widget types without explicit width/height defaults. */
|
|
76
|
+
const WIDGET_FALLBACK_SIZES = {
|
|
77
|
+
'sticky-note': { width: 180, height: 60 },
|
|
78
|
+
'markdown': { width: 360, height: 200 },
|
|
79
|
+
'prototype': { width: 800, height: 600 },
|
|
80
|
+
'link-preview': { width: 320, height: 120 },
|
|
81
|
+
'component': { width: 200, height: 150 },
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Offset a position so the widget's center (not its top-left corner)
|
|
86
|
+
* lands on the given point.
|
|
87
|
+
*/
|
|
88
|
+
function centerPositionForWidget(pos, type, props) {
|
|
89
|
+
const fallback = WIDGET_FALLBACK_SIZES[type] || { width: 200, height: 150 }
|
|
90
|
+
const w = props?.width ?? fallback.width
|
|
91
|
+
const h = props?.height ?? fallback.height
|
|
92
|
+
return {
|
|
93
|
+
x: Math.round(pos.x - w / 2),
|
|
94
|
+
y: Math.round(pos.y - h / 2),
|
|
60
95
|
}
|
|
61
96
|
}
|
|
62
97
|
|
|
@@ -65,17 +100,61 @@ function roundPosition(value) {
|
|
|
65
100
|
}
|
|
66
101
|
|
|
67
102
|
/** Renders a single JSON-defined widget by type lookup. */
|
|
68
|
-
function WidgetRenderer({ widget, onUpdate }) {
|
|
103
|
+
function WidgetRenderer({ widget, onUpdate, widgetRef }) {
|
|
69
104
|
const Component = getWidgetComponent(widget.type)
|
|
70
105
|
if (!Component) {
|
|
71
106
|
console.warn(`[canvas] Unknown widget type: ${widget.type}`)
|
|
72
107
|
return null
|
|
73
108
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
109
|
+
// Only pass ref to forwardRef-wrapped components (e.g. PrototypeEmbed)
|
|
110
|
+
const elementProps = { id: widget.id, props: widget.props, onUpdate }
|
|
111
|
+
if (Component.$$typeof === Symbol.for('react.forward_ref')) {
|
|
112
|
+
elementProps.ref = widgetRef
|
|
113
|
+
}
|
|
114
|
+
return createElement(Component, elementProps)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Wrapper for each JSON widget that holds its own ref for imperative actions.
|
|
119
|
+
* This allows WidgetChrome to dispatch actions to the widget via ref.
|
|
120
|
+
*/
|
|
121
|
+
function ChromeWrappedWidget({
|
|
122
|
+
widget,
|
|
123
|
+
selected,
|
|
124
|
+
onSelect,
|
|
125
|
+
onDeselect,
|
|
126
|
+
onUpdate,
|
|
127
|
+
onRemove,
|
|
128
|
+
}) {
|
|
129
|
+
const widgetRef = useRef(null)
|
|
130
|
+
const features = getFeatures(widget.type)
|
|
131
|
+
|
|
132
|
+
const handleAction = useCallback((actionId) => {
|
|
133
|
+
if (actionId === 'delete') {
|
|
134
|
+
onRemove(widget.id)
|
|
135
|
+
}
|
|
136
|
+
}, [widget.id, onRemove])
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<WidgetChrome
|
|
140
|
+
widgetId={widget.id}
|
|
141
|
+
widgetType={widget.type}
|
|
142
|
+
features={features}
|
|
143
|
+
selected={selected}
|
|
144
|
+
widgetProps={widget.props}
|
|
145
|
+
widgetRef={widgetRef}
|
|
146
|
+
onSelect={onSelect}
|
|
147
|
+
onDeselect={onDeselect}
|
|
148
|
+
onAction={handleAction}
|
|
149
|
+
onUpdate={(updates) => onUpdate(widget.id, updates)}
|
|
150
|
+
>
|
|
151
|
+
<WidgetRenderer
|
|
152
|
+
widget={widget}
|
|
153
|
+
onUpdate={(updates) => onUpdate(widget.id, updates)}
|
|
154
|
+
widgetRef={widgetRef}
|
|
155
|
+
/>
|
|
156
|
+
</WidgetChrome>
|
|
157
|
+
)
|
|
79
158
|
}
|
|
80
159
|
|
|
81
160
|
/**
|
|
@@ -154,6 +233,25 @@ export default function CanvasPage({ name }) {
|
|
|
154
233
|
)
|
|
155
234
|
}, [name])
|
|
156
235
|
|
|
236
|
+
const debouncedSourceSave = useRef(
|
|
237
|
+
debounce((canvasName, sources) => {
|
|
238
|
+
updateCanvas(canvasName, { sources }).catch((err) =>
|
|
239
|
+
console.error('[canvas] Failed to save sources:', err)
|
|
240
|
+
)
|
|
241
|
+
}, 2000)
|
|
242
|
+
).current
|
|
243
|
+
|
|
244
|
+
const handleSourceUpdate = useCallback((exportName, updates) => {
|
|
245
|
+
setLocalSources((prev) => {
|
|
246
|
+
const current = Array.isArray(prev) ? prev : []
|
|
247
|
+
const next = current.some((s) => s?.export === exportName)
|
|
248
|
+
? current.map((s) => (s?.export === exportName ? { ...s, ...updates } : s))
|
|
249
|
+
: [...current, { export: exportName, ...updates }]
|
|
250
|
+
debouncedSourceSave(name, next)
|
|
251
|
+
return next
|
|
252
|
+
})
|
|
253
|
+
}, [name, debouncedSourceSave])
|
|
254
|
+
|
|
157
255
|
const handleItemDragEnd = useCallback((dragId, position) => {
|
|
158
256
|
if (!dragId || !position) return
|
|
159
257
|
const rounded = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
|
|
@@ -189,6 +287,43 @@ export default function CanvasPage({ name }) {
|
|
|
189
287
|
zoomRef.current = zoom
|
|
190
288
|
}, [zoom])
|
|
191
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Zoom to a new level, anchoring on an optional client-space point.
|
|
292
|
+
* When a cursor position is provided (e.g. from a wheel event), the
|
|
293
|
+
* canvas point under the cursor stays fixed. Otherwise falls back to
|
|
294
|
+
* the viewport center.
|
|
295
|
+
*/
|
|
296
|
+
function applyZoom(newZoom, clientX, clientY) {
|
|
297
|
+
const el = scrollRef.current
|
|
298
|
+
const clampedZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom))
|
|
299
|
+
|
|
300
|
+
if (!el) {
|
|
301
|
+
setZoom(clampedZoom)
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const oldScale = zoomRef.current / 100
|
|
306
|
+
const newScale = clampedZoom / 100
|
|
307
|
+
|
|
308
|
+
// Anchor point in scroll-container space
|
|
309
|
+
const rect = el.getBoundingClientRect()
|
|
310
|
+
const useViewportCenter = clientX == null || clientY == null
|
|
311
|
+
const anchorX = useViewportCenter ? el.clientWidth / 2 : clientX - rect.left
|
|
312
|
+
const anchorY = useViewportCenter ? el.clientHeight / 2 : clientY - rect.top
|
|
313
|
+
|
|
314
|
+
// Anchor → canvas coordinate
|
|
315
|
+
const canvasX = (el.scrollLeft + anchorX) / oldScale
|
|
316
|
+
const canvasY = (el.scrollTop + anchorY) / oldScale
|
|
317
|
+
|
|
318
|
+
// Synchronous render so the DOM has the new transform before we adjust scroll
|
|
319
|
+
zoomRef.current = clampedZoom
|
|
320
|
+
flushSync(() => setZoom(clampedZoom))
|
|
321
|
+
|
|
322
|
+
// Scroll so the same canvas point stays under the anchor
|
|
323
|
+
el.scrollLeft = canvasX * newScale - anchorX
|
|
324
|
+
el.scrollTop = canvasY * newScale - anchorY
|
|
325
|
+
}
|
|
326
|
+
|
|
192
327
|
// Signal canvas mount/unmount to CoreUIBar
|
|
193
328
|
useEffect(() => {
|
|
194
329
|
window[CANVAS_BRIDGE_STATE_KEY] = { active: true, name, zoom: zoomRef.current }
|
|
@@ -213,7 +348,8 @@ export default function CanvasPage({ name }) {
|
|
|
213
348
|
// Add a widget by type — used by CanvasControls and CoreUIBar event
|
|
214
349
|
const addWidget = useCallback(async (type) => {
|
|
215
350
|
const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
|
|
216
|
-
const
|
|
351
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
352
|
+
const pos = centerPositionForWidget(center, type, defaultProps)
|
|
217
353
|
try {
|
|
218
354
|
const result = await addWidgetApi(name, {
|
|
219
355
|
type,
|
|
@@ -242,7 +378,7 @@ export default function CanvasPage({ name }) {
|
|
|
242
378
|
function handleZoom(e) {
|
|
243
379
|
const { zoom: newZoom } = e.detail
|
|
244
380
|
if (typeof newZoom === 'number') {
|
|
245
|
-
|
|
381
|
+
applyZoom(newZoom)
|
|
246
382
|
}
|
|
247
383
|
}
|
|
248
384
|
document.addEventListener('storyboard:canvas:set-zoom', handleZoom)
|
|
@@ -296,7 +432,34 @@ export default function CanvasPage({ name }) {
|
|
|
296
432
|
|
|
297
433
|
// Paste handler — same-origin URLs become prototypes, other URLs become link previews, text becomes markdown
|
|
298
434
|
useEffect(() => {
|
|
299
|
-
const
|
|
435
|
+
const origin = window.location.origin
|
|
436
|
+
const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
437
|
+
const baseUrl = origin + basePath
|
|
438
|
+
|
|
439
|
+
// Check if a URL is same-origin, accounting for branch-deploy prefixes.
|
|
440
|
+
// e.g. https://site.com/branch--my-feature/Proto and https://site.com/storyboard/Proto
|
|
441
|
+
// are both same-origin prototype URLs.
|
|
442
|
+
function isSameOriginPrototype(url) {
|
|
443
|
+
if (!url.startsWith(origin)) return false
|
|
444
|
+
if (url.startsWith(baseUrl)) return true
|
|
445
|
+
// Match branch deploy URLs: origin + /branch--*/...
|
|
446
|
+
const pathAfterOrigin = url.slice(origin.length)
|
|
447
|
+
return BRANCH_PREFIX_RE.test(pathAfterOrigin)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Strip the base path (or any branch prefix) from a pathname to get a portable src.
|
|
451
|
+
function extractPrototypeSrc(pathname) {
|
|
452
|
+
// Strip current base path
|
|
453
|
+
if (basePath && pathname.startsWith(basePath)) {
|
|
454
|
+
return pathname.slice(basePath.length) || '/'
|
|
455
|
+
}
|
|
456
|
+
// Strip branch prefix: /branch--name/rest → /rest
|
|
457
|
+
const branchMatch = pathname.match(BRANCH_PREFIX_RE)
|
|
458
|
+
if (branchMatch) {
|
|
459
|
+
return pathname.slice(branchMatch[0].length) || '/'
|
|
460
|
+
}
|
|
461
|
+
return pathname
|
|
462
|
+
}
|
|
300
463
|
|
|
301
464
|
async function handlePaste(e) {
|
|
302
465
|
const tag = e.target.tagName
|
|
@@ -310,11 +473,9 @@ export default function CanvasPage({ name }) {
|
|
|
310
473
|
let type, props
|
|
311
474
|
try {
|
|
312
475
|
const parsed = new URL(text)
|
|
313
|
-
if (text
|
|
314
|
-
// Same-origin URL → prototype embed with the path portion
|
|
476
|
+
if (isSameOriginPrototype(text)) {
|
|
315
477
|
const pathPortion = parsed.pathname + parsed.search + parsed.hash
|
|
316
|
-
const
|
|
317
|
-
const src = basePath ? pathPortion.replace(new RegExp(`^${basePath}`), '') : pathPortion
|
|
478
|
+
const src = extractPrototypeSrc(pathPortion)
|
|
318
479
|
type = 'prototype'
|
|
319
480
|
props = { src: src || '/', label: '', width: 800, height: 600 }
|
|
320
481
|
} else {
|
|
@@ -326,7 +487,8 @@ export default function CanvasPage({ name }) {
|
|
|
326
487
|
props = { content: text }
|
|
327
488
|
}
|
|
328
489
|
|
|
329
|
-
const
|
|
490
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
491
|
+
const pos = centerPositionForWidget(center, type, props)
|
|
330
492
|
try {
|
|
331
493
|
const result = await addWidgetApi(name, {
|
|
332
494
|
type,
|
|
@@ -356,7 +518,7 @@ export default function CanvasPage({ name }) {
|
|
|
356
518
|
const step = Math.trunc(zoomAccum.current)
|
|
357
519
|
if (step === 0) return
|
|
358
520
|
zoomAccum.current -= step
|
|
359
|
-
|
|
521
|
+
applyZoom(zoomRef.current + step, e.clientX, e.clientY)
|
|
360
522
|
}
|
|
361
523
|
document.addEventListener('wheel', handleWheel, { passive: false })
|
|
362
524
|
return () => document.removeEventListener('wheel', handleWheel)
|
|
@@ -453,32 +615,51 @@ export default function CanvasPage({ name }) {
|
|
|
453
615
|
// Merge JSX-sourced widgets (from .canvas.jsx) and JSON widgets
|
|
454
616
|
const allChildren = []
|
|
455
617
|
|
|
456
|
-
const
|
|
618
|
+
const sourceDataByExport = Object.fromEntries(
|
|
457
619
|
(localSources || [])
|
|
458
620
|
.filter((source) => source?.export)
|
|
459
|
-
.map((source) => [source.export, source
|
|
621
|
+
.map((source) => [source.export, source])
|
|
460
622
|
)
|
|
461
623
|
|
|
462
|
-
// 1. JSX-sourced component widgets
|
|
624
|
+
// 1. JSX-sourced component widgets (wrapped in WidgetChrome, not deletable)
|
|
625
|
+
const componentFeatures = getFeatures('component')
|
|
463
626
|
if (jsxExports) {
|
|
464
627
|
for (const [exportName, Component] of Object.entries(jsxExports)) {
|
|
465
|
-
const
|
|
628
|
+
const sourceData = sourceDataByExport[exportName] || {}
|
|
629
|
+
const sourcePosition = sourceData.position || { x: 0, y: 0 }
|
|
466
630
|
allChildren.push(
|
|
467
631
|
<div
|
|
468
632
|
key={`jsx-${exportName}`}
|
|
469
633
|
id={`jsx-${exportName}`}
|
|
470
634
|
data-tc-x={sourcePosition.x}
|
|
471
635
|
data-tc-y={sourcePosition.y}
|
|
636
|
+
data-tc-handle=".tc-drag-handle"
|
|
472
637
|
{...canvasPrimerAttrs}
|
|
473
638
|
style={canvasThemeVars}
|
|
639
|
+
onClick={(e) => {
|
|
640
|
+
e.stopPropagation()
|
|
641
|
+
setSelectedWidgetId(`jsx-${exportName}`)
|
|
642
|
+
}}
|
|
474
643
|
>
|
|
475
|
-
<
|
|
644
|
+
<WidgetChrome
|
|
645
|
+
features={componentFeatures}
|
|
646
|
+
selected={selectedWidgetId === `jsx-${exportName}`}
|
|
647
|
+
onSelect={() => setSelectedWidgetId(`jsx-${exportName}`)}
|
|
648
|
+
onDeselect={() => setSelectedWidgetId(null)}
|
|
649
|
+
>
|
|
650
|
+
<ComponentWidget
|
|
651
|
+
component={Component}
|
|
652
|
+
width={sourceData.width}
|
|
653
|
+
height={sourceData.height}
|
|
654
|
+
onUpdate={(updates) => handleSourceUpdate(exportName, updates)}
|
|
655
|
+
/>
|
|
656
|
+
</WidgetChrome>
|
|
476
657
|
</div>
|
|
477
658
|
)
|
|
478
659
|
}
|
|
479
660
|
}
|
|
480
661
|
|
|
481
|
-
// 2. JSON-defined mutable widgets (selectable)
|
|
662
|
+
// 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
|
|
482
663
|
for (const widget of (localWidgets ?? [])) {
|
|
483
664
|
allChildren.push(
|
|
484
665
|
<div
|
|
@@ -486,17 +667,24 @@ export default function CanvasPage({ name }) {
|
|
|
486
667
|
id={widget.id}
|
|
487
668
|
data-tc-x={widget?.position?.x ?? 0}
|
|
488
669
|
data-tc-y={widget?.position?.y ?? 0}
|
|
670
|
+
data-tc-handle=".tc-drag-handle"
|
|
489
671
|
{...canvasPrimerAttrs}
|
|
490
672
|
style={canvasThemeVars}
|
|
491
673
|
onClick={(e) => {
|
|
492
674
|
e.stopPropagation()
|
|
493
675
|
setSelectedWidgetId(widget.id)
|
|
494
676
|
}}
|
|
495
|
-
className={selectedWidgetId === widget.id ? styles.selected : undefined}
|
|
496
677
|
>
|
|
497
|
-
<
|
|
678
|
+
<ChromeWrappedWidget
|
|
498
679
|
widget={widget}
|
|
499
|
-
|
|
680
|
+
selected={selectedWidgetId === widget.id}
|
|
681
|
+
onSelect={() => setSelectedWidgetId(widget.id)}
|
|
682
|
+
onDeselect={() => setSelectedWidgetId(null)}
|
|
683
|
+
onUpdate={handleWidgetUpdate}
|
|
684
|
+
onRemove={(id) => {
|
|
685
|
+
handleWidgetRemove(id)
|
|
686
|
+
setSelectedWidgetId(null)
|
|
687
|
+
}}
|
|
500
688
|
/>
|
|
501
689
|
</div>
|
|
502
690
|
)
|
|
@@ -32,11 +32,7 @@
|
|
|
32
32
|
min-height: 100%;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
.
|
|
36
|
-
outline: 2px solid var(--bgColor-accent-emphasis, #2f81f7);
|
|
37
|
-
outline-offset: 2px;
|
|
38
|
-
border-radius: 4px;
|
|
39
|
-
}
|
|
35
|
+
/* Selection outline is now handled by WidgetChrome.module.css (.widgetSlotSelected) */
|
|
40
36
|
|
|
41
37
|
.canvasTitle {
|
|
42
38
|
position: fixed;
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
2
|
import { addWidget as addWidgetApi } from './canvasApi.js'
|
|
3
3
|
import { schemas, getDefaults } from './widgets/widgetProps.js'
|
|
4
|
+
import { getMenuWidgetTypes } from './widgets/widgetConfig.js'
|
|
4
5
|
import styles from './CanvasToolbar.module.css'
|
|
5
6
|
|
|
6
|
-
const WIDGET_TYPES =
|
|
7
|
-
{ type: 'sticky-note', label: 'Sticky Note', icon: '📝' },
|
|
8
|
-
{ type: 'markdown', label: 'Markdown', icon: '📄' },
|
|
9
|
-
{ type: 'prototype', label: 'Prototype embed', icon: '🖥️' },
|
|
10
|
-
]
|
|
7
|
+
const WIDGET_TYPES = getMenuWidgetTypes()
|
|
11
8
|
|
|
12
9
|
/**
|
|
13
10
|
* Floating toolbar for adding widgets to a canvas.
|
|
@@ -1,15 +1,63 @@
|
|
|
1
|
+
import { useRef, useCallback, useState, useEffect } from 'react'
|
|
1
2
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
3
|
+
import ResizeHandle from './ResizeHandle.jsx'
|
|
4
|
+
import styles from './ComponentWidget.module.css'
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* Renders a live JSX export from a .canvas.jsx companion file.
|
|
5
|
-
* Content is read-only (re-renders on HMR), only position
|
|
8
|
+
* Content is read-only (re-renders on HMR), only position and size are mutable.
|
|
9
|
+
* Cannot be deleted from canvas — only removed from source code.
|
|
10
|
+
*
|
|
11
|
+
* Double-click the overlay to enter interactive mode (dropdowns, buttons work).
|
|
12
|
+
* Click outside to exit interactive mode.
|
|
6
13
|
*/
|
|
7
|
-
export default function ComponentWidget({ component: Component }) {
|
|
14
|
+
export default function ComponentWidget({ component: Component, width, height, onUpdate }) {
|
|
15
|
+
const containerRef = useRef(null)
|
|
16
|
+
const [interactive, setInteractive] = useState(false)
|
|
17
|
+
|
|
18
|
+
const handleResize = useCallback((w, h) => {
|
|
19
|
+
onUpdate?.({ width: w, height: h })
|
|
20
|
+
}, [onUpdate])
|
|
21
|
+
|
|
22
|
+
const enterInteractive = useCallback(() => setInteractive(true), [])
|
|
23
|
+
|
|
24
|
+
// Exit interactive mode when clicking outside the component
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!interactive) return
|
|
27
|
+
function handlePointerDown(e) {
|
|
28
|
+
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
29
|
+
setInteractive(false)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
document.addEventListener('pointerdown', handlePointerDown)
|
|
33
|
+
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
34
|
+
}, [interactive])
|
|
35
|
+
|
|
8
36
|
if (!Component) return null
|
|
9
37
|
|
|
38
|
+
const sizeStyle = {}
|
|
39
|
+
if (typeof width === 'number') sizeStyle.width = `${width}px`
|
|
40
|
+
if (typeof height === 'number') sizeStyle.height = `${height}px`
|
|
41
|
+
|
|
10
42
|
return (
|
|
11
43
|
<WidgetWrapper>
|
|
12
|
-
<
|
|
44
|
+
<div ref={containerRef} className={styles.container} style={sizeStyle}>
|
|
45
|
+
<div className={styles.content}>
|
|
46
|
+
<Component />
|
|
47
|
+
</div>
|
|
48
|
+
{!interactive && (
|
|
49
|
+
<div
|
|
50
|
+
className={styles.interactOverlay}
|
|
51
|
+
onDoubleClick={enterInteractive}
|
|
52
|
+
/>
|
|
53
|
+
)}
|
|
54
|
+
<ResizeHandle
|
|
55
|
+
targetRef={containerRef}
|
|
56
|
+
minWidth={100}
|
|
57
|
+
minHeight={60}
|
|
58
|
+
onResize={handleResize}
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
13
61
|
</WidgetWrapper>
|
|
14
62
|
)
|
|
15
63
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
.container {
|
|
2
|
+
position: relative;
|
|
3
|
+
overflow: auto;
|
|
4
|
+
min-width: 100px;
|
|
5
|
+
min-height: 60px;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.content {
|
|
9
|
+
width: 100%;
|
|
10
|
+
height: 100%;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.interactOverlay {
|
|
14
|
+
position: absolute;
|
|
15
|
+
inset: 0;
|
|
16
|
+
z-index: 1;
|
|
17
|
+
cursor: default;
|
|
18
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
|
1
|
+
import { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'
|
|
2
2
|
import { buildPrototypeIndex } from '@dfosco/storyboard-core'
|
|
3
3
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
4
4
|
import { readProp, prototypeEmbedSchema } from './widgetProps.js'
|
|
@@ -28,7 +28,7 @@ function resolveCanvasThemeFromStorage() {
|
|
|
28
28
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export default function PrototypeEmbed({ props, onUpdate }) {
|
|
31
|
+
export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
|
|
32
32
|
const src = readProp(props, 'src', prototypeEmbedSchema)
|
|
33
33
|
const width = readProp(props, 'width', prototypeEmbedSchema)
|
|
34
34
|
const height = readProp(props, 'height', prototypeEmbedSchema)
|
|
@@ -40,9 +40,11 @@ export default function PrototypeEmbed({ props, onUpdate }) {
|
|
|
40
40
|
const rawSrc = useMemo(() => {
|
|
41
41
|
if (!src) return ''
|
|
42
42
|
if (/^https?:\/\//.test(src)) return src
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return
|
|
43
|
+
// Strip stale branch prefixes from stored src (e.g. /branch--old-feat/Page)
|
|
44
|
+
const cleaned = src.replace(/^\/branch--[^/]+/, '')
|
|
45
|
+
if (baseSegment && cleaned.startsWith(basePath)) return cleaned
|
|
46
|
+
if (baseSegment && cleaned.startsWith(baseSegment)) return `/${cleaned}`
|
|
47
|
+
return `${basePath}${cleaned}`
|
|
46
48
|
}, [src, basePath, baseSegment])
|
|
47
49
|
|
|
48
50
|
const scale = zoom / 100
|
|
@@ -179,6 +181,21 @@ export default function PrototypeEmbed({ props, onUpdate }) {
|
|
|
179
181
|
|
|
180
182
|
const enterInteractive = useCallback(() => setInteractive(true), [])
|
|
181
183
|
|
|
184
|
+
// Expose imperative action handlers for WidgetChrome
|
|
185
|
+
useImperativeHandle(ref, () => ({
|
|
186
|
+
handleAction(actionId) {
|
|
187
|
+
if (actionId === 'edit') {
|
|
188
|
+
setEditing(true)
|
|
189
|
+
} else if (actionId === 'zoom-in') {
|
|
190
|
+
const step = zoom < 75 ? 5 : 25
|
|
191
|
+
onUpdate?.({ zoom: Math.min(200, zoom + step) })
|
|
192
|
+
} else if (actionId === 'zoom-out') {
|
|
193
|
+
const step = zoom <= 75 ? 5 : 25
|
|
194
|
+
onUpdate?.({ zoom: Math.max(25, zoom - step) })
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
}), [zoom, onUpdate])
|
|
198
|
+
|
|
182
199
|
function handlePickRoute(route) {
|
|
183
200
|
onUpdate?.({ src: route })
|
|
184
201
|
setEditing(false)
|
|
@@ -320,45 +337,6 @@ export default function PrototypeEmbed({ props, onUpdate }) {
|
|
|
320
337
|
<p>Double-click to set prototype URL</p>
|
|
321
338
|
</div>
|
|
322
339
|
)}
|
|
323
|
-
{iframeSrc && !editing && (
|
|
324
|
-
<button
|
|
325
|
-
className={styles.editBtn}
|
|
326
|
-
onClick={(e) => { e.stopPropagation(); setEditing(true) }}
|
|
327
|
-
onMouseDown={(e) => e.stopPropagation()}
|
|
328
|
-
onPointerDown={(e) => e.stopPropagation()}
|
|
329
|
-
title="Edit URL"
|
|
330
|
-
aria-label="Edit prototype URL"
|
|
331
|
-
>
|
|
332
|
-
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z"/></svg>
|
|
333
|
-
</button>
|
|
334
|
-
)}
|
|
335
|
-
{iframeSrc && !editing && (
|
|
336
|
-
<div
|
|
337
|
-
className={styles.zoomBar}
|
|
338
|
-
onMouseDown={(e) => e.stopPropagation()}
|
|
339
|
-
onPointerDown={(e) => e.stopPropagation()}
|
|
340
|
-
>
|
|
341
|
-
<button
|
|
342
|
-
className={styles.zoomBtn}
|
|
343
|
-
onClick={() => {
|
|
344
|
-
const step = zoom <= 75 ? 5 : 25
|
|
345
|
-
onUpdate?.({ zoom: Math.max(25, zoom - step) })
|
|
346
|
-
}}
|
|
347
|
-
disabled={zoom <= 25}
|
|
348
|
-
aria-label="Zoom out"
|
|
349
|
-
>−</button>
|
|
350
|
-
<span className={styles.zoomLabel}>{zoom}%</span>
|
|
351
|
-
<button
|
|
352
|
-
className={styles.zoomBtn}
|
|
353
|
-
onClick={() => {
|
|
354
|
-
const step = zoom < 75 ? 5 : 25
|
|
355
|
-
onUpdate?.({ zoom: Math.min(200, zoom + step) })
|
|
356
|
-
}}
|
|
357
|
-
disabled={zoom >= 200}
|
|
358
|
-
aria-label="Zoom in"
|
|
359
|
-
>+</button>
|
|
360
|
-
</div>
|
|
361
|
-
)}
|
|
362
340
|
</div>
|
|
363
341
|
<div
|
|
364
342
|
className={styles.resizeHandle}
|
|
@@ -385,4 +363,4 @@ export default function PrototypeEmbed({ props, onUpdate }) {
|
|
|
385
363
|
/>
|
|
386
364
|
</WidgetWrapper>
|
|
387
365
|
)
|
|
388
|
-
}
|
|
366
|
+
})
|
|
@@ -37,10 +37,6 @@ export default function StickyNote({ props, onUpdate }) {
|
|
|
37
37
|
onUpdate?.({ text: e.target.value })
|
|
38
38
|
}, [onUpdate])
|
|
39
39
|
|
|
40
|
-
const handleColorChange = useCallback((newColor) => {
|
|
41
|
-
onUpdate?.({ color: newColor })
|
|
42
|
-
}, [onUpdate])
|
|
43
|
-
|
|
44
40
|
return (
|
|
45
41
|
<div className={styles.container}>
|
|
46
42
|
<article
|
|
@@ -86,33 +82,6 @@ export default function StickyNote({ props, onUpdate }) {
|
|
|
86
82
|
onResize={handleResize}
|
|
87
83
|
/>
|
|
88
84
|
</article>
|
|
89
|
-
|
|
90
|
-
{/* Color picker — dot trigger below the sticky */}
|
|
91
|
-
<div
|
|
92
|
-
className={styles.pickerArea}
|
|
93
|
-
onMouseDown={(e) => e.stopPropagation()}
|
|
94
|
-
onPointerDown={(e) => e.stopPropagation()}
|
|
95
|
-
>
|
|
96
|
-
<span
|
|
97
|
-
className={styles.pickerDot}
|
|
98
|
-
style={{ background: palette.dot }}
|
|
99
|
-
/>
|
|
100
|
-
<div className={styles.pickerPopup}>
|
|
101
|
-
{Object.entries(COLORS).map(([colorName, c]) => (
|
|
102
|
-
<button
|
|
103
|
-
key={colorName}
|
|
104
|
-
className={`${styles.colorDot} ${colorName === color ? styles.active : ''}`}
|
|
105
|
-
style={{ background: c.bg, borderColor: c.border }}
|
|
106
|
-
onClick={(e) => {
|
|
107
|
-
e.stopPropagation()
|
|
108
|
-
handleColorChange(colorName)
|
|
109
|
-
}}
|
|
110
|
-
title={colorName}
|
|
111
|
-
aria-label={`Set color to ${colorName}`}
|
|
112
|
-
/>
|
|
113
|
-
))}
|
|
114
|
-
</div>
|
|
115
|
-
</div>
|
|
116
85
|
</div>
|
|
117
86
|
)
|
|
118
87
|
}
|
|
@@ -60,73 +60,3 @@
|
|
|
60
60
|
:global([data-sb-canvas-theme^='dark']) .textarea {
|
|
61
61
|
color: color-mix(in srgb, var(--sticky-bg) 26%, #f0f6fc 74%);
|
|
62
62
|
}
|
|
63
|
-
|
|
64
|
-
/* Color picker area — sits below the sticky */
|
|
65
|
-
|
|
66
|
-
.pickerArea {
|
|
67
|
-
display: flex;
|
|
68
|
-
justify-content: center;
|
|
69
|
-
padding-top: 6px;
|
|
70
|
-
position: relative;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
.pickerDot {
|
|
74
|
-
width: 8px;
|
|
75
|
-
height: 8px;
|
|
76
|
-
border-radius: 50%;
|
|
77
|
-
opacity: 0.5;
|
|
78
|
-
transition: opacity 150ms;
|
|
79
|
-
cursor: pointer;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
.pickerPopup {
|
|
83
|
-
position: absolute;
|
|
84
|
-
top: 4px;
|
|
85
|
-
display: flex;
|
|
86
|
-
gap: 5px;
|
|
87
|
-
padding: 6px 10px;
|
|
88
|
-
background: var(--bgColor-default, #ffffff);
|
|
89
|
-
border-radius: 20px;
|
|
90
|
-
box-shadow:
|
|
91
|
-
0 0 0 1px rgba(0, 0, 0, 0.08),
|
|
92
|
-
0 4px 12px rgba(0, 0, 0, 0.12);
|
|
93
|
-
opacity: 0;
|
|
94
|
-
pointer-events: none;
|
|
95
|
-
transition: opacity 150ms;
|
|
96
|
-
z-index: 10;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
:global([data-sb-canvas-theme^='dark']) .pickerPopup {
|
|
100
|
-
background: var(--bgColor-muted, #161b22);
|
|
101
|
-
box-shadow:
|
|
102
|
-
0 0 0 1px rgba(255, 255, 255, 0.08),
|
|
103
|
-
0 4px 12px rgba(0, 0, 0, 0.45);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
.pickerArea:hover .pickerDot {
|
|
107
|
-
opacity: 0;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
.pickerArea:hover .pickerPopup {
|
|
111
|
-
opacity: 1;
|
|
112
|
-
pointer-events: auto;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
.colorDot {
|
|
116
|
-
all: unset;
|
|
117
|
-
width: 20px;
|
|
118
|
-
height: 20px;
|
|
119
|
-
border-radius: 50%;
|
|
120
|
-
border: 2px solid transparent;
|
|
121
|
-
cursor: pointer;
|
|
122
|
-
transition: transform 100ms;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
.colorDot:hover {
|
|
126
|
-
transform: scale(1.15);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
.colorDot.active {
|
|
130
|
-
border-color: var(--sticky-border);
|
|
131
|
-
box-shadow: 0 0 0 1px var(--sticky-border);
|
|
132
|
-
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from 'react'
|
|
2
|
+
import styles from './WidgetChrome.module.css'
|
|
3
|
+
|
|
4
|
+
const STICKY_NOTE_COLORS = {
|
|
5
|
+
yellow: { bg: '#fff8c5', border: '#d4a72c', dot: '#e8c846' },
|
|
6
|
+
blue: { bg: '#ddf4ff', border: '#54aeff', dot: '#74b9ff' },
|
|
7
|
+
green: { bg: '#dafbe1', border: '#4ac26b', dot: '#6dd58c' },
|
|
8
|
+
pink: { bg: '#ffebe9', border: '#ff8182', dot: '#ff9a9e' },
|
|
9
|
+
purple: { bg: '#fbefff', border: '#c297ff', dot: '#d4a8ff' },
|
|
10
|
+
orange: { bg: '#fff1e5', border: '#d18616', dot: '#e8a844' },
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function DeleteIcon() {
|
|
14
|
+
return (
|
|
15
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
16
|
+
<path d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.15l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z" />
|
|
17
|
+
</svg>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ZoomInIcon() {
|
|
22
|
+
return (
|
|
23
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
24
|
+
<path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z" />
|
|
25
|
+
</svg>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ZoomOutIcon() {
|
|
30
|
+
return (
|
|
31
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
32
|
+
<path d="M2.75 7.25h10.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5Z" />
|
|
33
|
+
</svg>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function EditIcon() {
|
|
38
|
+
return (
|
|
39
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
40
|
+
<path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z" />
|
|
41
|
+
</svg>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ACTION_ICONS = {
|
|
46
|
+
'delete': DeleteIcon,
|
|
47
|
+
'zoom-in': ZoomInIcon,
|
|
48
|
+
'zoom-out': ZoomOutIcon,
|
|
49
|
+
'edit': EditIcon,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ACTION_LABELS = {
|
|
53
|
+
'delete': 'Delete widget',
|
|
54
|
+
'zoom-in': 'Zoom in',
|
|
55
|
+
'zoom-out': 'Zoom out',
|
|
56
|
+
'edit': 'Edit',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* ColorPicker feature button — shows a dot that reveals color options on hover.
|
|
61
|
+
*/
|
|
62
|
+
function ColorPickerFeature({ currentColor, options, onColorChange }) {
|
|
63
|
+
const palette = STICKY_NOTE_COLORS[currentColor] ?? STICKY_NOTE_COLORS.yellow
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
className={styles.colorPickerWrapper}
|
|
68
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
69
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
70
|
+
>
|
|
71
|
+
<button
|
|
72
|
+
className={styles.featureBtn}
|
|
73
|
+
style={{ background: palette.dot }}
|
|
74
|
+
aria-label="Change color"
|
|
75
|
+
title="Change color"
|
|
76
|
+
>
|
|
77
|
+
<span className={styles.colorDotInner} style={{ background: palette.dot }} />
|
|
78
|
+
</button>
|
|
79
|
+
<div className={styles.colorPopup}>
|
|
80
|
+
{(options || Object.keys(STICKY_NOTE_COLORS)).map((colorName) => {
|
|
81
|
+
const c = STICKY_NOTE_COLORS[colorName]
|
|
82
|
+
if (!c) return null
|
|
83
|
+
return (
|
|
84
|
+
<button
|
|
85
|
+
key={colorName}
|
|
86
|
+
className={`${styles.colorOption} ${colorName === currentColor ? styles.colorOptionActive : ''}`}
|
|
87
|
+
style={{ background: c.bg, borderColor: c.border }}
|
|
88
|
+
onClick={(e) => {
|
|
89
|
+
e.stopPropagation()
|
|
90
|
+
onColorChange(colorName)
|
|
91
|
+
}}
|
|
92
|
+
title={colorName}
|
|
93
|
+
aria-label={`Set color to ${colorName}`}
|
|
94
|
+
/>
|
|
95
|
+
)
|
|
96
|
+
})}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* WidgetChrome — universal hover toolbar rendered below every canvas widget.
|
|
104
|
+
*
|
|
105
|
+
* Provides:
|
|
106
|
+
* - A trigger dot (visible at rest) that transitions to a toolbar on hover
|
|
107
|
+
* - Feature buttons (left) driven by widget config
|
|
108
|
+
* - A select handle (right) for selection toggling
|
|
109
|
+
*
|
|
110
|
+
* Widget components can expose imperative action handlers via a ref:
|
|
111
|
+
* useImperativeHandle(ref, () => ({ handleAction(actionId) { ... } }))
|
|
112
|
+
* WidgetChrome will call widgetRef.current.handleAction(actionId) for
|
|
113
|
+
* non-standard actions (anything other than 'delete').
|
|
114
|
+
*/
|
|
115
|
+
export default function WidgetChrome({
|
|
116
|
+
features = [],
|
|
117
|
+
selected = false,
|
|
118
|
+
widgetProps,
|
|
119
|
+
widgetRef,
|
|
120
|
+
onSelect,
|
|
121
|
+
onDeselect,
|
|
122
|
+
onAction,
|
|
123
|
+
onUpdate,
|
|
124
|
+
children,
|
|
125
|
+
}) {
|
|
126
|
+
const [hovered, setHovered] = useState(false)
|
|
127
|
+
const leaveTimer = useRef(null)
|
|
128
|
+
const pointerStartPos = useRef(null)
|
|
129
|
+
|
|
130
|
+
const handleMouseEnter = useCallback(() => {
|
|
131
|
+
clearTimeout(leaveTimer.current)
|
|
132
|
+
setHovered(true)
|
|
133
|
+
}, [])
|
|
134
|
+
|
|
135
|
+
const handleMouseLeave = useCallback(() => {
|
|
136
|
+
leaveTimer.current = setTimeout(() => setHovered(false), 80)
|
|
137
|
+
}, [])
|
|
138
|
+
|
|
139
|
+
// Track pointer position on the handle to distinguish click from drag.
|
|
140
|
+
const handleHandlePointerDown = useCallback((e) => {
|
|
141
|
+
pointerStartPos.current = { x: e.clientX, y: e.clientY }
|
|
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
|
|
151
|
+
e.stopPropagation()
|
|
152
|
+
if (selected) {
|
|
153
|
+
onDeselect?.()
|
|
154
|
+
} else {
|
|
155
|
+
onSelect?.()
|
|
156
|
+
}
|
|
157
|
+
}, [selected, onSelect, onDeselect])
|
|
158
|
+
|
|
159
|
+
const handleActionClick = useCallback((actionId, e) => {
|
|
160
|
+
e.stopPropagation()
|
|
161
|
+
// Standard actions go through onAction (handled by CanvasPage)
|
|
162
|
+
if (actionId === 'delete') {
|
|
163
|
+
onAction?.(actionId)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
// Widget-specific actions go through the widget's imperative ref
|
|
167
|
+
if (widgetRef?.current?.handleAction) {
|
|
168
|
+
widgetRef.current.handleAction(actionId)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
// Fallback to generic handler
|
|
172
|
+
onAction?.(actionId)
|
|
173
|
+
}, [onAction, widgetRef])
|
|
174
|
+
|
|
175
|
+
const handleColorChange = useCallback((color) => {
|
|
176
|
+
onUpdate?.({ color })
|
|
177
|
+
}, [onUpdate])
|
|
178
|
+
|
|
179
|
+
const showToolbar = hovered || selected
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div
|
|
183
|
+
className={styles.chromeContainer}
|
|
184
|
+
onMouseEnter={handleMouseEnter}
|
|
185
|
+
onMouseLeave={handleMouseLeave}
|
|
186
|
+
>
|
|
187
|
+
<div className={`${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''}`}>
|
|
188
|
+
{children}
|
|
189
|
+
</div>
|
|
190
|
+
<div
|
|
191
|
+
className={styles.toolbar}
|
|
192
|
+
>
|
|
193
|
+
{/* Trigger dot — visible at rest */}
|
|
194
|
+
<span
|
|
195
|
+
className={`${styles.triggerDot} ${showToolbar ? styles.triggerDotHidden : ''}`}
|
|
196
|
+
/>
|
|
197
|
+
|
|
198
|
+
{/* Toolbar content — visible on hover */}
|
|
199
|
+
<div className={`${styles.toolbarContent} ${showToolbar ? styles.toolbarContentVisible : ''}`}>
|
|
200
|
+
<div className={styles.featureButtons}>
|
|
201
|
+
{features.map((feature) => {
|
|
202
|
+
if (feature.type === 'color-picker') {
|
|
203
|
+
return (
|
|
204
|
+
<ColorPickerFeature
|
|
205
|
+
key={feature.id}
|
|
206
|
+
currentColor={widgetProps?.[feature.prop] || 'yellow'}
|
|
207
|
+
options={feature.options}
|
|
208
|
+
onColorChange={handleColorChange}
|
|
209
|
+
/>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (feature.type === 'action') {
|
|
214
|
+
const Icon = ACTION_ICONS[feature.action]
|
|
215
|
+
return (
|
|
216
|
+
<button
|
|
217
|
+
key={feature.id}
|
|
218
|
+
className={styles.featureBtn}
|
|
219
|
+
onClick={(e) => handleActionClick(feature.action, e)}
|
|
220
|
+
title={ACTION_LABELS[feature.action] || feature.action}
|
|
221
|
+
aria-label={ACTION_LABELS[feature.action] || feature.action}
|
|
222
|
+
>
|
|
223
|
+
{Icon ? <Icon /> : feature.action}
|
|
224
|
+
</button>
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return null
|
|
229
|
+
})}
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<button
|
|
233
|
+
className={`tc-drag-handle ${styles.selectHandle} ${selected ? styles.selectHandleActive : ''}`}
|
|
234
|
+
onPointerDown={handleHandlePointerDown}
|
|
235
|
+
onPointerUp={handleHandlePointerUp}
|
|
236
|
+
title={selected ? 'Deselect' : 'Select'}
|
|
237
|
+
aria-label={selected ? 'Deselect widget' : 'Select widget'}
|
|
238
|
+
aria-pressed={selected}
|
|
239
|
+
/>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
)
|
|
244
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/* WidgetChrome — universal hover toolbar for canvas widgets */
|
|
2
|
+
|
|
3
|
+
.chromeContainer {
|
|
4
|
+
position: relative;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/* Widget slot — contains the actual widget; selection outline targets this */
|
|
8
|
+
.widgetSlot {
|
|
9
|
+
position: relative;
|
|
10
|
+
border-radius: 4px;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.widgetSlotSelected {
|
|
14
|
+
outline: 2px solid var(--bgColor-accent-emphasis, #2f81f7);
|
|
15
|
+
outline-offset: 2px;
|
|
16
|
+
border-radius: 4px;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* Toolbar — absolutely positioned below the widget so it doesn't affect
|
|
20
|
+
the draggable box dimensions (tiny-canvas measures children for drag). */
|
|
21
|
+
.toolbar {
|
|
22
|
+
display: flex;
|
|
23
|
+
align-items: center;
|
|
24
|
+
justify-content: center;
|
|
25
|
+
height: 28px;
|
|
26
|
+
position: absolute;
|
|
27
|
+
left: 0;
|
|
28
|
+
right: 0;
|
|
29
|
+
top: calc(100% + 4px);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Trigger dot — centered, visible at rest */
|
|
33
|
+
.triggerDot {
|
|
34
|
+
width: 6px;
|
|
35
|
+
height: 6px;
|
|
36
|
+
border-radius: 50%;
|
|
37
|
+
background: var(--borderColor-muted, #d0d7de);
|
|
38
|
+
opacity: 0.5;
|
|
39
|
+
transition: opacity 120ms;
|
|
40
|
+
position: absolute;
|
|
41
|
+
left: 50%;
|
|
42
|
+
top: 50%;
|
|
43
|
+
transform: translate(-50%, -50%);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
:global([data-sb-canvas-theme^='dark']) .triggerDot {
|
|
47
|
+
background: var(--borderColor-muted, #373e47);
|
|
48
|
+
opacity: 0.6;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.triggerDotHidden {
|
|
52
|
+
opacity: 0;
|
|
53
|
+
pointer-events: none;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Toolbar content — feature buttons + select handle */
|
|
57
|
+
.toolbarContent {
|
|
58
|
+
display: flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
justify-content: space-between;
|
|
61
|
+
width: 100%;
|
|
62
|
+
opacity: 0;
|
|
63
|
+
pointer-events: none;
|
|
64
|
+
transition: opacity 120ms;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.toolbarContentVisible {
|
|
68
|
+
opacity: 1;
|
|
69
|
+
pointer-events: auto;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* Feature buttons — left-aligned group */
|
|
73
|
+
.featureButtons {
|
|
74
|
+
display: flex;
|
|
75
|
+
align-items: center;
|
|
76
|
+
gap: 3px;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* Individual feature button */
|
|
80
|
+
.featureBtn {
|
|
81
|
+
all: unset;
|
|
82
|
+
cursor: pointer;
|
|
83
|
+
display: flex;
|
|
84
|
+
align-items: center;
|
|
85
|
+
justify-content: center;
|
|
86
|
+
width: 24px;
|
|
87
|
+
height: 24px;
|
|
88
|
+
border-radius: 12px;
|
|
89
|
+
border: 1.6px solid var(--borderColor-muted, #d0d7de);
|
|
90
|
+
background: var(--bgColor-default, #ffffff);
|
|
91
|
+
color: var(--fgColor-muted, #656d76);
|
|
92
|
+
font-size: 12px;
|
|
93
|
+
transition: background 100ms, color 100ms, border-color 100ms;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
:global([data-sb-canvas-theme^='dark']) .featureBtn {
|
|
97
|
+
background: var(--bgColor-muted, #161b22);
|
|
98
|
+
border-color: var(--borderColor-muted, #373e47);
|
|
99
|
+
color: var(--fgColor-muted, #8b949e);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.featureBtn:hover {
|
|
103
|
+
background: var(--bgColor-neutral-muted, #eaeef2);
|
|
104
|
+
color: var(--fgColor-default, #1f2328);
|
|
105
|
+
border-color: var(--borderColor-default, #d0d7de);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
:global([data-sb-canvas-theme^='dark']) .featureBtn:hover {
|
|
109
|
+
background: var(--bgColor-neutral-muted, #272c33);
|
|
110
|
+
color: var(--fgColor-default, #e6edf3);
|
|
111
|
+
border-color: var(--borderColor-default, #484f58);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* Select handle — right-aligned rounded rect */
|
|
115
|
+
.selectHandle {
|
|
116
|
+
all: unset;
|
|
117
|
+
cursor: grab;
|
|
118
|
+
width: 18px;
|
|
119
|
+
height: 12px;
|
|
120
|
+
border-radius: 4px;
|
|
121
|
+
border: 1.6px solid var(--borderColor-muted, #d0d7de);
|
|
122
|
+
background: var(--bgColor-default, #ffffff);
|
|
123
|
+
transition: background 100ms, border-color 100ms;
|
|
124
|
+
flex-shrink: 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
:global([data-sb-canvas-theme^='dark']) .selectHandle {
|
|
128
|
+
background: var(--bgColor-muted, #161b22);
|
|
129
|
+
border-color: var(--borderColor-muted, #373e47);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.selectHandle:hover {
|
|
133
|
+
border-color: var(--bgColor-accent-emphasis, #2f81f7);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.selectHandleActive {
|
|
137
|
+
background: var(--bgColor-accent-emphasis, #2f81f7);
|
|
138
|
+
border-color: var(--bgColor-accent-emphasis, #2f81f7);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.selectHandleActive:hover {
|
|
142
|
+
background: var(--bgColor-accent-emphasis, #388bfd);
|
|
143
|
+
border-color: var(--bgColor-accent-emphasis, #388bfd);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* Color picker feature */
|
|
147
|
+
.colorPickerWrapper {
|
|
148
|
+
position: relative;
|
|
149
|
+
display: flex;
|
|
150
|
+
align-items: center;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.colorDotInner {
|
|
154
|
+
width: 10px;
|
|
155
|
+
height: 10px;
|
|
156
|
+
border-radius: 50%;
|
|
157
|
+
display: block;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.colorPopup {
|
|
161
|
+
position: absolute;
|
|
162
|
+
bottom: calc(100% + 6px);
|
|
163
|
+
left: 50%;
|
|
164
|
+
transform: translateX(-50%);
|
|
165
|
+
display: flex;
|
|
166
|
+
gap: 5px;
|
|
167
|
+
padding: 6px 10px;
|
|
168
|
+
background: var(--bgColor-default, #ffffff);
|
|
169
|
+
border-radius: 20px;
|
|
170
|
+
box-shadow:
|
|
171
|
+
0 0 0 1px rgba(0, 0, 0, 0.08),
|
|
172
|
+
0 4px 12px rgba(0, 0, 0, 0.12);
|
|
173
|
+
opacity: 0;
|
|
174
|
+
pointer-events: none;
|
|
175
|
+
transition: opacity 150ms;
|
|
176
|
+
z-index: 10;
|
|
177
|
+
white-space: nowrap;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
:global([data-sb-canvas-theme^='dark']) .colorPopup {
|
|
181
|
+
background: var(--bgColor-muted, #161b22);
|
|
182
|
+
box-shadow:
|
|
183
|
+
0 0 0 1px rgba(255, 255, 255, 0.08),
|
|
184
|
+
0 4px 12px rgba(0, 0, 0, 0.45);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.colorPickerWrapper:hover .colorPopup {
|
|
188
|
+
opacity: 1;
|
|
189
|
+
pointer-events: auto;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.colorOption {
|
|
193
|
+
all: unset;
|
|
194
|
+
width: 20px;
|
|
195
|
+
height: 20px;
|
|
196
|
+
border-radius: 50%;
|
|
197
|
+
border: 2px solid transparent;
|
|
198
|
+
cursor: pointer;
|
|
199
|
+
transition: transform 100ms;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.colorOption:hover {
|
|
203
|
+
transform: scale(1.15);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.colorOptionActive {
|
|
207
|
+
border-color: currentColor;
|
|
208
|
+
box-shadow: 0 0 0 1px currentColor;
|
|
209
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget Config Loader
|
|
3
|
+
*
|
|
4
|
+
* Reads widgets.config.json from @dfosco/storyboard-core and builds
|
|
5
|
+
* schema objects compatible with the existing readProp/readAllProps/getDefaults API.
|
|
6
|
+
*
|
|
7
|
+
* The config is the single source of truth for widget definitions —
|
|
8
|
+
* prop schemas, feature lists, labels, and icons all come from here.
|
|
9
|
+
*/
|
|
10
|
+
import widgetsConfig from '@dfosco/storyboard-core/widgets.config.json'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Convert a config prop definition to the schema shape used by widgetProps.js.
|
|
14
|
+
* Config uses `"default"`, schema uses `"defaultValue"`.
|
|
15
|
+
*/
|
|
16
|
+
function configPropToSchema(propDef) {
|
|
17
|
+
const schema = {
|
|
18
|
+
type: propDef.type,
|
|
19
|
+
label: propDef.label,
|
|
20
|
+
category: propDef.category,
|
|
21
|
+
}
|
|
22
|
+
if (propDef.default !== undefined) schema.defaultValue = propDef.default
|
|
23
|
+
if (propDef.options) schema.options = propDef.options
|
|
24
|
+
if (propDef.min !== undefined) schema.min = propDef.min
|
|
25
|
+
if (propDef.max !== undefined) schema.max = propDef.max
|
|
26
|
+
return schema
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build schema objects for all widget types from the config.
|
|
31
|
+
* Returns the same shape as the old hardcoded schemas in widgetProps.js.
|
|
32
|
+
*/
|
|
33
|
+
function buildSchemas() {
|
|
34
|
+
const result = {}
|
|
35
|
+
for (const [type, def] of Object.entries(widgetsConfig.widgets)) {
|
|
36
|
+
const schema = {}
|
|
37
|
+
for (const [key, propDef] of Object.entries(def.props || {})) {
|
|
38
|
+
schema[key] = configPropToSchema(propDef)
|
|
39
|
+
}
|
|
40
|
+
result[type] = schema
|
|
41
|
+
}
|
|
42
|
+
return result
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** All widget schemas, keyed by type string. */
|
|
46
|
+
export const schemas = buildSchemas()
|
|
47
|
+
|
|
48
|
+
/** Full widget config entries, keyed by type string. */
|
|
49
|
+
export const widgetTypes = widgetsConfig.widgets
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get the feature list for a widget type.
|
|
53
|
+
* @param {string} type — widget type string
|
|
54
|
+
* @returns {Array} features array from config, or empty array
|
|
55
|
+
*/
|
|
56
|
+
export function getFeatures(type) {
|
|
57
|
+
return widgetTypes[type]?.features ?? []
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get the display metadata (label, icon) for a widget type.
|
|
62
|
+
* @param {string} type — widget type string
|
|
63
|
+
* @returns {{ label: string, icon: string } | null}
|
|
64
|
+
*/
|
|
65
|
+
export function getWidgetMeta(type) {
|
|
66
|
+
const def = widgetTypes[type]
|
|
67
|
+
if (!def) return null
|
|
68
|
+
return { label: def.label, icon: def.icon }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get all widget types as an array of { type, label, icon } for menus.
|
|
73
|
+
* Excludes link-preview which is created via paste only.
|
|
74
|
+
*/
|
|
75
|
+
export function getMenuWidgetTypes() {
|
|
76
|
+
return Object.entries(widgetTypes)
|
|
77
|
+
.filter(([type]) => type !== 'link-preview')
|
|
78
|
+
.map(([type, def]) => ({ type, label: def.label, icon: def.icon }))
|
|
79
|
+
}
|
|
@@ -54,8 +54,9 @@
|
|
|
54
54
|
*
|
|
55
55
|
* ## Declaring Widget Props (Schema)
|
|
56
56
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
57
|
+
* Widget prop schemas are defined in widgets.config.json (packages/core)
|
|
58
|
+
* and loaded via widgetConfig.js. This module re-exports the generated
|
|
59
|
+
* schemas and provides utility functions for reading props with defaults.
|
|
59
60
|
*/
|
|
60
61
|
|
|
61
62
|
/**
|
|
@@ -71,6 +72,8 @@
|
|
|
71
72
|
* @property {number} [max] — maximum for 'number' type
|
|
72
73
|
*/
|
|
73
74
|
|
|
75
|
+
import { schemas as configSchemas } from './widgetConfig.js'
|
|
76
|
+
|
|
74
77
|
/**
|
|
75
78
|
* Read a prop value with fallback to schema default.
|
|
76
79
|
* @param {object} props — widget props object (may be null)
|
|
@@ -114,40 +117,13 @@ export function getDefaults(schema) {
|
|
|
114
117
|
return result
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
// ──
|
|
118
|
-
|
|
119
|
-
export const stickyNoteSchema = {
|
|
120
|
-
text: { type: 'text', label: 'Text', category: 'content', defaultValue: '' },
|
|
121
|
-
color: { type: 'select', label: 'Color', category: 'settings', defaultValue: 'yellow',
|
|
122
|
-
options: ['yellow', 'blue', 'green', 'pink', 'purple', 'orange'] },
|
|
123
|
-
width: { type: 'number', label: 'Width', category: 'size', min: 180 },
|
|
124
|
-
height: { type: 'number', label: 'Height', category: 'size', min: 60 },
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export const markdownSchema = {
|
|
128
|
-
content: { type: 'text', label: 'Content', category: 'content', defaultValue: '' },
|
|
129
|
-
width: { type: 'number', label: 'Width', category: 'size', defaultValue: 360, min: 200, max: 1200 },
|
|
130
|
-
}
|
|
120
|
+
// ── Config-driven schemas ───────────────────────────────────────────
|
|
131
121
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
label: { type: 'text', label: 'Label', category: 'settings', defaultValue: '' },
|
|
135
|
-
zoom: { type: 'number', label: 'Zoom', category: 'settings', defaultValue: 100, min: 25, max: 200 },
|
|
136
|
-
width: { type: 'number', label: 'Width', category: 'size', defaultValue: 800, min: 200, max: 2000 },
|
|
137
|
-
height: { type: 'number', label: 'Height', category: 'size', defaultValue: 600, min: 200, max: 1500 },
|
|
138
|
-
}
|
|
122
|
+
/** Schema registry — maps widget type strings to their schemas. */
|
|
123
|
+
export const schemas = configSchemas
|
|
139
124
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Schema registry — maps widget type strings to their schemas.
|
|
147
|
-
*/
|
|
148
|
-
export const schemas = {
|
|
149
|
-
'sticky-note': stickyNoteSchema,
|
|
150
|
-
'markdown': markdownSchema,
|
|
151
|
-
'prototype': prototypeEmbedSchema,
|
|
152
|
-
'link-preview': linkPreviewSchema,
|
|
153
|
-
}
|
|
125
|
+
// Named exports for backward compatibility with widget imports
|
|
126
|
+
export const stickyNoteSchema = schemas['sticky-note']
|
|
127
|
+
export const markdownSchema = schemas['markdown']
|
|
128
|
+
export const prototypeEmbedSchema = schemas['prototype']
|
|
129
|
+
export const linkPreviewSchema = schemas['link-preview']
|