@dfosco/storyboard-react 4.2.0-beta.17 → 4.2.0-beta.18

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.
Files changed (79) hide show
  1. package/package.json +3 -3
  2. package/src/BranchBar/BranchBar.jsx +3 -1
  3. package/src/BranchBar/BranchBar.module.css +2 -2
  4. package/src/BranchBar/useBranches.js +20 -6
  5. package/src/BranchBar/useBranches.test.js +68 -0
  6. package/src/CommandPalette/CommandPalette.jsx +250 -61
  7. package/src/CommandPalette/command-palette.css +12 -0
  8. package/src/Icon.jsx +46 -11
  9. package/src/Viewfinder.jsx +53 -133
  10. package/src/Viewfinder.module.css +20 -91
  11. package/src/Workspace.jsx +7 -0
  12. package/src/canvas/CanvasPage.jsx +601 -62
  13. package/src/canvas/CanvasPage.module.css +15 -2
  14. package/src/canvas/CanvasPage.multiselect.test.jsx +7 -0
  15. package/src/canvas/ConnectorLayer.jsx +120 -152
  16. package/src/canvas/ConnectorLayer.module.css +69 -0
  17. package/src/canvas/canvasApi.js +68 -2
  18. package/src/canvas/connectorGeometry.js +132 -0
  19. package/src/canvas/hotPoolDevLogs.js +25 -0
  20. package/src/canvas/useMarqueeSelect.js +30 -4
  21. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  22. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  23. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  24. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  25. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  26. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  27. package/src/canvas/widgets/ExpandedPane.jsx +472 -0
  28. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  29. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  30. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  31. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  32. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  33. package/src/canvas/widgets/FigmaEmbed.jsx +49 -102
  34. package/src/canvas/widgets/ImageWidget.jsx +129 -8
  35. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  36. package/src/canvas/widgets/LinkPreview.jsx +93 -44
  37. package/src/canvas/widgets/MarkdownBlock.jsx +141 -16
  38. package/src/canvas/widgets/MarkdownBlock.module.css +25 -0
  39. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  40. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  41. package/src/canvas/widgets/PrototypeEmbed.jsx +46 -170
  42. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  43. package/src/canvas/widgets/StoryWidget.jsx +65 -11
  44. package/src/canvas/widgets/TerminalReadWidget.jsx +11 -5
  45. package/src/canvas/widgets/TerminalReadWidget.module.css +3 -1
  46. package/src/canvas/widgets/TerminalWidget.jsx +301 -124
  47. package/src/canvas/widgets/TerminalWidget.module.css +121 -12
  48. package/src/canvas/widgets/TilesWidget.jsx +302 -0
  49. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  50. package/src/canvas/widgets/WidgetChrome.jsx +67 -152
  51. package/src/canvas/widgets/WidgetChrome.module.css +20 -1
  52. package/src/canvas/widgets/expandUtils.js +385 -16
  53. package/src/canvas/widgets/expandUtils.test.js +155 -0
  54. package/src/canvas/widgets/index.js +6 -2
  55. package/src/canvas/widgets/tilePool.js +23 -0
  56. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  57. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  58. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  59. package/src/canvas/widgets/tiles/leaf.png +0 -0
  60. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  61. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  62. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  63. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  64. package/src/canvas/widgets/widgetConfig.js +37 -4
  65. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  66. package/src/canvas/widgets/widgetProps.js +1 -0
  67. package/src/context.jsx +47 -19
  68. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  69. package/src/index.js +4 -2
  70. package/src/story/ComponentSetPage.jsx +186 -0
  71. package/src/story/ComponentSetPage.module.css +121 -0
  72. package/src/story/StoryPage.jsx +32 -2
  73. package/src/vite/data-plugin.js +79 -35
  74. package/src/canvas/widgets/ActionWidget.jsx +0 -200
  75. package/src/canvas/widgets/ActionWidget.module.css +0 -122
  76. package/src/canvas/widgets/SplitExpandModal.jsx +0 -234
  77. package/src/canvas/widgets/SplitExpandModal.module.css +0 -335
  78. package/src/canvas/widgets/SplitScreenTopBar.jsx +0 -30
  79. package/src/canvas/widgets/SplitScreenTopBar.module.css +0 -58
@@ -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,24 +36,30 @@
30
36
  position: relative;
31
37
  border-radius: var(--base-size-16, 16px);
32
38
  overflow: hidden;
33
- background: #0d1117;
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: var(--base-size-8, 8px);
37
- padding-bottom: var(--base-size-16, 16px);
42
+ padding: var(--base-size-16, 16px);
43
+ box-sizing: border-box;
38
44
  }
39
45
 
40
46
  .xtermContainer {
41
47
  width: 100%;
42
48
  height: 100%;
43
49
  box-sizing: border-box;
50
+ background: var(--term-bg, #181b22);
51
+ transition: opacity 0.3s ease;
44
52
  }
45
53
 
46
54
  /* ghostty-web / xterm.js container overrides */
47
55
  .xtermContainer :global(.xterm) {
48
56
  width: 100%;
49
57
  height: 100%;
50
- padding: 12px;
58
+ background: var(--term-bg, #181b22);
59
+ }
60
+
61
+ .xtermContainer :global(.xterm) canvas {
62
+ background: var(--term-bg, #181b22);
51
63
  }
52
64
 
53
65
  /* Hide the native caret on ghostty-web's helper textarea —
@@ -92,10 +104,28 @@
92
104
  color: #8b949e;
93
105
  font-size: 13px;
94
106
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
95
- background: #0d1117;
107
+ background: var(--term-bg, #181b22);
96
108
  z-index: 1;
97
109
  }
98
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
+
99
129
  .error {
100
130
  position: absolute;
101
131
  inset: 0;
@@ -105,7 +135,7 @@
105
135
  color: #f85149;
106
136
  font-size: 13px;
107
137
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
108
- background: #0d1117;
138
+ background: var(--term-bg, #181b22);
109
139
  z-index: 1;
110
140
  }
111
141
 
@@ -114,8 +144,8 @@
114
144
  font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace;
115
145
  font-size: 16px;
116
146
  position: absolute;
117
- top: 12px;
118
- left: 16px;
147
+ top: 8px;
148
+ left: 8px;
119
149
  pointer-events: none;
120
150
  user-select: none;
121
151
  }
@@ -174,6 +204,85 @@
174
204
  }
175
205
  }
176
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
+
177
286
  /* Fullscreen expand */
178
287
 
179
288
  /* Drag hint tooltip — appears when user tries to drag the terminal body */
@@ -211,7 +320,7 @@
211
320
  position: fixed;
212
321
  inset: 0;
213
322
  z-index: 100000;
214
- background: #0d1117;
323
+ background: var(--term-bg, #0d1117);
215
324
  display: flex;
216
325
  flex-direction: column;
217
326
  animation: expandFadeIn 0.15s ease;
@@ -227,7 +336,7 @@
227
336
  align-items: center;
228
337
  height: 40px;
229
338
  padding: 0 12px;
230
- background: #161b22;
339
+ background: #21262d;
231
340
  border-bottom: 1px solid #30363d;
232
341
  flex-shrink: 0;
233
342
  }
@@ -284,7 +393,7 @@
284
393
  flex: 1;
285
394
  min-width: 0;
286
395
  overflow: hidden;
287
- background: #0d1117;
396
+ background: var(--term-bg, #0d1117);
288
397
  }
289
398
 
290
399
  .expandTerminal :global(.xterm) {
@@ -0,0 +1,302 @@
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 width = readProp(props, 'width', tilesSchema)
51
+ const height = readProp(props, 'height', tilesSchema)
52
+ const savedTiles = (isProd ? localState?.tiles : null) ?? readProp(props, 'tiles', tilesSchema)
53
+
54
+ // Local state for interactions
55
+ const [selectedIdx, setSelectedIdx] = useState(null)
56
+ const [copiedSrc, setCopiedSrc] = useState(null)
57
+ const [dragIdx, setDragIdx] = useState(null)
58
+ const [dragOverIdx, setDragOverIdx] = useState(null)
59
+
60
+ // Clear selection when exiting interact mode (dev only — prod has no gate)
61
+ useEffect(() => {
62
+ if (isProd) return
63
+ const el = containerRef.current
64
+ if (!el) return
65
+ const observer = new MutationObserver(() => {
66
+ const slot = el.closest('[data-widget-interacting]')
67
+ if (!slot) {
68
+ setSelectedIdx(null)
69
+ setCopiedSrc(null)
70
+ }
71
+ })
72
+ const slot = el.closest('[data-widget-selected]')?.parentElement
73
+ if (slot) observer.observe(slot, { attributes: true, attributeFilter: ['data-widget-interacting'] })
74
+ return () => observer.disconnect()
75
+ }, [isProd])
76
+
77
+ // Build effective tile list from saved indexes or default pool
78
+ const tiles = useMemo(() => {
79
+ if (savedTiles && savedTiles.length > 0) {
80
+ return savedTiles.map((idx) =>
81
+ typeof idx === 'number' ? (TILE_POOL[idx] ?? TILE_POOL[0]) : TILE_POOL[0]
82
+ )
83
+ }
84
+ return [...TILE_POOL]
85
+ }, [savedTiles])
86
+
87
+ // Total visible slots
88
+ const slotCount = columns * rows
89
+ const visibleTiles = tiles.slice(0, slotCount)
90
+
91
+ // Pad with nulls if we have fewer images than slots
92
+ const grid = useMemo(() => {
93
+ const g = [...visibleTiles]
94
+ while (g.length < slotCount) g.push(null)
95
+ return g
96
+ }, [visibleTiles, slotCount])
97
+
98
+ // Persist — writes to onUpdate (dev) or localStorage (prod)
99
+ const persist = useCallback((patch) => {
100
+ if (isProd) {
101
+ setLocalState((prev) => {
102
+ const next = { ...prev, ...patch }
103
+ saveToStorage(id, next)
104
+ return next
105
+ })
106
+ } else {
107
+ onUpdate?.(patch)
108
+ }
109
+ }, [isProd, id, onUpdate])
110
+
111
+ const persistTiles = useCallback((srcs) => {
112
+ const indexes = srcs.map((src) => {
113
+ const idx = TILE_POOL.indexOf(src)
114
+ return idx >= 0 ? idx : 0
115
+ })
116
+ persist({ tiles: indexes })
117
+ }, [persist])
118
+
119
+ // ── Actions (shared by toolbar handleAction and inline buttons) ──
120
+ const addColumn = useCallback(() => persist({ columns: columns + 1 }), [columns, persist])
121
+ const removeColumn = useCallback(() => { if (columns > 1) persist({ columns: columns - 1 }) }, [columns, persist])
122
+ const addRow = useCallback(() => persist({ rows: rows + 1 }), [rows, persist])
123
+ const removeRow = useCallback(() => { if (rows > 1) persist({ rows: rows - 1 }) }, [rows, persist])
124
+ const randomize = useCallback(() => {
125
+ const total = columns * rows
126
+ const filled = Array.from({ length: total }, () =>
127
+ TILE_POOL[Math.floor(Math.random() * TILE_POOL.length)]
128
+ )
129
+ persistTiles(filled)
130
+ }, [columns, rows, persistTiles])
131
+
132
+ // ── Keyboard: Cmd+C / Cmd+V ──
133
+ useEffect(() => {
134
+ function handleKeyDown(e) {
135
+ const el = containerRef.current
136
+ if (!el) return
137
+ // In dev, only respond when interacting; in prod, always respond when focused
138
+ if (!isProd) {
139
+ const slot = el.closest('[data-widget-interacting]')
140
+ if (!slot) return
141
+ }
142
+
143
+ if (!e.metaKey && !e.ctrlKey) return
144
+ if (e.key === 'c' && selectedIdx !== null && grid[selectedIdx]) {
145
+ e.stopPropagation()
146
+ e.preventDefault()
147
+ setCopiedSrc(grid[selectedIdx])
148
+ }
149
+ if (e.key === 'v' && selectedIdx !== null && copiedSrc) {
150
+ e.stopPropagation()
151
+ e.preventDefault()
152
+ const newGrid = [...grid]
153
+ newGrid[selectedIdx] = copiedSrc
154
+ persistTiles(newGrid.filter(Boolean))
155
+ setCopiedSrc(null)
156
+ }
157
+ }
158
+ window.addEventListener('keydown', handleKeyDown, true)
159
+ return () => window.removeEventListener('keydown', handleKeyDown, true)
160
+ }, [selectedIdx, copiedSrc, grid, persistTiles, isProd])
161
+
162
+ // ── Drag & drop handlers ──
163
+ const handleDragStart = useCallback((e, idx) => {
164
+ e.stopPropagation()
165
+ setDragIdx(idx)
166
+ e.dataTransfer.effectAllowed = 'move'
167
+ e.dataTransfer.setData('text/plain', String(idx))
168
+ }, [])
169
+
170
+ const handleDragOver = useCallback((e, idx) => {
171
+ e.preventDefault()
172
+ e.stopPropagation()
173
+ e.dataTransfer.dropEffect = 'move'
174
+ setDragOverIdx(idx)
175
+ }, [])
176
+
177
+ const handleDragLeave = useCallback(() => {
178
+ setDragOverIdx(null)
179
+ }, [])
180
+
181
+ const handleDrop = useCallback((e, toIdx) => {
182
+ e.preventDefault()
183
+ e.stopPropagation()
184
+ const fromIdx = dragIdx
185
+ setDragIdx(null)
186
+ setDragOverIdx(null)
187
+ if (fromIdx === null || fromIdx === toIdx) return
188
+ const newGrid = [...grid]
189
+ ;[newGrid[fromIdx], newGrid[toIdx]] = [newGrid[toIdx], newGrid[fromIdx]]
190
+ persistTiles(newGrid.filter(Boolean))
191
+ }, [dragIdx, grid, persistTiles])
192
+
193
+ const handleDragEnd = useCallback(() => {
194
+ setDragIdx(null)
195
+ setDragOverIdx(null)
196
+ }, [])
197
+
198
+ // ── Tile click ──
199
+ const handleTileClick = useCallback((e, idx) => {
200
+ e.stopPropagation()
201
+ setSelectedIdx((prev) => (prev === idx ? null : idx))
202
+ }, [])
203
+
204
+ const handleBackgroundClick = useCallback(() => {
205
+ setSelectedIdx(null)
206
+ }, [])
207
+
208
+ // ── Widget actions (dev toolbar) ──
209
+ useImperativeHandle(ref, () => ({
210
+ handleAction(actionId) {
211
+ if (actionId === 'add-column') { addColumn(); return true }
212
+ if (actionId === 'remove-column') { removeColumn(); return true }
213
+ if (actionId === 'add-row') { addRow(); return true }
214
+ if (actionId === 'remove-row') { removeRow(); return true }
215
+ if (actionId === 'randomize') { randomize(); return true }
216
+ },
217
+ }), [addColumn, removeColumn, addRow, removeRow, randomize])
218
+
219
+ const gridStyle = {
220
+ gridTemplateColumns: `repeat(${columns}, ${tileSize}px)`,
221
+ gridTemplateRows: `repeat(${rows}, ${tileSize}px)`,
222
+ gap: '2px',
223
+ }
224
+
225
+ return (
226
+ <WidgetWrapper>
227
+ <div
228
+ ref={containerRef}
229
+ className={styles.container}
230
+ onClick={handleBackgroundClick}
231
+ data-tiles-widget
232
+ >
233
+ {isProd && (
234
+ <div className={styles.toolbar}>
235
+ <button className={styles.toolbarBtn} onClick={(e) => { e.stopPropagation(); randomize() }} title="Randomize">🔀</button>
236
+ <span className={styles.toolbarSep} />
237
+ <button className={styles.toolbarBtn} onClick={(e) => { e.stopPropagation(); removeColumn() }} title="Remove column" disabled={columns <= 1}>−</button>
238
+ <span className={styles.toolbarLabel}>{columns}×{rows}</span>
239
+ <button className={styles.toolbarBtn} onClick={(e) => { e.stopPropagation(); addColumn() }} title="Add column">+</button>
240
+ <span className={styles.toolbarSep} />
241
+ <button className={styles.toolbarBtn} onClick={(e) => { e.stopPropagation(); removeRow() }} title="Remove row" disabled={rows <= 1}>↑</button>
242
+ <button className={styles.toolbarBtn} onClick={(e) => { e.stopPropagation(); addRow() }} title="Add row">↓</button>
243
+ </div>
244
+ )}
245
+ <div className={styles.grid} style={gridStyle}>
246
+ {grid.map((src, idx) => (
247
+ <div
248
+ key={`${idx}-${src || 'empty'}`}
249
+ className={[
250
+ styles.tile,
251
+ selectedIdx === idx ? styles.selected : '',
252
+ copiedSrc && selectedIdx === idx ? styles.pasteTarget : '',
253
+ dragOverIdx === idx ? styles.dragOver : '',
254
+ dragIdx === idx ? styles.dragging : '',
255
+ ].filter(Boolean).join(' ')}
256
+ draggable={!!src}
257
+ onDragStart={(e) => handleDragStart(e, idx)}
258
+ onDragOver={(e) => handleDragOver(e, idx)}
259
+ onDragLeave={handleDragLeave}
260
+ onDrop={(e) => handleDrop(e, idx)}
261
+ onDragEnd={handleDragEnd}
262
+ onClick={(e) => handleTileClick(e, idx)}
263
+ onMouseDown={(e) => e.stopPropagation()}
264
+ onPointerDown={(e) => e.stopPropagation()}
265
+ style={{ width: tileSize, height: tileSize }}
266
+ >
267
+ {src ? (
268
+ <img
269
+ src={src}
270
+ alt=""
271
+ className={styles.tileImage}
272
+ draggable={false}
273
+ />
274
+ ) : (
275
+ <span className={styles.emptyTile} />
276
+ )}
277
+ {copiedSrc && selectedIdx === idx && (
278
+ <span className={styles.pasteHint}>⌘V</span>
279
+ )}
280
+ </div>
281
+ ))}
282
+ </div>
283
+ {selectedIdx !== null && grid[selectedIdx] && !copiedSrc && (
284
+ <div className={styles.hint}>⌘C to copy · click another tile · ⌘V to paste</div>
285
+ )}
286
+ {copiedSrc && selectedIdx !== null && (
287
+ <div className={styles.hint}>⌘V to replace this tile</div>
288
+ )}
289
+ {resizable && (
290
+ <ResizeHandle
291
+ targetRef={containerRef}
292
+ minWidth={200}
293
+ minHeight={100}
294
+ onResize={(w, h) => onUpdate?.({ width: w, height: h })}
295
+ />
296
+ )}
297
+ </div>
298
+ </WidgetWrapper>
299
+ )
300
+ })
301
+
302
+ export default TilesWidget
@@ -0,0 +1,133 @@
1
+ .container {
2
+ padding: 8px;
3
+ user-select: none;
4
+ cursor: default;
5
+ }
6
+
7
+ .toolbar {
8
+ display: flex;
9
+ align-items: center;
10
+ gap: 4px;
11
+ padding: 4px 0 8px;
12
+ }
13
+
14
+ .toolbarBtn {
15
+ all: unset;
16
+ display: inline-flex;
17
+ align-items: center;
18
+ justify-content: center;
19
+ width: 28px;
20
+ height: 28px;
21
+ border-radius: 6px;
22
+ font-size: 14px;
23
+ cursor: pointer;
24
+ color: var(--fgColor-default, #1f2328);
25
+ background: var(--bgColor-muted, #f6f8fa);
26
+ transition: background 0.12s ease;
27
+ }
28
+
29
+ .toolbarBtn:hover {
30
+ background: var(--bgColor-neutral-muted, #d0d7de);
31
+ }
32
+
33
+ .toolbarBtn:disabled {
34
+ opacity: 0.35;
35
+ cursor: default;
36
+ }
37
+
38
+ .toolbarLabel {
39
+ font-size: 12px;
40
+ font-weight: 600;
41
+ color: var(--fgColor-muted, #656d76);
42
+ min-width: 32px;
43
+ text-align: center;
44
+ }
45
+
46
+ .toolbarSep {
47
+ width: 1px;
48
+ height: 16px;
49
+ background: var(--borderColor-muted, #d0d7de);
50
+ margin: 0 4px;
51
+ }
52
+
53
+ .grid {
54
+ display: grid;
55
+ border-radius: 6px;
56
+ overflow: hidden;
57
+ }
58
+
59
+ .tile {
60
+ position: relative;
61
+ overflow: hidden;
62
+ cursor: pointer;
63
+ border: 2px solid transparent;
64
+ border-radius: 4px;
65
+ transition: border-color 0.12s ease, opacity 0.15s ease, transform 0.12s ease;
66
+ background: var(--bgColor-muted, #f6f8fa);
67
+ }
68
+
69
+ .tile:hover {
70
+ border-color: var(--borderColor-accent-emphasis, #0969da);
71
+ }
72
+
73
+ .selected {
74
+ border-color: var(--fgColor-accent, #0969da);
75
+ box-shadow: 0 0 0 2px var(--fgColor-accent, #0969da);
76
+ }
77
+
78
+ .pasteTarget {
79
+ border-color: var(--fgColor-success, #1a7f37);
80
+ box-shadow: 0 0 0 2px var(--fgColor-success, #1a7f37);
81
+ animation: pulse 1s infinite;
82
+ }
83
+
84
+ .dragOver {
85
+ border-color: var(--fgColor-accent, #0969da);
86
+ background: var(--bgColor-accent-muted, #ddf4ff);
87
+ transform: scale(1.04);
88
+ }
89
+
90
+ .dragging {
91
+ opacity: 0.4;
92
+ }
93
+
94
+ .tileImage {
95
+ width: 100%;
96
+ height: 100%;
97
+ object-fit: cover;
98
+ display: block;
99
+ pointer-events: none;
100
+ }
101
+
102
+ .emptyTile {
103
+ display: block;
104
+ width: 100%;
105
+ height: 100%;
106
+ background: var(--bgColor-muted, #f6f8fa);
107
+ }
108
+
109
+ .pasteHint {
110
+ position: absolute;
111
+ inset: 0;
112
+ display: flex;
113
+ align-items: center;
114
+ justify-content: center;
115
+ background: rgba(0, 0, 0, 0.45);
116
+ color: white;
117
+ font-size: 11px;
118
+ font-weight: 600;
119
+ letter-spacing: 0.5px;
120
+ pointer-events: none;
121
+ }
122
+
123
+ .hint {
124
+ padding: 6px 0 2px;
125
+ text-align: center;
126
+ font-size: 11px;
127
+ color: var(--fgColor-muted, #656d76);
128
+ }
129
+
130
+ @keyframes pulse {
131
+ 0%, 100% { box-shadow: 0 0 0 2px var(--fgColor-success, #1a7f37); }
132
+ 50% { box-shadow: 0 0 0 4px var(--fgColor-success, #1a7f37); }
133
+ }