@dfosco/storyboard-react 4.2.0-beta.4 → 4.2.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 +10 -11
- package/src/AuthModal/AuthModal.jsx +6 -8
- package/src/BranchBar/BranchBar.jsx +20 -6
- package/src/BranchBar/BranchBar.module.css +13 -4
- package/src/BranchBar/useBranches.js +20 -6
- package/src/BranchBar/useBranches.test.js +68 -0
- package/src/CommandPalette/CommandPalette.jsx +480 -187
- package/src/CommandPalette/command-palette.css +142 -78
- package/src/Icon.jsx +157 -58
- package/src/Viewfinder.jsx +562 -207
- package/src/Viewfinder.module.css +434 -93
- package/src/Workspace.jsx +7 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
- package/src/canvas/CanvasPage.dragdrop.test.jsx +11 -7
- package/src/canvas/CanvasPage.jsx +739 -219
- package/src/canvas/CanvasPage.module.css +13 -15
- package/src/canvas/CanvasPage.multiselect.test.jsx +17 -6
- package/src/canvas/ConnectorLayer.jsx +121 -165
- package/src/canvas/ConnectorLayer.module.css +69 -0
- package/src/canvas/PageSelector.test.jsx +15 -6
- package/src/canvas/canvasApi.js +68 -2
- package/src/canvas/canvasReloadGuard.test.js +1 -1
- package/src/canvas/connectorGeometry.js +132 -0
- package/src/canvas/hotPoolDevLogs.js +25 -0
- package/src/canvas/useCanvas.js +1 -1
- package/src/canvas/useMarqueeSelect.js +30 -4
- package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
- package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
- package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
- package/src/canvas/widgets/ComponentWidget.jsx +1 -0
- package/src/canvas/widgets/CropOverlay.jsx +219 -0
- package/src/canvas/widgets/CropOverlay.module.css +118 -0
- package/src/canvas/widgets/ExpandedPane.jsx +474 -0
- package/src/canvas/widgets/ExpandedPane.module.css +179 -0
- package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
- package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
- package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
- package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +62 -47
- package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
- package/src/canvas/widgets/ImageWidget.jsx +130 -9
- package/src/canvas/widgets/ImageWidget.module.css +30 -0
- package/src/canvas/widgets/LinkPreview.jsx +113 -5
- package/src/canvas/widgets/LinkPreview.module.css +127 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +167 -17
- package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
- package/src/canvas/widgets/PromptWidget.jsx +414 -0
- package/src/canvas/widgets/PromptWidget.module.css +273 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +77 -39
- package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
- package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
- package/src/canvas/widgets/ResizeHandle.jsx +17 -6
- package/src/canvas/widgets/StoryWidget.jsx +73 -15
- package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
- package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
- package/src/canvas/widgets/TerminalWidget.jsx +445 -67
- package/src/canvas/widgets/TerminalWidget.module.css +271 -8
- package/src/canvas/widgets/TilesWidget.jsx +300 -0
- package/src/canvas/widgets/TilesWidget.module.css +133 -0
- package/src/canvas/widgets/WidgetChrome.jsx +74 -153
- package/src/canvas/widgets/WidgetChrome.module.css +30 -1
- package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
- package/src/canvas/widgets/expandUtils.js +560 -0
- package/src/canvas/widgets/expandUtils.test.js +155 -0
- package/src/canvas/widgets/index.js +9 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
- package/src/canvas/widgets/tilePool.js +23 -0
- package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
- package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
- package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
- package/src/canvas/widgets/tiles/leaf.png +0 -0
- package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
- package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
- package/src/canvas/widgets/tiles/solid-a.png +0 -0
- package/src/canvas/widgets/tiles/solid-b.png +0 -0
- package/src/canvas/widgets/widgetConfig.js +55 -4
- package/src/canvas/widgets/widgetIcons.jsx +190 -0
- package/src/canvas/widgets/widgetProps.js +1 -0
- package/src/context.jsx +48 -20
- package/src/hooks/useConfig.js +14 -0
- package/src/hooks/usePrototypeReloadGuard.js +64 -0
- package/src/hooks/useSceneData.js +1 -0
- package/src/hooks/useThemeState.test.js +1 -1
- package/src/index.js +8 -2
- package/src/story/ComponentSetPage.jsx +186 -0
- package/src/story/ComponentSetPage.module.css +121 -0
- package/src/story/StoryPage.jsx +32 -2
- package/src/vite/data-plugin.js +363 -67
- package/src/vite/data-plugin.test.js +1 -1
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--term-bg: #181b22;
|
|
3
|
+
}
|
|
4
|
+
|
|
1
5
|
.container {
|
|
2
6
|
position: relative;
|
|
3
7
|
padding-bottom: 0;
|
|
@@ -16,10 +20,12 @@
|
|
|
16
20
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
17
21
|
font-size: 11px;
|
|
18
22
|
color: #8b949e;
|
|
19
|
-
pointer-events: none;
|
|
20
23
|
user-select: none;
|
|
21
24
|
white-space: nowrap;
|
|
22
25
|
z-index: 2;
|
|
26
|
+
cursor: grab;
|
|
27
|
+
padding: 2px 6px;
|
|
28
|
+
border-radius: 4px;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
[data-widget-selected] .titleBar {
|
|
@@ -30,23 +36,46 @@
|
|
|
30
36
|
position: relative;
|
|
31
37
|
border-radius: var(--base-size-16, 16px);
|
|
32
38
|
overflow: hidden;
|
|
33
|
-
background: #
|
|
39
|
+
background: var(--term-bg, #181b22);
|
|
34
40
|
border: 1px solid var(--borderColor-default, #30363d);
|
|
35
41
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.24);
|
|
36
|
-
padding:
|
|
42
|
+
padding: var(--base-size-16, 16px);
|
|
43
|
+
box-sizing: border-box;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
.xtermContainer {
|
|
40
47
|
width: 100%;
|
|
41
48
|
height: 100%;
|
|
42
49
|
box-sizing: border-box;
|
|
50
|
+
background: var(--term-bg, #181b22);
|
|
51
|
+
transition: opacity 0.3s ease;
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
/* ghostty-web / xterm.js container overrides */
|
|
46
55
|
.xtermContainer :global(.xterm) {
|
|
47
56
|
width: 100%;
|
|
48
57
|
height: 100%;
|
|
49
|
-
|
|
58
|
+
background: var(--term-bg, #181b22);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.xtermContainer :global(.xterm) canvas {
|
|
62
|
+
background: var(--term-bg, #181b22);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* Hide the native caret on ghostty-web's helper textarea —
|
|
66
|
+
without this it renders a visible blinking cursor at (0,0)
|
|
67
|
+
and triggers scrollIntoView on focus (scroll-to-top bug). */
|
|
68
|
+
.xtermContainer :global(.xterm-helper-textarea),
|
|
69
|
+
.xtermContainer textarea {
|
|
70
|
+
caret-color: transparent !important;
|
|
71
|
+
opacity: 0 !important;
|
|
72
|
+
position: absolute !important;
|
|
73
|
+
top: 0 !important;
|
|
74
|
+
left: 0 !important;
|
|
75
|
+
width: 1px !important;
|
|
76
|
+
height: 1px !important;
|
|
77
|
+
overflow: hidden !important;
|
|
78
|
+
pointer-events: none !important;
|
|
50
79
|
}
|
|
51
80
|
|
|
52
81
|
.xtermContainer :global(.xterm-viewport) {
|
|
@@ -75,10 +104,28 @@
|
|
|
75
104
|
color: #8b949e;
|
|
76
105
|
font-size: 13px;
|
|
77
106
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
78
|
-
background: #
|
|
107
|
+
background: var(--term-bg, #181b22);
|
|
79
108
|
z-index: 1;
|
|
80
109
|
}
|
|
81
110
|
|
|
111
|
+
.spinner {
|
|
112
|
+
width: 40px;
|
|
113
|
+
height: 40px;
|
|
114
|
+
background-color: #333;
|
|
115
|
+
border-radius: 100%;
|
|
116
|
+
animation: sk-scaleout 1.0s infinite ease-in-out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@keyframes sk-scaleout {
|
|
120
|
+
0% {
|
|
121
|
+
transform: scale(0);
|
|
122
|
+
}
|
|
123
|
+
100% {
|
|
124
|
+
transform: scale(1.0);
|
|
125
|
+
opacity: 0;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
82
129
|
.error {
|
|
83
130
|
position: absolute;
|
|
84
131
|
inset: 0;
|
|
@@ -88,7 +135,7 @@
|
|
|
88
135
|
color: #f85149;
|
|
89
136
|
font-size: 13px;
|
|
90
137
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
91
|
-
background: #
|
|
138
|
+
background: var(--term-bg, #181b22);
|
|
92
139
|
z-index: 1;
|
|
93
140
|
}
|
|
94
141
|
|
|
@@ -97,8 +144,8 @@
|
|
|
97
144
|
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace;
|
|
98
145
|
font-size: 16px;
|
|
99
146
|
position: absolute;
|
|
100
|
-
top:
|
|
101
|
-
left:
|
|
147
|
+
top: 8px;
|
|
148
|
+
left: 8px;
|
|
102
149
|
pointer-events: none;
|
|
103
150
|
user-select: none;
|
|
104
151
|
}
|
|
@@ -156,3 +203,219 @@
|
|
|
156
203
|
transform: translateY(-24px);
|
|
157
204
|
}
|
|
158
205
|
}
|
|
206
|
+
|
|
207
|
+
/* ── Resource-limited overlay ── */
|
|
208
|
+
|
|
209
|
+
.resourceIcon {
|
|
210
|
+
font-size: 24px;
|
|
211
|
+
margin-bottom: 4px;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.resourceTitle {
|
|
215
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
216
|
+
font-size: 14px;
|
|
217
|
+
font-weight: 600;
|
|
218
|
+
color: #d29922;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.resourceMessage {
|
|
222
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
223
|
+
font-size: 12px;
|
|
224
|
+
color: #8b949e;
|
|
225
|
+
text-align: center;
|
|
226
|
+
line-height: 1.5;
|
|
227
|
+
display: flex;
|
|
228
|
+
flex-direction: column;
|
|
229
|
+
gap: 4px;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.resourceCounts {
|
|
233
|
+
font-family: 'SF Mono', 'Fira Code', Menlo, monospace;
|
|
234
|
+
font-size: 11px;
|
|
235
|
+
color: #6e7681;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.resourceActions {
|
|
239
|
+
display: flex;
|
|
240
|
+
flex-direction: column;
|
|
241
|
+
gap: 8px;
|
|
242
|
+
align-items: center;
|
|
243
|
+
margin-top: 8px;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.resourceBtn {
|
|
247
|
+
all: unset;
|
|
248
|
+
cursor: pointer;
|
|
249
|
+
padding: 6px 16px;
|
|
250
|
+
border-radius: 6px;
|
|
251
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
252
|
+
font-size: 12px;
|
|
253
|
+
font-weight: 500;
|
|
254
|
+
color: #ffffff;
|
|
255
|
+
background: #da3633;
|
|
256
|
+
transition: background 100ms;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.resourceBtn:hover {
|
|
260
|
+
background: #f85149;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.resourceBtnSecondary {
|
|
264
|
+
all: unset;
|
|
265
|
+
cursor: pointer;
|
|
266
|
+
padding: 4px 12px;
|
|
267
|
+
border-radius: 6px;
|
|
268
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
269
|
+
font-size: 12px;
|
|
270
|
+
color: #8b949e;
|
|
271
|
+
transition: color 100ms;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.resourceBtnSecondary:hover {
|
|
275
|
+
color: #e6edf3;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.resourceMuted {
|
|
279
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
280
|
+
font-size: 11px;
|
|
281
|
+
color: #6e7681;
|
|
282
|
+
text-align: center;
|
|
283
|
+
max-width: 240px;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/* Fullscreen expand */
|
|
287
|
+
|
|
288
|
+
/* Drag hint tooltip — appears when user tries to drag the terminal body */
|
|
289
|
+
.dragHint {
|
|
290
|
+
position: absolute;
|
|
291
|
+
bottom: -32px;
|
|
292
|
+
right: 0;
|
|
293
|
+
z-index: 10;
|
|
294
|
+
display: flex;
|
|
295
|
+
align-items: center;
|
|
296
|
+
gap: 4px;
|
|
297
|
+
padding: 4px 10px;
|
|
298
|
+
border-radius: 6px;
|
|
299
|
+
background: var(--bgColor-inverse, #1f2328);
|
|
300
|
+
color: var(--fgColor-onInverse, #ffffff);
|
|
301
|
+
font-size: 12px;
|
|
302
|
+
font-weight: 500;
|
|
303
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
304
|
+
white-space: nowrap;
|
|
305
|
+
pointer-events: none;
|
|
306
|
+
animation: dragHintIn 150ms ease;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.dragHintArrow {
|
|
310
|
+
font-size: 14px;
|
|
311
|
+
opacity: 0.7;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
@keyframes dragHintIn {
|
|
315
|
+
from { opacity: 0; transform: translateY(-4px); }
|
|
316
|
+
to { opacity: 1; transform: translateY(0); }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.expandBackdrop {
|
|
320
|
+
position: fixed;
|
|
321
|
+
inset: 0;
|
|
322
|
+
z-index: 100000;
|
|
323
|
+
background: var(--term-bg, #0d1117);
|
|
324
|
+
display: flex;
|
|
325
|
+
flex-direction: column;
|
|
326
|
+
animation: expandFadeIn 0.15s ease;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
@keyframes expandFadeIn {
|
|
330
|
+
from { opacity: 0; }
|
|
331
|
+
to { opacity: 1; }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.expandTopBar {
|
|
335
|
+
display: flex;
|
|
336
|
+
align-items: center;
|
|
337
|
+
height: 40px;
|
|
338
|
+
padding: 0 12px;
|
|
339
|
+
background: #21262d;
|
|
340
|
+
border-bottom: 1px solid #30363d;
|
|
341
|
+
flex-shrink: 0;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.expandTitle {
|
|
345
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
346
|
+
font-size: 12px;
|
|
347
|
+
font-weight: 500;
|
|
348
|
+
color: #e6edf3;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
.expandEmbedLabel {
|
|
352
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
353
|
+
font-size: 12px;
|
|
354
|
+
color: #8b949e;
|
|
355
|
+
margin-left: auto;
|
|
356
|
+
margin-right: 12px;
|
|
357
|
+
overflow: hidden;
|
|
358
|
+
text-overflow: ellipsis;
|
|
359
|
+
white-space: nowrap;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
.expandClose {
|
|
363
|
+
all: unset;
|
|
364
|
+
cursor: pointer;
|
|
365
|
+
margin-left: auto;
|
|
366
|
+
width: 28px;
|
|
367
|
+
height: 28px;
|
|
368
|
+
display: flex;
|
|
369
|
+
align-items: center;
|
|
370
|
+
justify-content: center;
|
|
371
|
+
border-radius: 6px;
|
|
372
|
+
color: #8b949e;
|
|
373
|
+
font-size: 14px;
|
|
374
|
+
transition: background 100ms, color 100ms;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.expandClose:hover {
|
|
378
|
+
background: #30363d;
|
|
379
|
+
color: #e6edf3;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.expandEmbedLabel + .expandClose {
|
|
383
|
+
margin-left: 0;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
.expandBody {
|
|
387
|
+
flex: 1;
|
|
388
|
+
min-height: 0;
|
|
389
|
+
display: flex;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
.expandTerminal {
|
|
393
|
+
flex: 1;
|
|
394
|
+
min-width: 0;
|
|
395
|
+
overflow: hidden;
|
|
396
|
+
background: var(--term-bg, #0d1117);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
.expandTerminal :global(.xterm) {
|
|
400
|
+
height: 100%;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
.expandSplit .expandTerminal {
|
|
404
|
+
flex: 1;
|
|
405
|
+
border-right: 1px solid #30363d;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
.expandSplit .expandEmbed {
|
|
409
|
+
flex: 1;
|
|
410
|
+
min-width: 0;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
.expandEmbed {
|
|
414
|
+
display: flex;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
.expandIframe {
|
|
418
|
+
border: none;
|
|
419
|
+
width: 100%;
|
|
420
|
+
height: 100%;
|
|
421
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect, forwardRef, useImperativeHandle, useMemo } from 'react'
|
|
2
|
+
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
3
|
+
import ResizeHandle from './ResizeHandle.jsx'
|
|
4
|
+
import { readProp } from './widgetProps.js'
|
|
5
|
+
import { schemas } from './widgetConfig.js'
|
|
6
|
+
import { TILE_POOL } from './tilePool.js'
|
|
7
|
+
import styles from './TilesWidget.module.css'
|
|
8
|
+
|
|
9
|
+
const tilesSchema = schemas['tiles']
|
|
10
|
+
const LS_PREFIX = 'storyboard-tiles-'
|
|
11
|
+
|
|
12
|
+
/** Read persisted state from localStorage for a given widget ID. */
|
|
13
|
+
function loadFromStorage(widgetId) {
|
|
14
|
+
try {
|
|
15
|
+
const raw = localStorage.getItem(`${LS_PREFIX}${widgetId}`)
|
|
16
|
+
return raw ? JSON.parse(raw) : null
|
|
17
|
+
} catch { return null }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Save state to localStorage for a given widget ID. */
|
|
21
|
+
function saveToStorage(widgetId, state) {
|
|
22
|
+
try {
|
|
23
|
+
localStorage.setItem(`${LS_PREFIX}${widgetId}`, JSON.stringify(state))
|
|
24
|
+
} catch { /* quota exceeded — silently ignore */ }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Canvas widget that arranges square images in an interactive tile grid.
|
|
29
|
+
*
|
|
30
|
+
* Uses a static pool of tile images bundled with the widget (see tilePool.js).
|
|
31
|
+
* In production, state is persisted to localStorage since canvas editing is read-only.
|
|
32
|
+
*
|
|
33
|
+
* Features:
|
|
34
|
+
* - Configurable columns × rows (toolbar actions in dev, inline buttons in prod)
|
|
35
|
+
* - Drag & drop reorder
|
|
36
|
+
* - Click-select + Cmd+C / Cmd+V to copy-paste tiles
|
|
37
|
+
* - Randomize action
|
|
38
|
+
* - Tile composition persisted as indexes into TILE_POOL
|
|
39
|
+
*/
|
|
40
|
+
const TilesWidget = forwardRef(function TilesWidget({ id, props, onUpdate, resizable }, ref) {
|
|
41
|
+
const containerRef = useRef(null)
|
|
42
|
+
const isProd = !onUpdate
|
|
43
|
+
|
|
44
|
+
// In prod, load initial state from localStorage
|
|
45
|
+
const [localState, setLocalState] = useState(() => loadFromStorage(id))
|
|
46
|
+
|
|
47
|
+
const columns = (isProd ? localState?.columns : null) ?? (readProp(props, 'columns', tilesSchema) || 3)
|
|
48
|
+
const rows = (isProd ? localState?.rows : null) ?? (readProp(props, 'rows', tilesSchema) || 3)
|
|
49
|
+
const tileSize = readProp(props, 'tileSize', tilesSchema) || 80
|
|
50
|
+
const savedTiles = (isProd ? localState?.tiles : null) ?? readProp(props, 'tiles', tilesSchema)
|
|
51
|
+
|
|
52
|
+
// Local state for interactions
|
|
53
|
+
const [selectedIdx, setSelectedIdx] = useState(null)
|
|
54
|
+
const [copiedSrc, setCopiedSrc] = useState(null)
|
|
55
|
+
const [dragIdx, setDragIdx] = useState(null)
|
|
56
|
+
const [dragOverIdx, setDragOverIdx] = useState(null)
|
|
57
|
+
|
|
58
|
+
// Clear selection when exiting interact mode (dev only — prod has no gate)
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (isProd) return
|
|
61
|
+
const el = containerRef.current
|
|
62
|
+
if (!el) return
|
|
63
|
+
const observer = new MutationObserver(() => {
|
|
64
|
+
const slot = el.closest('[data-widget-interacting]')
|
|
65
|
+
if (!slot) {
|
|
66
|
+
setSelectedIdx(null)
|
|
67
|
+
setCopiedSrc(null)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
const slot = el.closest('[data-widget-selected]')?.parentElement
|
|
71
|
+
if (slot) observer.observe(slot, { attributes: true, attributeFilter: ['data-widget-interacting'] })
|
|
72
|
+
return () => observer.disconnect()
|
|
73
|
+
}, [isProd])
|
|
74
|
+
|
|
75
|
+
// Build effective tile list from saved indexes or default pool
|
|
76
|
+
const tiles = useMemo(() => {
|
|
77
|
+
if (savedTiles && savedTiles.length > 0) {
|
|
78
|
+
return savedTiles.map((idx) =>
|
|
79
|
+
typeof idx === 'number' ? (TILE_POOL[idx] ?? TILE_POOL[0]) : TILE_POOL[0]
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
return [...TILE_POOL]
|
|
83
|
+
}, [savedTiles])
|
|
84
|
+
|
|
85
|
+
// Total visible slots
|
|
86
|
+
const slotCount = columns * rows
|
|
87
|
+
const visibleTiles = tiles.slice(0, slotCount)
|
|
88
|
+
|
|
89
|
+
// Pad with nulls if we have fewer images than slots
|
|
90
|
+
const grid = useMemo(() => {
|
|
91
|
+
const g = [...visibleTiles]
|
|
92
|
+
while (g.length < slotCount) g.push(null)
|
|
93
|
+
return g
|
|
94
|
+
}, [visibleTiles, slotCount])
|
|
95
|
+
|
|
96
|
+
// Persist — writes to onUpdate (dev) or localStorage (prod)
|
|
97
|
+
const persist = useCallback((patch) => {
|
|
98
|
+
if (isProd) {
|
|
99
|
+
setLocalState((prev) => {
|
|
100
|
+
const next = { ...prev, ...patch }
|
|
101
|
+
saveToStorage(id, next)
|
|
102
|
+
return next
|
|
103
|
+
})
|
|
104
|
+
} else {
|
|
105
|
+
onUpdate?.(patch)
|
|
106
|
+
}
|
|
107
|
+
}, [isProd, id, onUpdate])
|
|
108
|
+
|
|
109
|
+
const persistTiles = useCallback((srcs) => {
|
|
110
|
+
const indexes = srcs.map((src) => {
|
|
111
|
+
const idx = TILE_POOL.indexOf(src)
|
|
112
|
+
return idx >= 0 ? idx : 0
|
|
113
|
+
})
|
|
114
|
+
persist({ tiles: indexes })
|
|
115
|
+
}, [persist])
|
|
116
|
+
|
|
117
|
+
// ── Actions (shared by toolbar handleAction and inline buttons) ──
|
|
118
|
+
const addColumn = useCallback(() => persist({ columns: columns + 1 }), [columns, persist])
|
|
119
|
+
const removeColumn = useCallback(() => { if (columns > 1) persist({ columns: columns - 1 }) }, [columns, persist])
|
|
120
|
+
const addRow = useCallback(() => persist({ rows: rows + 1 }), [rows, persist])
|
|
121
|
+
const removeRow = useCallback(() => { if (rows > 1) persist({ rows: rows - 1 }) }, [rows, persist])
|
|
122
|
+
const randomize = useCallback(() => {
|
|
123
|
+
const total = columns * rows
|
|
124
|
+
const filled = Array.from({ length: total }, () =>
|
|
125
|
+
TILE_POOL[Math.floor(Math.random() * TILE_POOL.length)]
|
|
126
|
+
)
|
|
127
|
+
persistTiles(filled)
|
|
128
|
+
}, [columns, rows, persistTiles])
|
|
129
|
+
|
|
130
|
+
// ── Keyboard: Cmd+C / Cmd+V ──
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
function handleKeyDown(e) {
|
|
133
|
+
const el = containerRef.current
|
|
134
|
+
if (!el) return
|
|
135
|
+
// In dev, only respond when interacting; in prod, always respond when focused
|
|
136
|
+
if (!isProd) {
|
|
137
|
+
const slot = el.closest('[data-widget-interacting]')
|
|
138
|
+
if (!slot) return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!e.metaKey && !e.ctrlKey) return
|
|
142
|
+
if (e.key === 'c' && selectedIdx !== null && grid[selectedIdx]) {
|
|
143
|
+
e.stopPropagation()
|
|
144
|
+
e.preventDefault()
|
|
145
|
+
setCopiedSrc(grid[selectedIdx])
|
|
146
|
+
}
|
|
147
|
+
if (e.key === 'v' && selectedIdx !== null && copiedSrc) {
|
|
148
|
+
e.stopPropagation()
|
|
149
|
+
e.preventDefault()
|
|
150
|
+
const newGrid = [...grid]
|
|
151
|
+
newGrid[selectedIdx] = copiedSrc
|
|
152
|
+
persistTiles(newGrid.filter(Boolean))
|
|
153
|
+
setCopiedSrc(null)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
window.addEventListener('keydown', handleKeyDown, true)
|
|
157
|
+
return () => window.removeEventListener('keydown', handleKeyDown, true)
|
|
158
|
+
}, [selectedIdx, copiedSrc, grid, persistTiles, isProd])
|
|
159
|
+
|
|
160
|
+
// ── Drag & drop handlers ──
|
|
161
|
+
const handleDragStart = useCallback((e, idx) => {
|
|
162
|
+
e.stopPropagation()
|
|
163
|
+
setDragIdx(idx)
|
|
164
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
165
|
+
e.dataTransfer.setData('text/plain', String(idx))
|
|
166
|
+
}, [])
|
|
167
|
+
|
|
168
|
+
const handleDragOver = useCallback((e, idx) => {
|
|
169
|
+
e.preventDefault()
|
|
170
|
+
e.stopPropagation()
|
|
171
|
+
e.dataTransfer.dropEffect = 'move'
|
|
172
|
+
setDragOverIdx(idx)
|
|
173
|
+
}, [])
|
|
174
|
+
|
|
175
|
+
const handleDragLeave = useCallback(() => {
|
|
176
|
+
setDragOverIdx(null)
|
|
177
|
+
}, [])
|
|
178
|
+
|
|
179
|
+
const handleDrop = useCallback((e, toIdx) => {
|
|
180
|
+
e.preventDefault()
|
|
181
|
+
e.stopPropagation()
|
|
182
|
+
const fromIdx = dragIdx
|
|
183
|
+
setDragIdx(null)
|
|
184
|
+
setDragOverIdx(null)
|
|
185
|
+
if (fromIdx === null || fromIdx === toIdx) return
|
|
186
|
+
const newGrid = [...grid]
|
|
187
|
+
;[newGrid[fromIdx], newGrid[toIdx]] = [newGrid[toIdx], newGrid[fromIdx]]
|
|
188
|
+
persistTiles(newGrid.filter(Boolean))
|
|
189
|
+
}, [dragIdx, grid, persistTiles])
|
|
190
|
+
|
|
191
|
+
const handleDragEnd = useCallback(() => {
|
|
192
|
+
setDragIdx(null)
|
|
193
|
+
setDragOverIdx(null)
|
|
194
|
+
}, [])
|
|
195
|
+
|
|
196
|
+
// ── Tile click ──
|
|
197
|
+
const handleTileClick = useCallback((e, idx) => {
|
|
198
|
+
e.stopPropagation()
|
|
199
|
+
setSelectedIdx((prev) => (prev === idx ? null : idx))
|
|
200
|
+
}, [])
|
|
201
|
+
|
|
202
|
+
const handleBackgroundClick = useCallback(() => {
|
|
203
|
+
setSelectedIdx(null)
|
|
204
|
+
}, [])
|
|
205
|
+
|
|
206
|
+
// ── Widget actions (dev toolbar) ──
|
|
207
|
+
useImperativeHandle(ref, () => ({
|
|
208
|
+
handleAction(actionId) {
|
|
209
|
+
if (actionId === 'add-column') { addColumn(); return true }
|
|
210
|
+
if (actionId === 'remove-column') { removeColumn(); return true }
|
|
211
|
+
if (actionId === 'add-row') { addRow(); return true }
|
|
212
|
+
if (actionId === 'remove-row') { removeRow(); return true }
|
|
213
|
+
if (actionId === 'randomize') { randomize(); return true }
|
|
214
|
+
},
|
|
215
|
+
}), [addColumn, removeColumn, addRow, removeRow, randomize])
|
|
216
|
+
|
|
217
|
+
const gridStyle = {
|
|
218
|
+
gridTemplateColumns: `repeat(${columns}, ${tileSize}px)`,
|
|
219
|
+
gridTemplateRows: `repeat(${rows}, ${tileSize}px)`,
|
|
220
|
+
gap: '2px',
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<WidgetWrapper>
|
|
225
|
+
<div
|
|
226
|
+
ref={containerRef}
|
|
227
|
+
className={styles.container}
|
|
228
|
+
onClick={handleBackgroundClick}
|
|
229
|
+
data-tiles-widget
|
|
230
|
+
>
|
|
231
|
+
{isProd && (
|
|
232
|
+
<div className={styles.toolbar}>
|
|
233
|
+
<button className={styles.toolbarBtn} onClick={(e) => { e.stopPropagation(); randomize() }} title="Randomize">🔀</button>
|
|
234
|
+
<span className={styles.toolbarSep} />
|
|
235
|
+
<button className={styles.toolbarBtn} onClick={(e) => { e.stopPropagation(); removeColumn() }} title="Remove column" disabled={columns <= 1}>−</button>
|
|
236
|
+
<span className={styles.toolbarLabel}>{columns}×{rows}</span>
|
|
237
|
+
<button className={styles.toolbarBtn} onClick={(e) => { e.stopPropagation(); addColumn() }} title="Add column">+</button>
|
|
238
|
+
<span className={styles.toolbarSep} />
|
|
239
|
+
<button className={styles.toolbarBtn} onClick={(e) => { e.stopPropagation(); removeRow() }} title="Remove row" disabled={rows <= 1}>↑</button>
|
|
240
|
+
<button className={styles.toolbarBtn} onClick={(e) => { e.stopPropagation(); addRow() }} title="Add row">↓</button>
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
<div className={styles.grid} style={gridStyle}>
|
|
244
|
+
{grid.map((src, idx) => (
|
|
245
|
+
<div
|
|
246
|
+
key={`${idx}-${src || 'empty'}`}
|
|
247
|
+
className={[
|
|
248
|
+
styles.tile,
|
|
249
|
+
selectedIdx === idx ? styles.selected : '',
|
|
250
|
+
copiedSrc && selectedIdx === idx ? styles.pasteTarget : '',
|
|
251
|
+
dragOverIdx === idx ? styles.dragOver : '',
|
|
252
|
+
dragIdx === idx ? styles.dragging : '',
|
|
253
|
+
].filter(Boolean).join(' ')}
|
|
254
|
+
draggable={!!src}
|
|
255
|
+
onDragStart={(e) => handleDragStart(e, idx)}
|
|
256
|
+
onDragOver={(e) => handleDragOver(e, idx)}
|
|
257
|
+
onDragLeave={handleDragLeave}
|
|
258
|
+
onDrop={(e) => handleDrop(e, idx)}
|
|
259
|
+
onDragEnd={handleDragEnd}
|
|
260
|
+
onClick={(e) => handleTileClick(e, idx)}
|
|
261
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
262
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
263
|
+
style={{ width: tileSize, height: tileSize }}
|
|
264
|
+
>
|
|
265
|
+
{src ? (
|
|
266
|
+
<img
|
|
267
|
+
src={src}
|
|
268
|
+
alt=""
|
|
269
|
+
className={styles.tileImage}
|
|
270
|
+
draggable={false}
|
|
271
|
+
/>
|
|
272
|
+
) : (
|
|
273
|
+
<span className={styles.emptyTile} />
|
|
274
|
+
)}
|
|
275
|
+
{copiedSrc && selectedIdx === idx && (
|
|
276
|
+
<span className={styles.pasteHint}>⌘V</span>
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
))}
|
|
280
|
+
</div>
|
|
281
|
+
{selectedIdx !== null && grid[selectedIdx] && !copiedSrc && (
|
|
282
|
+
<div className={styles.hint}>⌘C to copy · click another tile · ⌘V to paste</div>
|
|
283
|
+
)}
|
|
284
|
+
{copiedSrc && selectedIdx !== null && (
|
|
285
|
+
<div className={styles.hint}>⌘V to replace this tile</div>
|
|
286
|
+
)}
|
|
287
|
+
{resizable && (
|
|
288
|
+
<ResizeHandle
|
|
289
|
+
targetRef={containerRef}
|
|
290
|
+
minWidth={200}
|
|
291
|
+
minHeight={100}
|
|
292
|
+
onResize={(w, h) => onUpdate?.({ width: w, height: h })}
|
|
293
|
+
/>
|
|
294
|
+
)}
|
|
295
|
+
</div>
|
|
296
|
+
</WidgetWrapper>
|
|
297
|
+
)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
export default TilesWidget
|