@dfosco/storyboard-react 2.8.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 +6 -3
- package/src/Viewfinder.jsx +5 -4
- package/src/canvas/CanvasControls.jsx +123 -0
- package/src/canvas/CanvasControls.module.css +133 -0
- package/src/canvas/CanvasPage.jsx +433 -0
- package/src/canvas/CanvasPage.module.css +73 -0
- package/src/canvas/CanvasToolbar.jsx +76 -0
- package/src/canvas/CanvasToolbar.module.css +92 -0
- package/src/canvas/canvasApi.js +41 -0
- package/src/canvas/useCanvas.js +74 -0
- package/src/canvas/widgets/ComponentWidget.jsx +15 -0
- package/src/canvas/widgets/LinkPreview.jsx +34 -0
- package/src/canvas/widgets/LinkPreview.module.css +51 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +91 -0
- package/src/canvas/widgets/MarkdownBlock.module.css +78 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +179 -0
- package/src/canvas/widgets/PrototypeEmbed.module.css +242 -0
- package/src/canvas/widgets/StickyNote.jsx +98 -0
- package/src/canvas/widgets/StickyNote.module.css +111 -0
- package/src/canvas/widgets/WidgetWrapper.jsx +15 -0
- package/src/canvas/widgets/WidgetWrapper.module.css +23 -0
- package/src/canvas/widgets/index.js +23 -0
- package/src/canvas/widgets/widgetProps.js +151 -0
- package/src/hooks/useFeatureFlag.js +2 -4
- package/src/hooks/useFlows.js +50 -0
- package/src/hooks/useFlows.test.js +134 -0
- package/src/index.js +5 -0
- package/src/vite/data-plugin.js +131 -29
- package/src/vite/data-plugin.test.js +3 -3
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
.embed {
|
|
2
|
+
position: relative;
|
|
3
|
+
overflow: hidden;
|
|
4
|
+
background: var(--bgColor-default, #ffffff);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.iframeContainer {
|
|
8
|
+
width: 100%;
|
|
9
|
+
height: 100%;
|
|
10
|
+
overflow: hidden;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.iframe {
|
|
14
|
+
border: none;
|
|
15
|
+
display: block;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.dragOverlay {
|
|
19
|
+
position: absolute;
|
|
20
|
+
inset: 0;
|
|
21
|
+
z-index: 1;
|
|
22
|
+
cursor: grab;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.empty {
|
|
26
|
+
display: flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
justify-content: center;
|
|
29
|
+
height: 100%;
|
|
30
|
+
color: var(--fgColor-muted, #656d76);
|
|
31
|
+
font-size: 14px;
|
|
32
|
+
font-style: italic;
|
|
33
|
+
cursor: pointer;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.empty p {
|
|
37
|
+
margin: 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.editBtn {
|
|
41
|
+
all: unset;
|
|
42
|
+
cursor: pointer;
|
|
43
|
+
position: absolute;
|
|
44
|
+
top: 8px;
|
|
45
|
+
right: 8px;
|
|
46
|
+
width: 28px;
|
|
47
|
+
height: 28px;
|
|
48
|
+
display: flex;
|
|
49
|
+
align-items: center;
|
|
50
|
+
justify-content: center;
|
|
51
|
+
border-radius: 6px;
|
|
52
|
+
background: rgba(255, 255, 255, 0.92);
|
|
53
|
+
backdrop-filter: blur(12px);
|
|
54
|
+
-webkit-backdrop-filter: blur(12px);
|
|
55
|
+
border: 1px solid rgba(0, 0, 0, 0.12);
|
|
56
|
+
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
|
57
|
+
font-size: 14px;
|
|
58
|
+
opacity: 0;
|
|
59
|
+
transition: opacity 150ms;
|
|
60
|
+
z-index: 2;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.embed:hover .editBtn {
|
|
64
|
+
opacity: 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.editBtn:hover {
|
|
68
|
+
background: rgba(255, 255, 255, 0.98);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@media (prefers-color-scheme: dark) {
|
|
72
|
+
.editBtn {
|
|
73
|
+
background: rgba(22, 27, 34, 0.88);
|
|
74
|
+
border-color: rgba(255, 255, 255, 0.1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.editBtn:hover {
|
|
78
|
+
background: rgba(30, 37, 46, 0.95);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.urlForm {
|
|
83
|
+
display: flex;
|
|
84
|
+
flex-direction: column;
|
|
85
|
+
gap: 8px;
|
|
86
|
+
padding: 24px;
|
|
87
|
+
height: 100%;
|
|
88
|
+
box-sizing: border-box;
|
|
89
|
+
justify-content: center;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.urlLabel {
|
|
93
|
+
font-size: 12px;
|
|
94
|
+
font-weight: 600;
|
|
95
|
+
color: var(--fgColor-muted, #656d76);
|
|
96
|
+
text-transform: uppercase;
|
|
97
|
+
letter-spacing: 0.5px;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.urlInput {
|
|
101
|
+
all: unset;
|
|
102
|
+
padding: 8px 10px;
|
|
103
|
+
font-size: 14px;
|
|
104
|
+
font-family: ui-monospace, SFMono-Regular, monospace;
|
|
105
|
+
border: 1px solid var(--borderColor-default, #d0d7de);
|
|
106
|
+
border-radius: 6px;
|
|
107
|
+
background: var(--bgColor-default, #ffffff);
|
|
108
|
+
color: var(--fgColor-default, #1f2328);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.urlInput:focus {
|
|
112
|
+
border-color: var(--bgColor-accent-emphasis, #2f81f7);
|
|
113
|
+
box-shadow: 0 0 0 2px rgba(47, 129, 247, 0.3);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.urlActions {
|
|
117
|
+
display: flex;
|
|
118
|
+
gap: 8px;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.urlSave,
|
|
122
|
+
.urlCancel {
|
|
123
|
+
all: unset;
|
|
124
|
+
cursor: pointer;
|
|
125
|
+
padding: 6px 14px;
|
|
126
|
+
font-size: 13px;
|
|
127
|
+
font-weight: 500;
|
|
128
|
+
border-radius: 6px;
|
|
129
|
+
text-align: center;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.urlSave {
|
|
133
|
+
background: var(--bgColor-accent-emphasis, #2f81f7);
|
|
134
|
+
color: var(--fgColor-onEmphasis, #ffffff);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.urlSave:hover {
|
|
138
|
+
background: var(--bgColor-accent-emphasis, #388bfd);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.urlCancel {
|
|
142
|
+
color: var(--fgColor-muted, #656d76);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.urlCancel:hover {
|
|
146
|
+
color: var(--fgColor-default, #1f2328);
|
|
147
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.resizeHandle {
|
|
151
|
+
position: absolute;
|
|
152
|
+
bottom: 0;
|
|
153
|
+
right: 0;
|
|
154
|
+
width: 16px;
|
|
155
|
+
height: 16px;
|
|
156
|
+
cursor: nwse-resize;
|
|
157
|
+
background: linear-gradient(
|
|
158
|
+
135deg,
|
|
159
|
+
transparent 40%,
|
|
160
|
+
var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 40%,
|
|
161
|
+
var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 50%,
|
|
162
|
+
transparent 50%,
|
|
163
|
+
transparent 65%,
|
|
164
|
+
var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 65%,
|
|
165
|
+
var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 75%,
|
|
166
|
+
transparent 75%
|
|
167
|
+
);
|
|
168
|
+
opacity: 0;
|
|
169
|
+
transition: opacity 150ms;
|
|
170
|
+
z-index: 2;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.embed:hover ~ .resizeHandle,
|
|
174
|
+
.resizeHandle:hover {
|
|
175
|
+
opacity: 1;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.zoomBar {
|
|
179
|
+
position: absolute;
|
|
180
|
+
bottom: 8px;
|
|
181
|
+
left: 50%;
|
|
182
|
+
transform: translateX(-50%);
|
|
183
|
+
display: flex;
|
|
184
|
+
align-items: center;
|
|
185
|
+
border-radius: 10px;
|
|
186
|
+
border: 1.5px solid var(--trigger-border, var(--borderColor-muted, #d0d7de));
|
|
187
|
+
background: var(--trigger-bg, var(--bgColor-muted, #f6f8fa));
|
|
188
|
+
opacity: 0;
|
|
189
|
+
transition: opacity 150ms;
|
|
190
|
+
z-index: 3;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.embed:hover .zoomBar {
|
|
194
|
+
opacity: 1;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.zoomBtn {
|
|
198
|
+
all: unset;
|
|
199
|
+
cursor: pointer;
|
|
200
|
+
width: 36px;
|
|
201
|
+
height: 32px;
|
|
202
|
+
display: flex;
|
|
203
|
+
align-items: center;
|
|
204
|
+
justify-content: center;
|
|
205
|
+
font-size: 16px;
|
|
206
|
+
font-weight: 600;
|
|
207
|
+
color: var(--trigger-text, var(--fgColor-default, #1f2328));
|
|
208
|
+
transition: background 120ms;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.zoomBtn:first-child {
|
|
212
|
+
border-radius: 7px 0 0 7px;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.zoomBtn:last-child {
|
|
216
|
+
border-radius: 0 7px 7px 0;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.zoomBtn:hover:not(:disabled) {
|
|
220
|
+
background: var(--trigger-bg-hover, var(--bgColor-neutral-muted, #eaeef2));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.zoomBtn:disabled {
|
|
224
|
+
opacity: 0.3;
|
|
225
|
+
cursor: default;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.zoomLabel {
|
|
229
|
+
display: flex;
|
|
230
|
+
align-items: center;
|
|
231
|
+
justify-content: center;
|
|
232
|
+
min-width: 48px;
|
|
233
|
+
height: 32px;
|
|
234
|
+
padding: 0 4px;
|
|
235
|
+
font-size: 11px;
|
|
236
|
+
font-weight: 600;
|
|
237
|
+
font-variant-numeric: tabular-nums;
|
|
238
|
+
color: var(--trigger-text, var(--fgColor-default, #1f2328));
|
|
239
|
+
border-left: 1.5px solid var(--trigger-border, var(--borderColor-muted, #d0d7de));
|
|
240
|
+
border-right: 1.5px solid var(--trigger-border, var(--borderColor-muted, #d0d7de));
|
|
241
|
+
user-select: none;
|
|
242
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
2
|
+
import { readProp, stickyNoteSchema } from './widgetProps.js'
|
|
3
|
+
import styles from './StickyNote.module.css'
|
|
4
|
+
|
|
5
|
+
const COLORS = {
|
|
6
|
+
yellow: { bg: '#fff8c5', border: '#d4a72c', dot: '#e8c846' },
|
|
7
|
+
blue: { bg: '#ddf4ff', border: '#54aeff', dot: '#74b9ff' },
|
|
8
|
+
green: { bg: '#dafbe1', border: '#4ac26b', dot: '#6dd58c' },
|
|
9
|
+
pink: { bg: '#ffebe9', border: '#ff8182', dot: '#ff9a9e' },
|
|
10
|
+
purple: { bg: '#fbefff', border: '#c297ff', dot: '#d4a8ff' },
|
|
11
|
+
orange: { bg: '#fff1e5', border: '#d18616', dot: '#e8a844' },
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function StickyNote({ props, onUpdate }) {
|
|
15
|
+
const text = readProp(props, 'text', stickyNoteSchema)
|
|
16
|
+
const color = readProp(props, 'color', stickyNoteSchema)
|
|
17
|
+
const palette = COLORS[color] ?? COLORS.yellow
|
|
18
|
+
const textareaRef = useRef(null)
|
|
19
|
+
const [editing, setEditing] = useState(false)
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (editing && textareaRef.current) {
|
|
23
|
+
textareaRef.current.focus()
|
|
24
|
+
textareaRef.current.selectionStart = textareaRef.current.value.length
|
|
25
|
+
}
|
|
26
|
+
}, [editing])
|
|
27
|
+
|
|
28
|
+
const handleTextChange = useCallback((e) => {
|
|
29
|
+
onUpdate?.({ text: e.target.value })
|
|
30
|
+
}, [onUpdate])
|
|
31
|
+
|
|
32
|
+
const handleColorChange = useCallback((newColor) => {
|
|
33
|
+
onUpdate?.({ color: newColor })
|
|
34
|
+
}, [onUpdate])
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className={styles.container}>
|
|
38
|
+
<article
|
|
39
|
+
className={styles.sticky}
|
|
40
|
+
style={{ '--sticky-bg': palette.bg, '--sticky-border': palette.border }}
|
|
41
|
+
>
|
|
42
|
+
<p
|
|
43
|
+
className={styles.text}
|
|
44
|
+
style={editing ? { visibility: 'hidden' } : undefined}
|
|
45
|
+
onDoubleClick={() => setEditing(true)}
|
|
46
|
+
role="button"
|
|
47
|
+
tabIndex={0}
|
|
48
|
+
onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}
|
|
49
|
+
>
|
|
50
|
+
{text || 'Double-click to edit…'}
|
|
51
|
+
</p>
|
|
52
|
+
{editing && (
|
|
53
|
+
<textarea
|
|
54
|
+
ref={textareaRef}
|
|
55
|
+
className={styles.textarea}
|
|
56
|
+
value={text}
|
|
57
|
+
onChange={handleTextChange}
|
|
58
|
+
onBlur={() => setEditing(false)}
|
|
59
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
60
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
61
|
+
onKeyDown={(e) => {
|
|
62
|
+
if (e.key === 'Escape') setEditing(false)
|
|
63
|
+
}}
|
|
64
|
+
placeholder="Type here…"
|
|
65
|
+
/>
|
|
66
|
+
)}
|
|
67
|
+
</article>
|
|
68
|
+
|
|
69
|
+
{/* Color picker — dot trigger below the sticky */}
|
|
70
|
+
<div
|
|
71
|
+
className={styles.pickerArea}
|
|
72
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
73
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
74
|
+
>
|
|
75
|
+
<span
|
|
76
|
+
className={styles.pickerDot}
|
|
77
|
+
style={{ background: palette.dot }}
|
|
78
|
+
/>
|
|
79
|
+
<div className={styles.pickerPopup}>
|
|
80
|
+
{Object.entries(COLORS).map(([colorName, c]) => (
|
|
81
|
+
<button
|
|
82
|
+
key={colorName}
|
|
83
|
+
className={`${styles.colorDot} ${colorName === color ? styles.active : ''}`}
|
|
84
|
+
style={{ background: c.bg, borderColor: c.border }}
|
|
85
|
+
onClick={(e) => {
|
|
86
|
+
e.stopPropagation()
|
|
87
|
+
handleColorChange(colorName)
|
|
88
|
+
}}
|
|
89
|
+
title={colorName}
|
|
90
|
+
aria-label={`Set color to ${colorName}`}
|
|
91
|
+
/>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
.container {
|
|
2
|
+
position: relative;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.sticky {
|
|
6
|
+
background: var(--sticky-bg, #fff8c5);
|
|
7
|
+
border-radius: 6px;
|
|
8
|
+
border: 2px solid color-mix(in srgb, var(--sticky-bg) 80%, rgb(0, 0, 0) 10%);
|
|
9
|
+
min-width: 180px;
|
|
10
|
+
box-shadow: 2px 3px 8px rgba(0, 0, 0, 0.04);
|
|
11
|
+
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
12
|
+
resize: both;
|
|
13
|
+
overflow: auto;
|
|
14
|
+
position: relative;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.text {
|
|
18
|
+
padding: 16px 20px;
|
|
19
|
+
margin: 0;
|
|
20
|
+
font-size: 18px;
|
|
21
|
+
line-height: 1.5;
|
|
22
|
+
color: color-mix(in srgb, var(--sticky-bg), black 70%);
|
|
23
|
+
white-space: pre-wrap;
|
|
24
|
+
word-break: break-word;
|
|
25
|
+
cursor: text;
|
|
26
|
+
min-height: 60px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.textarea {
|
|
30
|
+
position: absolute;
|
|
31
|
+
top: 0;
|
|
32
|
+
left: 0;
|
|
33
|
+
width: 100%;
|
|
34
|
+
height: 100%;
|
|
35
|
+
box-sizing: border-box;
|
|
36
|
+
padding: 16px 20px;
|
|
37
|
+
margin: 0;
|
|
38
|
+
border: none;
|
|
39
|
+
outline: none;
|
|
40
|
+
background: transparent;
|
|
41
|
+
font-family: inherit;
|
|
42
|
+
font-size: 18px;
|
|
43
|
+
line-height: 1.5;
|
|
44
|
+
color: color-mix(in srgb, var(--sticky-bg) 80%, rgb(0, 0, 0) 98%);
|
|
45
|
+
white-space: pre-wrap;
|
|
46
|
+
word-break: break-word;
|
|
47
|
+
resize: none;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* Color picker area — sits below the sticky */
|
|
51
|
+
|
|
52
|
+
.pickerArea {
|
|
53
|
+
display: flex;
|
|
54
|
+
justify-content: center;
|
|
55
|
+
padding-top: 6px;
|
|
56
|
+
position: relative;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.pickerDot {
|
|
60
|
+
width: 8px;
|
|
61
|
+
height: 8px;
|
|
62
|
+
border-radius: 50%;
|
|
63
|
+
opacity: 0.5;
|
|
64
|
+
transition: opacity 150ms;
|
|
65
|
+
cursor: pointer;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.pickerPopup {
|
|
69
|
+
position: absolute;
|
|
70
|
+
top: 4px;
|
|
71
|
+
display: flex;
|
|
72
|
+
gap: 5px;
|
|
73
|
+
padding: 6px 10px;
|
|
74
|
+
background: var(--bgColor-default, #ffffff);
|
|
75
|
+
border-radius: 20px;
|
|
76
|
+
box-shadow:
|
|
77
|
+
0 0 0 1px rgba(0, 0, 0, 0.08),
|
|
78
|
+
0 4px 12px rgba(0, 0, 0, 0.12);
|
|
79
|
+
opacity: 0;
|
|
80
|
+
pointer-events: none;
|
|
81
|
+
transition: opacity 150ms;
|
|
82
|
+
z-index: 10;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.pickerArea:hover .pickerDot {
|
|
86
|
+
opacity: 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.pickerArea:hover .pickerPopup {
|
|
90
|
+
opacity: 1;
|
|
91
|
+
pointer-events: auto;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.colorDot {
|
|
95
|
+
all: unset;
|
|
96
|
+
width: 20px;
|
|
97
|
+
height: 20px;
|
|
98
|
+
border-radius: 50%;
|
|
99
|
+
border: 2px solid transparent;
|
|
100
|
+
cursor: pointer;
|
|
101
|
+
transition: transform 100ms;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.colorDot:hover {
|
|
105
|
+
transform: scale(1.15);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.colorDot.active {
|
|
109
|
+
border-color: var(--sticky-border);
|
|
110
|
+
box-shadow: 0 0 0 1px var(--sticky-border);
|
|
111
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import styles from './WidgetWrapper.module.css'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Common wrapper for all canvas widgets.
|
|
5
|
+
* Provides shadow/border styling.
|
|
6
|
+
*/
|
|
7
|
+
export default function WidgetWrapper({ children, className }) {
|
|
8
|
+
return (
|
|
9
|
+
<section className={`${styles.wrapper} ${className || ''}`}>
|
|
10
|
+
<div className={styles.content}>
|
|
11
|
+
{children}
|
|
12
|
+
</div>
|
|
13
|
+
</section>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
.wrapper {
|
|
2
|
+
background: var(--bgColor-default, #ffffff);
|
|
3
|
+
border-radius: var(--borderRadius-large, 12px);
|
|
4
|
+
overflow: hidden;
|
|
5
|
+
min-width: 200px;
|
|
6
|
+
box-shadow:
|
|
7
|
+
0 0 0 1px rgba(0, 0, 0, 0.06),
|
|
8
|
+
0 0 0 2px rgba(255, 255, 255, 0.04),
|
|
9
|
+
0 2px 8px rgba(0, 0, 0, 0.08);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.content {
|
|
13
|
+
position: relative;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@media (prefers-color-scheme: dark) {
|
|
17
|
+
.wrapper {
|
|
18
|
+
box-shadow:
|
|
19
|
+
0 0 0 1px rgba(255, 255, 255, 0.08),
|
|
20
|
+
0 0 0 2px rgba(0, 0, 0, 0.3),
|
|
21
|
+
0 2px 8px rgba(0, 0, 0, 0.25);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import StickyNote from './StickyNote.jsx'
|
|
2
|
+
import MarkdownBlock from './MarkdownBlock.jsx'
|
|
3
|
+
import PrototypeEmbed from './PrototypeEmbed.jsx'
|
|
4
|
+
import LinkPreview from './LinkPreview.jsx'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Maps widget type strings to their React components.
|
|
8
|
+
* Each component receives: { id, props, onUpdate }
|
|
9
|
+
*/
|
|
10
|
+
export const widgetRegistry = {
|
|
11
|
+
'sticky-note': StickyNote,
|
|
12
|
+
'markdown': MarkdownBlock,
|
|
13
|
+
'prototype': PrototypeEmbed,
|
|
14
|
+
'link-preview': LinkPreview,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a widget type string to its component.
|
|
19
|
+
* Returns null for unknown types.
|
|
20
|
+
*/
|
|
21
|
+
export function getWidgetComponent(type) {
|
|
22
|
+
return widgetRegistry[type] ?? null
|
|
23
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas Widget Props API
|
|
3
|
+
*
|
|
4
|
+
* Every canvas widget receives its data through a structured `props` object
|
|
5
|
+
* stored in .canvas.json. This module defines the prop schema system that
|
|
6
|
+
* widgets use to declare, read, and update their editable properties.
|
|
7
|
+
*
|
|
8
|
+
* ## Prop Categories
|
|
9
|
+
*
|
|
10
|
+
* Widget props are grouped into three categories:
|
|
11
|
+
*
|
|
12
|
+
* ### `content` — User-editable content
|
|
13
|
+
* Text, markdown, URLs — the stuff users type or paste.
|
|
14
|
+
* Updated frequently (every keystroke when editing).
|
|
15
|
+
* Examples: sticky note text, markdown content, embed URL.
|
|
16
|
+
*
|
|
17
|
+
* ### `settings` — Widget configuration
|
|
18
|
+
* One-off choices that affect appearance or behavior.
|
|
19
|
+
* Updated infrequently (user picks from a menu).
|
|
20
|
+
* Examples: sticky note color, markdown width, embed layout.
|
|
21
|
+
*
|
|
22
|
+
* ### `size` — Dimensions
|
|
23
|
+
* Width and height of the widget.
|
|
24
|
+
* Updated via resize handles or explicit input.
|
|
25
|
+
* Examples: markdown block width, prototype embed width/height.
|
|
26
|
+
*
|
|
27
|
+
* ## Storage Format (.canvas.json)
|
|
28
|
+
*
|
|
29
|
+
* Props are stored flat in the widget's `props` object:
|
|
30
|
+
*
|
|
31
|
+
* ```json
|
|
32
|
+
* {
|
|
33
|
+
* "id": "sticky-1",
|
|
34
|
+
* "type": "sticky-note",
|
|
35
|
+
* "position": { "x": 100, "y": 200 },
|
|
36
|
+
* "props": {
|
|
37
|
+
* "text": "Hello world",
|
|
38
|
+
* "color": "yellow"
|
|
39
|
+
* }
|
|
40
|
+
* }
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* ## Widget Contract
|
|
44
|
+
*
|
|
45
|
+
* Every widget component receives:
|
|
46
|
+
* - `id` — stable widget identifier
|
|
47
|
+
* - `props` — the flat props object (may be null/undefined)
|
|
48
|
+
* - `onUpdate` — callback to persist prop changes: onUpdate({ key: value })
|
|
49
|
+
* - `onRemove` — callback to delete the widget
|
|
50
|
+
*
|
|
51
|
+
* `onUpdate` accepts a partial object that is shallow-merged into `props`.
|
|
52
|
+
* Multiple keys can be updated in one call:
|
|
53
|
+
* onUpdate({ text: 'new text', color: 'blue' })
|
|
54
|
+
*
|
|
55
|
+
* ## Declaring Widget Props (Schema)
|
|
56
|
+
*
|
|
57
|
+
* Each widget type exports a `schema` describing its props.
|
|
58
|
+
* This is used by the toolbar, canvas settings, and future widget inspectors.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {'text' | 'select' | 'number' | 'url' | 'boolean'} PropType
|
|
63
|
+
*
|
|
64
|
+
* @typedef {Object} PropDef
|
|
65
|
+
* @property {PropType} type — input type for editing
|
|
66
|
+
* @property {string} label — human-readable label
|
|
67
|
+
* @property {string} category — 'content' | 'settings' | 'size'
|
|
68
|
+
* @property {*} defaultValue — fallback when prop is missing
|
|
69
|
+
* @property {Array} [options] — choices for 'select' type
|
|
70
|
+
* @property {number} [min] — minimum for 'number' type
|
|
71
|
+
* @property {number} [max] — maximum for 'number' type
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read a prop value with fallback to schema default.
|
|
76
|
+
* @param {object} props — widget props object (may be null)
|
|
77
|
+
* @param {string} key — prop name
|
|
78
|
+
* @param {object} schema — widget schema
|
|
79
|
+
* @returns {*}
|
|
80
|
+
*/
|
|
81
|
+
export function readProp(props, key, schema) {
|
|
82
|
+
const value = props?.[key]
|
|
83
|
+
if (value !== undefined && value !== null) return value
|
|
84
|
+
return schema[key]?.defaultValue ?? null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read all props with defaults applied from schema.
|
|
89
|
+
* @param {object} props — widget props object (may be null)
|
|
90
|
+
* @param {object} schema — widget schema
|
|
91
|
+
* @returns {object}
|
|
92
|
+
*/
|
|
93
|
+
export function readAllProps(props, schema) {
|
|
94
|
+
const result = {}
|
|
95
|
+
for (const key of Object.keys(schema)) {
|
|
96
|
+
result[key] = readProp(props, key, schema)
|
|
97
|
+
}
|
|
98
|
+
return result
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get default props for a widget type from its schema.
|
|
103
|
+
* Used when creating new widgets.
|
|
104
|
+
* @param {object} schema — widget schema
|
|
105
|
+
* @returns {object}
|
|
106
|
+
*/
|
|
107
|
+
export function getDefaults(schema) {
|
|
108
|
+
const result = {}
|
|
109
|
+
for (const [key, def] of Object.entries(schema)) {
|
|
110
|
+
if (def.defaultValue !== undefined) {
|
|
111
|
+
result[key] = def.defaultValue
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return result
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Widget Schemas ──────────────────────────────────────────────────
|
|
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
|
+
}
|
|
124
|
+
|
|
125
|
+
export const markdownSchema = {
|
|
126
|
+
content: { type: 'text', label: 'Content', category: 'content', defaultValue: '' },
|
|
127
|
+
width: { type: 'number', label: 'Width', category: 'size', defaultValue: 360, min: 200, max: 1200 },
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const prototypeEmbedSchema = {
|
|
131
|
+
src: { type: 'url', label: 'URL', category: 'content', defaultValue: '' },
|
|
132
|
+
label: { type: 'text', label: 'Label', category: 'settings', defaultValue: '' },
|
|
133
|
+
zoom: { type: 'number', label: 'Zoom', category: 'settings', defaultValue: 100, min: 25, max: 200 },
|
|
134
|
+
width: { type: 'number', label: 'Width', category: 'size', defaultValue: 800, min: 200, max: 2000 },
|
|
135
|
+
height: { type: 'number', label: 'Height', category: 'size', defaultValue: 600, min: 200, max: 1500 },
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const linkPreviewSchema = {
|
|
139
|
+
url: { type: 'url', label: 'URL', category: 'content', defaultValue: '' },
|
|
140
|
+
title: { type: 'text', label: 'Title', category: 'content', defaultValue: '' },
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Schema registry — maps widget type strings to their schemas.
|
|
145
|
+
*/
|
|
146
|
+
export const schemas = {
|
|
147
|
+
'sticky-note': stickyNoteSchema,
|
|
148
|
+
'markdown': markdownSchema,
|
|
149
|
+
'prototype': prototypeEmbedSchema,
|
|
150
|
+
'link-preview': linkPreviewSchema,
|
|
151
|
+
}
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import { useSyncExternalStore } from 'react'
|
|
2
|
-
import { getFlag,
|
|
2
|
+
import { getFlag, subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* React hook for reading a feature flag value.
|
|
6
|
-
* Re-renders when the flag changes (via
|
|
6
|
+
* Re-renders when the flag changes (via localStorage).
|
|
7
7
|
*
|
|
8
8
|
* @param {string} key - Flag key (without "flag." prefix)
|
|
9
9
|
* @returns {boolean} Current resolved flag value
|
|
10
10
|
*/
|
|
11
11
|
export function useFeatureFlag(key) {
|
|
12
|
-
// Subscribe to both hash and storage changes for reactivity
|
|
13
|
-
useSyncExternalStore(subscribeToHash, getHashSnapshot)
|
|
14
12
|
useSyncExternalStore(subscribeToStorage, getStorageSnapshot)
|
|
15
13
|
return getFlag(key)
|
|
16
14
|
}
|