@dfosco/storyboard-react 3.0.0 → 3.1.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 +123 -0
- package/src/canvas/CanvasControls.module.css +133 -0
- package/src/canvas/CanvasPage.jsx +306 -18
- package/src/canvas/CanvasPage.module.css +58 -0
- package/src/canvas/widgets/LinkPreview.jsx +34 -0
- package/src/canvas/widgets/LinkPreview.module.css +51 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +8 -4
- package/src/canvas/widgets/PrototypeEmbed.jsx +160 -14
- package/src/canvas/widgets/PrototypeEmbed.module.css +217 -1
- package/src/canvas/widgets/StickyNote.jsx +12 -20
- package/src/canvas/widgets/StickyNote.module.css +14 -39
- package/src/canvas/widgets/WidgetWrapper.jsx +2 -15
- package/src/canvas/widgets/WidgetWrapper.module.css +0 -30
- package/src/canvas/widgets/index.js +3 -1
- package/src/canvas/widgets/widgetProps.js +7 -0
- package/src/vite/data-plugin.js +9 -7
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "3.
|
|
7
|
-
"@dfosco/tiny-canvas": "
|
|
6
|
+
"@dfosco/storyboard-core": "3.1.0",
|
|
7
|
+
"@dfosco/tiny-canvas": "^1.1.0",
|
|
8
8
|
"@neodrag/react": "^2.3.1",
|
|
9
9
|
"glob": "^11.0.0",
|
|
10
10
|
"jsonc-parser": "^3.3.1"
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
2
|
+
import styles from './CanvasControls.module.css'
|
|
3
|
+
|
|
4
|
+
const ZOOM_STEPS = [25, 50, 75, 100, 125, 150, 200]
|
|
5
|
+
export const ZOOM_MIN = ZOOM_STEPS[0]
|
|
6
|
+
export const ZOOM_MAX = ZOOM_STEPS[ZOOM_STEPS.length - 1]
|
|
7
|
+
|
|
8
|
+
const WIDGET_TYPES = [
|
|
9
|
+
{ type: 'sticky-note', label: 'Sticky Note' },
|
|
10
|
+
{ type: 'markdown', label: 'Markdown' },
|
|
11
|
+
{ type: 'prototype', label: 'Prototype' },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Focused canvas toolbar — bottom-left controls for zoom and widget creation.
|
|
16
|
+
*/
|
|
17
|
+
export default function CanvasControls({ zoom, onZoomChange, onAddWidget }) {
|
|
18
|
+
const [menuOpen, setMenuOpen] = useState(false)
|
|
19
|
+
const menuRef = useRef(null)
|
|
20
|
+
|
|
21
|
+
// Close menu on outside click
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!menuOpen) return
|
|
24
|
+
function handlePointerDown(e) {
|
|
25
|
+
if (menuRef.current && !menuRef.current.contains(e.target)) {
|
|
26
|
+
setMenuOpen(false)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
document.addEventListener('pointerdown', handlePointerDown)
|
|
30
|
+
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
31
|
+
}, [menuOpen])
|
|
32
|
+
|
|
33
|
+
const zoomIn = useCallback(() => {
|
|
34
|
+
onZoomChange((z) => {
|
|
35
|
+
const next = ZOOM_STEPS.find((s) => s > z)
|
|
36
|
+
return next ?? ZOOM_MAX
|
|
37
|
+
})
|
|
38
|
+
}, [onZoomChange])
|
|
39
|
+
|
|
40
|
+
const zoomOut = useCallback(() => {
|
|
41
|
+
onZoomChange((z) => {
|
|
42
|
+
const next = [...ZOOM_STEPS].reverse().find((s) => s < z)
|
|
43
|
+
return next ?? ZOOM_MIN
|
|
44
|
+
})
|
|
45
|
+
}, [onZoomChange])
|
|
46
|
+
|
|
47
|
+
const resetZoom = useCallback(() => {
|
|
48
|
+
onZoomChange(100)
|
|
49
|
+
}, [onZoomChange])
|
|
50
|
+
|
|
51
|
+
const handleAddWidget = useCallback((type) => {
|
|
52
|
+
onAddWidget(type)
|
|
53
|
+
setMenuOpen(false)
|
|
54
|
+
}, [onAddWidget])
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className={styles.toolbar} role="toolbar" aria-label="Canvas controls">
|
|
58
|
+
{/* Create widget */}
|
|
59
|
+
<div ref={menuRef} className={styles.createGroup}>
|
|
60
|
+
<button
|
|
61
|
+
className={styles.btn}
|
|
62
|
+
onClick={() => setMenuOpen((v) => !v)}
|
|
63
|
+
aria-label="Add widget"
|
|
64
|
+
aria-expanded={menuOpen}
|
|
65
|
+
title="Add widget"
|
|
66
|
+
>
|
|
67
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
68
|
+
<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" />
|
|
69
|
+
</svg>
|
|
70
|
+
</button>
|
|
71
|
+
{menuOpen && (
|
|
72
|
+
<div className={styles.menu} role="menu">
|
|
73
|
+
<div className={styles.menuLabel}>Add to canvas</div>
|
|
74
|
+
{WIDGET_TYPES.map((wt) => (
|
|
75
|
+
<button
|
|
76
|
+
key={wt.type}
|
|
77
|
+
className={styles.menuItem}
|
|
78
|
+
role="menuitem"
|
|
79
|
+
onClick={() => handleAddWidget(wt.type)}
|
|
80
|
+
>
|
|
81
|
+
{wt.label}
|
|
82
|
+
</button>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<div className={styles.divider} />
|
|
89
|
+
|
|
90
|
+
{/* Zoom controls */}
|
|
91
|
+
<button
|
|
92
|
+
className={styles.btn}
|
|
93
|
+
onClick={zoomOut}
|
|
94
|
+
disabled={zoom <= ZOOM_MIN}
|
|
95
|
+
aria-label="Zoom out"
|
|
96
|
+
title="Zoom out"
|
|
97
|
+
>
|
|
98
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
99
|
+
<path d="M2.75 7.25h10.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5Z" />
|
|
100
|
+
</svg>
|
|
101
|
+
</button>
|
|
102
|
+
<button
|
|
103
|
+
className={styles.zoomLevel}
|
|
104
|
+
onClick={resetZoom}
|
|
105
|
+
title="Reset to 100%"
|
|
106
|
+
aria-label={`Zoom ${zoom}%, click to reset`}
|
|
107
|
+
>
|
|
108
|
+
{zoom}%
|
|
109
|
+
</button>
|
|
110
|
+
<button
|
|
111
|
+
className={styles.btn}
|
|
112
|
+
onClick={zoomIn}
|
|
113
|
+
disabled={zoom >= ZOOM_MAX}
|
|
114
|
+
aria-label="Zoom in"
|
|
115
|
+
title="Zoom in"
|
|
116
|
+
>
|
|
117
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
118
|
+
<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" />
|
|
119
|
+
</svg>
|
|
120
|
+
</button>
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
.toolbar {
|
|
2
|
+
position: fixed;
|
|
3
|
+
bottom: 24px;
|
|
4
|
+
left: 24px;
|
|
5
|
+
z-index: 9998;
|
|
6
|
+
display: flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
gap: 2px;
|
|
9
|
+
padding: 4px;
|
|
10
|
+
background: rgba(255, 255, 255, 0.92);
|
|
11
|
+
backdrop-filter: blur(12px);
|
|
12
|
+
-webkit-backdrop-filter: blur(12px);
|
|
13
|
+
border: 1px solid rgba(0, 0, 0, 0.12);
|
|
14
|
+
border-radius: 10px;
|
|
15
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
|
16
|
+
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@media (prefers-color-scheme: dark) {
|
|
20
|
+
.toolbar {
|
|
21
|
+
background: rgba(22, 27, 34, 0.88);
|
|
22
|
+
border-color: rgba(255, 255, 255, 0.1);
|
|
23
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.btn {
|
|
28
|
+
all: unset;
|
|
29
|
+
cursor: pointer;
|
|
30
|
+
display: flex;
|
|
31
|
+
align-items: center;
|
|
32
|
+
justify-content: center;
|
|
33
|
+
width: 32px;
|
|
34
|
+
height: 32px;
|
|
35
|
+
border-radius: 8px;
|
|
36
|
+
color: var(--fgColor-default, #1f2328);
|
|
37
|
+
transition: background 120ms;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.btn:hover:not(:disabled) {
|
|
41
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.btn:active:not(:disabled) {
|
|
45
|
+
background: var(--bgColor-neutral-muted, #eaeef2);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.btn:disabled {
|
|
49
|
+
opacity: 0.35;
|
|
50
|
+
cursor: default;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.zoomLevel {
|
|
54
|
+
all: unset;
|
|
55
|
+
cursor: pointer;
|
|
56
|
+
display: flex;
|
|
57
|
+
align-items: center;
|
|
58
|
+
justify-content: center;
|
|
59
|
+
min-width: 44px;
|
|
60
|
+
height: 32px;
|
|
61
|
+
padding: 0 4px;
|
|
62
|
+
border-radius: 8px;
|
|
63
|
+
font-size: 12px;
|
|
64
|
+
font-weight: 500;
|
|
65
|
+
font-variant-numeric: tabular-nums;
|
|
66
|
+
color: var(--fgColor-muted, #656d76);
|
|
67
|
+
transition: background 120ms;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.zoomLevel:hover {
|
|
71
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
72
|
+
color: var(--fgColor-default, #1f2328);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.divider {
|
|
76
|
+
width: 1px;
|
|
77
|
+
height: 20px;
|
|
78
|
+
margin: 0 2px;
|
|
79
|
+
background: var(--borderColor-muted, #d8dee4);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* Create widget menu */
|
|
83
|
+
.createGroup {
|
|
84
|
+
position: relative;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.menu {
|
|
88
|
+
position: absolute;
|
|
89
|
+
bottom: calc(100% + 8px);
|
|
90
|
+
left: 0;
|
|
91
|
+
min-width: 160px;
|
|
92
|
+
padding: 4px;
|
|
93
|
+
background: rgba(255, 255, 255, 0.95);
|
|
94
|
+
backdrop-filter: blur(12px);
|
|
95
|
+
-webkit-backdrop-filter: blur(12px);
|
|
96
|
+
border: 1px solid rgba(0, 0, 0, 0.12);
|
|
97
|
+
border-radius: 10px;
|
|
98
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
|
99
|
+
z-index: 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@media (prefers-color-scheme: dark) {
|
|
103
|
+
.menu {
|
|
104
|
+
background: rgba(22, 27, 34, 0.92);
|
|
105
|
+
border-color: rgba(255, 255, 255, 0.1);
|
|
106
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.menuLabel {
|
|
111
|
+
padding: 6px 10px 4px;
|
|
112
|
+
font-size: 11px;
|
|
113
|
+
font-weight: 600;
|
|
114
|
+
color: var(--fgColor-muted, #656d76);
|
|
115
|
+
text-transform: uppercase;
|
|
116
|
+
letter-spacing: 0.4px;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.menuItem {
|
|
120
|
+
all: unset;
|
|
121
|
+
cursor: pointer;
|
|
122
|
+
display: block;
|
|
123
|
+
width: 100%;
|
|
124
|
+
padding: 6px 10px;
|
|
125
|
+
font-size: 13px;
|
|
126
|
+
color: var(--fgColor-default, #1f2328);
|
|
127
|
+
border-radius: 6px;
|
|
128
|
+
box-sizing: border-box;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.menuItem:hover {
|
|
132
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
133
|
+
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import { createElement, useCallback, useRef, useState } from 'react'
|
|
1
|
+
import { createElement, useCallback, useEffect, useRef, useState } from 'react'
|
|
2
2
|
import { Canvas } from '@dfosco/tiny-canvas'
|
|
3
3
|
import { useCanvas } from './useCanvas.js'
|
|
4
4
|
import { getWidgetComponent } from './widgets/index.js'
|
|
5
|
+
import { schemas, getDefaults } from './widgets/widgetProps.js'
|
|
5
6
|
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
6
|
-
import
|
|
7
|
-
import { updateCanvas, removeWidget as removeWidgetApi } from './canvasApi.js'
|
|
7
|
+
import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi } from './canvasApi.js'
|
|
8
8
|
import styles from './CanvasPage.module.css'
|
|
9
9
|
|
|
10
|
+
const ZOOM_MIN = 25
|
|
11
|
+
const ZOOM_MAX = 200
|
|
12
|
+
|
|
10
13
|
/**
|
|
11
14
|
* Debounce helper — returns a function that delays invocation.
|
|
12
15
|
*/
|
|
@@ -18,8 +21,33 @@ function debounce(fn, ms) {
|
|
|
18
21
|
}
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Save a drag position to localStorage so tiny-canvas picks it up on render.
|
|
26
|
+
*/
|
|
27
|
+
function saveWidgetPosition(widgetId, x, y) {
|
|
28
|
+
try {
|
|
29
|
+
const queue = JSON.parse(localStorage.getItem('tiny-canvas-queue')) || []
|
|
30
|
+
const now = new Date().toISOString().replace(/[:.]/g, '-')
|
|
31
|
+
const entry = { id: widgetId, x, y, time: now }
|
|
32
|
+
const idx = queue.findIndex((item) => item.id === widgetId)
|
|
33
|
+
if (idx >= 0) queue[idx] = entry
|
|
34
|
+
else queue.push(entry)
|
|
35
|
+
localStorage.setItem('tiny-canvas-queue', JSON.stringify(queue))
|
|
36
|
+
} catch { /* localStorage unavailable */ }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get viewport-center coordinates for placing a new widget.
|
|
41
|
+
*/
|
|
42
|
+
function getViewportCenter() {
|
|
43
|
+
return {
|
|
44
|
+
x: Math.round(window.innerWidth / 2 - 120),
|
|
45
|
+
y: Math.round(window.innerHeight / 2 - 80),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
21
49
|
/** Renders a single JSON-defined widget by type lookup. */
|
|
22
|
-
function WidgetRenderer({ widget, onUpdate
|
|
50
|
+
function WidgetRenderer({ widget, onUpdate }) {
|
|
23
51
|
const Component = getWidgetComponent(widget.type)
|
|
24
52
|
if (!Component) {
|
|
25
53
|
console.warn(`[canvas] Unknown widget type: ${widget.type}`)
|
|
@@ -29,7 +57,6 @@ function WidgetRenderer({ widget, onUpdate, onRemove }) {
|
|
|
29
57
|
id: widget.id,
|
|
30
58
|
props: widget.props,
|
|
31
59
|
onUpdate,
|
|
32
|
-
onRemove,
|
|
33
60
|
})
|
|
34
61
|
}
|
|
35
62
|
|
|
@@ -45,9 +72,16 @@ export default function CanvasPage({ name }) {
|
|
|
45
72
|
// Local mutable copy of widgets for instant UI updates
|
|
46
73
|
const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
|
|
47
74
|
const [trackedCanvas, setTrackedCanvas] = useState(canvas)
|
|
75
|
+
const [selectedWidgetId, setSelectedWidgetId] = useState(null)
|
|
76
|
+
const [zoom, setZoom] = useState(100)
|
|
77
|
+
const scrollRef = useRef(null)
|
|
78
|
+
const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
|
|
79
|
+
const titleInputRef = useRef(null)
|
|
80
|
+
|
|
48
81
|
if (canvas !== trackedCanvas) {
|
|
49
82
|
setTrackedCanvas(canvas)
|
|
50
83
|
setLocalWidgets(canvas?.widgets ?? null)
|
|
84
|
+
setCanvasTitle(canvas?.title || name)
|
|
51
85
|
}
|
|
52
86
|
|
|
53
87
|
// Debounced save to server
|
|
@@ -59,6 +93,27 @@ export default function CanvasPage({ name }) {
|
|
|
59
93
|
}, 2000)
|
|
60
94
|
).current
|
|
61
95
|
|
|
96
|
+
const debouncedTitleSave = useRef(
|
|
97
|
+
debounce((canvasName, title) => {
|
|
98
|
+
updateCanvas(canvasName, { settings: { title } }).catch((err) =>
|
|
99
|
+
console.error('[canvas] Failed to save title:', err)
|
|
100
|
+
)
|
|
101
|
+
}, 1000)
|
|
102
|
+
).current
|
|
103
|
+
|
|
104
|
+
const handleTitleChange = useCallback((e) => {
|
|
105
|
+
const newTitle = e.target.value
|
|
106
|
+
setCanvasTitle(newTitle)
|
|
107
|
+
debouncedTitleSave(name, newTitle)
|
|
108
|
+
}, [name, debouncedTitleSave])
|
|
109
|
+
|
|
110
|
+
const handleTitleKeyDown = useCallback((e) => {
|
|
111
|
+
if (e.key === 'Enter') {
|
|
112
|
+
e.target.blur()
|
|
113
|
+
}
|
|
114
|
+
e.stopPropagation()
|
|
115
|
+
}, [])
|
|
116
|
+
|
|
62
117
|
const handleWidgetUpdate = useCallback((widgetId, updates) => {
|
|
63
118
|
setLocalWidgets((prev) => {
|
|
64
119
|
if (!prev) return prev
|
|
@@ -77,6 +132,206 @@ export default function CanvasPage({ name }) {
|
|
|
77
132
|
)
|
|
78
133
|
}, [name])
|
|
79
134
|
|
|
135
|
+
// Signal canvas mount/unmount to CoreUIBar (include zoom state)
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:mounted', {
|
|
138
|
+
detail: { name, zoom }
|
|
139
|
+
}))
|
|
140
|
+
return () => {
|
|
141
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:unmounted'))
|
|
142
|
+
}
|
|
143
|
+
}, [name, zoom])
|
|
144
|
+
|
|
145
|
+
// Add a widget by type — used by CanvasControls and CoreUIBar event
|
|
146
|
+
const addWidget = useCallback(async (type) => {
|
|
147
|
+
const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
|
|
148
|
+
const pos = getViewportCenter()
|
|
149
|
+
try {
|
|
150
|
+
const result = await addWidgetApi(name, {
|
|
151
|
+
type,
|
|
152
|
+
props: defaultProps,
|
|
153
|
+
position: pos,
|
|
154
|
+
})
|
|
155
|
+
if (result.success && result.widget) {
|
|
156
|
+
saveWidgetPosition(result.widget.id, pos.x, pos.y)
|
|
157
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
158
|
+
}
|
|
159
|
+
} catch (err) {
|
|
160
|
+
console.error('[canvas] Failed to add widget:', err)
|
|
161
|
+
}
|
|
162
|
+
}, [name])
|
|
163
|
+
|
|
164
|
+
// Listen for CoreUIBar add-widget events
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
function handleAddWidget(e) {
|
|
167
|
+
addWidget(e.detail.type)
|
|
168
|
+
}
|
|
169
|
+
document.addEventListener('storyboard:canvas:add-widget', handleAddWidget)
|
|
170
|
+
return () => document.removeEventListener('storyboard:canvas:add-widget', handleAddWidget)
|
|
171
|
+
}, [addWidget])
|
|
172
|
+
|
|
173
|
+
// Listen for zoom changes from CoreUIBar
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
function handleZoom(e) {
|
|
176
|
+
const { zoom: newZoom } = e.detail
|
|
177
|
+
if (typeof newZoom === 'number') {
|
|
178
|
+
setZoom(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom)))
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
document.addEventListener('storyboard:canvas:set-zoom', handleZoom)
|
|
182
|
+
return () => document.removeEventListener('storyboard:canvas:set-zoom', handleZoom)
|
|
183
|
+
}, [])
|
|
184
|
+
|
|
185
|
+
// Broadcast zoom level to CoreUIBar whenever it changes
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
|
|
188
|
+
detail: { zoom }
|
|
189
|
+
}))
|
|
190
|
+
}, [zoom])
|
|
191
|
+
|
|
192
|
+
// Delete selected widget on Delete/Backspace key
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
function handleKeyDown(e) {
|
|
195
|
+
if (!selectedWidgetId) return
|
|
196
|
+
const tag = e.target.tagName
|
|
197
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
198
|
+
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
199
|
+
e.preventDefault()
|
|
200
|
+
handleWidgetRemove(selectedWidgetId)
|
|
201
|
+
setSelectedWidgetId(null)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
205
|
+
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
206
|
+
}, [selectedWidgetId, handleWidgetRemove])
|
|
207
|
+
|
|
208
|
+
// Paste handler — same-origin URLs become prototypes, other URLs become link previews, text becomes markdown
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
const baseUrl = window.location.origin + (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
211
|
+
|
|
212
|
+
async function handlePaste(e) {
|
|
213
|
+
const tag = e.target.tagName
|
|
214
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
215
|
+
|
|
216
|
+
const text = e.clipboardData?.getData('text/plain')?.trim()
|
|
217
|
+
if (!text) return
|
|
218
|
+
|
|
219
|
+
e.preventDefault()
|
|
220
|
+
|
|
221
|
+
let type, props
|
|
222
|
+
try {
|
|
223
|
+
const parsed = new URL(text)
|
|
224
|
+
if (text.startsWith(baseUrl)) {
|
|
225
|
+
// Same-origin URL → prototype embed with the path portion
|
|
226
|
+
const pathPortion = parsed.pathname + parsed.search + parsed.hash
|
|
227
|
+
const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
228
|
+
const src = basePath ? pathPortion.replace(new RegExp(`^${basePath}`), '') : pathPortion
|
|
229
|
+
type = 'prototype'
|
|
230
|
+
props = { src: src || '/', label: '', width: 800, height: 600 }
|
|
231
|
+
} else {
|
|
232
|
+
type = 'link-preview'
|
|
233
|
+
props = { url: text, title: '' }
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
type = 'markdown'
|
|
237
|
+
props = { content: text }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const pos = getViewportCenter()
|
|
241
|
+
try {
|
|
242
|
+
const result = await addWidgetApi(name, {
|
|
243
|
+
type,
|
|
244
|
+
props,
|
|
245
|
+
position: pos,
|
|
246
|
+
})
|
|
247
|
+
if (result.success && result.widget) {
|
|
248
|
+
saveWidgetPosition(result.widget.id, pos.x, pos.y)
|
|
249
|
+
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
250
|
+
}
|
|
251
|
+
} catch (err) {
|
|
252
|
+
console.error('[canvas] Failed to add widget from paste:', err)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
document.addEventListener('paste', handlePaste)
|
|
256
|
+
return () => document.removeEventListener('paste', handlePaste)
|
|
257
|
+
}, [name])
|
|
258
|
+
|
|
259
|
+
// Cmd+scroll / trackpad pinch to smooth-zoom the canvas
|
|
260
|
+
// On macOS, pinch-to-zoom fires wheel events with ctrlKey: true and small
|
|
261
|
+
// fractional deltaY values. We accumulate the delta to handle sub-pixel changes.
|
|
262
|
+
const zoomAccum = useRef(0)
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
function handleWheel(e) {
|
|
265
|
+
if (!e.metaKey && !e.ctrlKey) return
|
|
266
|
+
e.preventDefault()
|
|
267
|
+
zoomAccum.current += -e.deltaY
|
|
268
|
+
const step = Math.trunc(zoomAccum.current)
|
|
269
|
+
if (step === 0) return
|
|
270
|
+
zoomAccum.current -= step
|
|
271
|
+
setZoom((z) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z + step)))
|
|
272
|
+
}
|
|
273
|
+
document.addEventListener('wheel', handleWheel, { passive: false })
|
|
274
|
+
return () => document.removeEventListener('wheel', handleWheel)
|
|
275
|
+
}, [])
|
|
276
|
+
|
|
277
|
+
// Space + drag to pan the canvas
|
|
278
|
+
const [spaceHeld, setSpaceHeld] = useState(false)
|
|
279
|
+
const isPanning = useRef(false)
|
|
280
|
+
const [panningActive, setPanningActive] = useState(false)
|
|
281
|
+
const panStart = useRef({ x: 0, y: 0, scrollX: 0, scrollY: 0 })
|
|
282
|
+
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
function handleKeyDown(e) {
|
|
285
|
+
if (e.key === ' ' && !e.repeat) {
|
|
286
|
+
const tag = e.target.tagName
|
|
287
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
|
|
288
|
+
e.preventDefault()
|
|
289
|
+
setSpaceHeld(true)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function handleKeyUp(e) {
|
|
293
|
+
if (e.key === ' ') {
|
|
294
|
+
setSpaceHeld(false)
|
|
295
|
+
isPanning.current = false
|
|
296
|
+
setPanningActive(false)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
300
|
+
document.addEventListener('keyup', handleKeyUp)
|
|
301
|
+
return () => {
|
|
302
|
+
document.removeEventListener('keydown', handleKeyDown)
|
|
303
|
+
document.removeEventListener('keyup', handleKeyUp)
|
|
304
|
+
}
|
|
305
|
+
}, [])
|
|
306
|
+
|
|
307
|
+
const handlePanStart = useCallback((e) => {
|
|
308
|
+
if (!spaceHeld) return
|
|
309
|
+
e.preventDefault()
|
|
310
|
+
isPanning.current = true
|
|
311
|
+
setPanningActive(true)
|
|
312
|
+
const el = scrollRef.current
|
|
313
|
+
panStart.current = {
|
|
314
|
+
x: e.clientX,
|
|
315
|
+
y: e.clientY,
|
|
316
|
+
scrollX: el?.scrollLeft ?? 0,
|
|
317
|
+
scrollY: el?.scrollTop ?? 0,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function handlePanMove(ev) {
|
|
321
|
+
if (!isPanning.current || !el) return
|
|
322
|
+
el.scrollLeft = panStart.current.scrollX - (ev.clientX - panStart.current.x)
|
|
323
|
+
el.scrollTop = panStart.current.scrollY - (ev.clientY - panStart.current.y)
|
|
324
|
+
}
|
|
325
|
+
function handlePanEnd() {
|
|
326
|
+
isPanning.current = false
|
|
327
|
+
setPanningActive(false)
|
|
328
|
+
document.removeEventListener('mousemove', handlePanMove)
|
|
329
|
+
document.removeEventListener('mouseup', handlePanEnd)
|
|
330
|
+
}
|
|
331
|
+
document.addEventListener('mousemove', handlePanMove)
|
|
332
|
+
document.addEventListener('mouseup', handlePanEnd)
|
|
333
|
+
}, [spaceHeld])
|
|
334
|
+
|
|
80
335
|
if (!canvas) {
|
|
81
336
|
return (
|
|
82
337
|
<div className={styles.empty}>
|
|
@@ -115,31 +370,64 @@ export default function CanvasPage({ name }) {
|
|
|
115
370
|
}
|
|
116
371
|
}
|
|
117
372
|
|
|
118
|
-
// 2. JSON-defined mutable widgets
|
|
373
|
+
// 2. JSON-defined mutable widgets (selectable)
|
|
119
374
|
for (const widget of (localWidgets ?? [])) {
|
|
120
375
|
allChildren.push(
|
|
121
|
-
<div
|
|
376
|
+
<div
|
|
377
|
+
key={widget.id}
|
|
378
|
+
id={widget.id}
|
|
379
|
+
onClick={(e) => {
|
|
380
|
+
e.stopPropagation()
|
|
381
|
+
setSelectedWidgetId(widget.id)
|
|
382
|
+
}}
|
|
383
|
+
className={selectedWidgetId === widget.id ? styles.selected : undefined}
|
|
384
|
+
>
|
|
122
385
|
<WidgetRenderer
|
|
123
386
|
widget={widget}
|
|
124
387
|
onUpdate={(updates) => handleWidgetUpdate(widget.id, updates)}
|
|
125
|
-
onRemove={() => handleWidgetRemove(widget.id)}
|
|
126
388
|
/>
|
|
127
389
|
</div>
|
|
128
390
|
)
|
|
129
391
|
}
|
|
130
392
|
|
|
393
|
+
const scale = zoom / 100
|
|
394
|
+
|
|
131
395
|
return (
|
|
132
396
|
<>
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
397
|
+
<div className={styles.canvasTitle}>
|
|
398
|
+
<input
|
|
399
|
+
ref={titleInputRef}
|
|
400
|
+
className={styles.canvasTitleInput}
|
|
401
|
+
value={canvasTitle}
|
|
402
|
+
onChange={handleTitleChange}
|
|
403
|
+
onKeyDown={handleTitleKeyDown}
|
|
404
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
405
|
+
spellCheck={false}
|
|
406
|
+
aria-label="Canvas title"
|
|
407
|
+
style={{ width: `${Math.max(80, canvasTitle.length * 8.5 + 20)}px` }}
|
|
408
|
+
/>
|
|
409
|
+
</div>
|
|
410
|
+
<div
|
|
411
|
+
ref={scrollRef}
|
|
412
|
+
className={styles.canvasScroll}
|
|
413
|
+
style={spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : undefined}
|
|
414
|
+
onClick={() => setSelectedWidgetId(null)}
|
|
415
|
+
onMouseDown={handlePanStart}
|
|
416
|
+
>
|
|
417
|
+
<div
|
|
418
|
+
className={styles.canvasZoom}
|
|
419
|
+
style={{
|
|
420
|
+
transform: `scale(${scale})`,
|
|
421
|
+
transformOrigin: '0 0',
|
|
422
|
+
width: `${Math.max(10000, 100 / scale)}vw`,
|
|
423
|
+
height: `${Math.max(10000, 100 / scale)}vh`,
|
|
424
|
+
}}
|
|
425
|
+
>
|
|
426
|
+
<Canvas {...canvasProps}>
|
|
427
|
+
{allChildren}
|
|
428
|
+
</Canvas>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
143
431
|
</>
|
|
144
432
|
)
|
|
145
433
|
}
|