@dfosco/storyboard-react 4.1.0 → 4.2.0-beta.1
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/CommandPalette/CommandPalette.jsx +69 -5
- package/src/CommandPalette/command-palette.css +5 -0
- package/src/canvas/CanvasPage.jsx +412 -10
- package/src/canvas/CanvasPage.module.css +26 -4
- package/src/canvas/ConnectorLayer.jsx +252 -0
- package/src/canvas/ConnectorLayer.module.css +60 -0
- package/src/canvas/PageSelector.jsx +376 -37
- package/src/canvas/PageSelector.module.css +93 -6
- package/src/canvas/canvasApi.js +35 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +27 -6
- package/src/canvas/widgets/MarkdownBlock.module.css +11 -1
- package/src/canvas/widgets/StickyNote.module.css +3 -0
- package/src/canvas/widgets/TerminalWidget.jsx +274 -0
- package/src/canvas/widgets/TerminalWidget.module.css +158 -0
- package/src/canvas/widgets/WidgetChrome.jsx +86 -1
- package/src/canvas/widgets/WidgetChrome.module.css +72 -0
- package/src/canvas/widgets/index.js +2 -0
- package/src/canvas/widgets/widgetConfig.js +78 -0
- package/src/canvas/widgets/widgetProps.js +1 -0
- package/src/context.jsx +15 -0
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { useCallback, useRef, useState, useEffect } from 'react'
|
|
2
|
-
import { createCanvas } from './canvasApi.js'
|
|
2
|
+
import { createCanvas, renamePage, reorderPages, getPageOrder, duplicateCanvas } from './canvasApi.js'
|
|
3
3
|
import styles from './PageSelector.module.css'
|
|
4
4
|
|
|
5
|
+
const DragGrip = () => (
|
|
6
|
+
<svg className={styles.dragHandle} width="8" height="14" viewBox="0 0 8 14" fill="currentColor" aria-hidden="true">
|
|
7
|
+
<circle cx="2" cy="2" r="1.2" /><circle cx="6" cy="2" r="1.2" />
|
|
8
|
+
<circle cx="2" cy="7" r="1.2" /><circle cx="6" cy="7" r="1.2" />
|
|
9
|
+
<circle cx="2" cy="12" r="1.2" /><circle cx="6" cy="12" r="1.2" />
|
|
10
|
+
</svg>
|
|
11
|
+
)
|
|
12
|
+
|
|
5
13
|
/**
|
|
6
14
|
* In-canvas page selector — shows sibling pages in the same canvas group.
|
|
7
15
|
* Only renders when 2+ sibling pages exist.
|
|
@@ -24,33 +32,294 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
|
|
|
24
32
|
const [creating, setCreating] = useState(false)
|
|
25
33
|
const [pages, setPages] = useState(initialPages)
|
|
26
34
|
const [successMsg, setSuccessMsg] = useState(null)
|
|
35
|
+
const [editingPage, setEditingPage] = useState(null)
|
|
36
|
+
const [editValue, setEditValue] = useState('')
|
|
37
|
+
const [orderedItems, setOrderedItems] = useState(() => initialPages.map(p => ({ type: 'page', ...p })))
|
|
38
|
+
const [dragIndex, setDragIndex] = useState(null)
|
|
39
|
+
const [dropTarget, setDropTarget] = useState(null)
|
|
27
40
|
const containerRef = useRef(null)
|
|
28
41
|
const inputRef = useRef(null)
|
|
42
|
+
const editInputRef = useRef(null)
|
|
43
|
+
const clickTimerRef = useRef(null)
|
|
44
|
+
const didDragRef = useRef(false)
|
|
29
45
|
|
|
30
46
|
// Sync pages when prop changes (e.g. HMR reload)
|
|
31
47
|
useEffect(() => { setPages(initialPages) }, [initialPages])
|
|
32
48
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
49
|
+
// Keep orderedItems in sync with pages when dropdown is closed
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
setOrderedItems(prev => {
|
|
52
|
+
// If we have order with separators, preserve it but update page data
|
|
53
|
+
if (prev.some(i => i.type === 'separator')) return prev
|
|
54
|
+
return initialPages.map(p => ({ type: 'page', ...p }))
|
|
55
|
+
})
|
|
56
|
+
}, [initialPages])
|
|
36
57
|
|
|
37
58
|
// Derive folder from currentName (e.g. "Examples/Design Overview" → "Examples")
|
|
38
59
|
const folder = currentName.includes('/') ? currentName.split('/')[0] : ''
|
|
39
60
|
|
|
61
|
+
// Build ordered items from pages + saved order (with separators).
|
|
62
|
+
// Load eagerly (not just when open) so the trigger badge shows correct index.
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
let cancelled = false
|
|
65
|
+
async function loadOrder() {
|
|
66
|
+
if (!folder) {
|
|
67
|
+
setOrderedItems(pages.map(p => ({ type: 'page', ...p })))
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const result = await getPageOrder(folder)
|
|
72
|
+
if (cancelled) return
|
|
73
|
+
if (result?.order) {
|
|
74
|
+
const items = []
|
|
75
|
+
const pageMap = new Map(pages.map(p => [p.name, p]))
|
|
76
|
+
const seen = new Set()
|
|
77
|
+
for (const entry of result.order) {
|
|
78
|
+
if (typeof entry === 'string' && entry.startsWith('sep-')) {
|
|
79
|
+
items.push({ type: 'separator', id: entry })
|
|
80
|
+
} else if (pageMap.has(entry)) {
|
|
81
|
+
items.push({ type: 'page', ...pageMap.get(entry) })
|
|
82
|
+
seen.add(entry)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Append pages not in saved order
|
|
86
|
+
for (const p of pages) {
|
|
87
|
+
if (!seen.has(p.name)) items.push({ type: 'page', ...p })
|
|
88
|
+
}
|
|
89
|
+
setOrderedItems(items)
|
|
90
|
+
} else {
|
|
91
|
+
setOrderedItems(pages.map(p => ({ type: 'page', ...p })))
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
setOrderedItems(pages.map(p => ({ type: 'page', ...p })))
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
loadOrder()
|
|
98
|
+
return () => { cancelled = true }
|
|
99
|
+
}, [pages, folder])
|
|
100
|
+
|
|
101
|
+
// Derived values from ordered items
|
|
102
|
+
const realPages = orderedItems.filter(i => i.type === 'page')
|
|
103
|
+
const currentPage = realPages.find(p => p.name === currentName)
|
|
104
|
+
const currentLabel = currentPage?.title || currentName.split('/').pop()
|
|
105
|
+
const currentIndex = realPages.findIndex(p => p.name === currentName)
|
|
106
|
+
|
|
107
|
+
const navigateTo = useCallback((page) => {
|
|
108
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
109
|
+
window.location.href = base + page.route
|
|
110
|
+
}, [])
|
|
111
|
+
|
|
40
112
|
const handleSelect = useCallback(
|
|
41
113
|
(page) => {
|
|
42
114
|
if (page.name !== currentName) {
|
|
43
|
-
|
|
44
|
-
window.location.href = base + page.route
|
|
115
|
+
navigateTo(page)
|
|
45
116
|
}
|
|
46
117
|
setOpen(false)
|
|
47
118
|
},
|
|
48
|
-
[currentName],
|
|
119
|
+
[currentName, navigateTo],
|
|
49
120
|
)
|
|
50
121
|
|
|
122
|
+
// Click handler with 300ms delay (mouse only) to distinguish from dblclick
|
|
123
|
+
const handleItemClick = useCallback((page, e) => {
|
|
124
|
+
if (didDragRef.current) {
|
|
125
|
+
didDragRef.current = false
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
if (editingPage) return
|
|
129
|
+
// Keyboard Enter/Space → navigate immediately
|
|
130
|
+
if (!e?.nativeEvent || e.nativeEvent instanceof KeyboardEvent) {
|
|
131
|
+
handleSelect(page)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
// Mouse click → delay to allow dblclick
|
|
135
|
+
if (clickTimerRef.current) clearTimeout(clickTimerRef.current)
|
|
136
|
+
clickTimerRef.current = setTimeout(() => {
|
|
137
|
+
clickTimerRef.current = null
|
|
138
|
+
handleSelect(page)
|
|
139
|
+
}, 300)
|
|
140
|
+
}, [editingPage, handleSelect])
|
|
141
|
+
|
|
142
|
+
// Double-click to rename
|
|
143
|
+
const handleItemDblClick = useCallback((page) => {
|
|
144
|
+
if (!isLocalDev) return
|
|
145
|
+
if (clickTimerRef.current) {
|
|
146
|
+
clearTimeout(clickTimerRef.current)
|
|
147
|
+
clickTimerRef.current = null
|
|
148
|
+
}
|
|
149
|
+
setEditingPage(page.name)
|
|
150
|
+
setEditValue(page.title)
|
|
151
|
+
}, [isLocalDev])
|
|
152
|
+
|
|
153
|
+
// Focus edit input when entering edit mode
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (editingPage && editInputRef.current) {
|
|
156
|
+
editInputRef.current.focus()
|
|
157
|
+
editInputRef.current.select()
|
|
158
|
+
}
|
|
159
|
+
}, [editingPage])
|
|
160
|
+
|
|
161
|
+
// Commit rename
|
|
162
|
+
const handleRenameCommit = useCallback(async () => {
|
|
163
|
+
const trimmed = editValue.trim()
|
|
164
|
+
const oldName = editingPage
|
|
165
|
+
setEditingPage(null)
|
|
166
|
+
if (!trimmed || !oldName) return
|
|
167
|
+
|
|
168
|
+
const oldPage = realPages.find(p => p.name === oldName)
|
|
169
|
+
if (!oldPage || trimmed === oldPage.title) return
|
|
170
|
+
|
|
171
|
+
// Validate no duplicates (case-insensitive)
|
|
172
|
+
const lower = trimmed.toLowerCase()
|
|
173
|
+
if (realPages.some(p => p.name !== oldName && p.title.toLowerCase() === lower)) {
|
|
174
|
+
console.warn('Duplicate page name:', trimmed)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const result = await renamePage(oldName, trimmed)
|
|
180
|
+
if (result?.error) {
|
|
181
|
+
console.error('Failed to rename page:', result.error)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
const route = result?.route
|
|
185
|
+
if (route) {
|
|
186
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
187
|
+
const targetUrl = base + route
|
|
188
|
+
try { sessionStorage.setItem('sb-open-page-selector', '1') } catch { /* ignore */ }
|
|
189
|
+
if (import.meta.hot) {
|
|
190
|
+
const timer = setTimeout(() => { window.location.href = targetUrl }, 3000)
|
|
191
|
+
import.meta.hot.on('vite:beforeFullReload', () => {
|
|
192
|
+
clearTimeout(timer)
|
|
193
|
+
sessionStorage.setItem('sb-pending-navigate', targetUrl)
|
|
194
|
+
})
|
|
195
|
+
} else {
|
|
196
|
+
setTimeout(() => { window.location.href = targetUrl }, 1000)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} catch (err) {
|
|
200
|
+
console.error('Failed to rename page:', err)
|
|
201
|
+
}
|
|
202
|
+
}, [editValue, editingPage, realPages])
|
|
203
|
+
|
|
204
|
+
// Duplicate a page
|
|
205
|
+
const handleDuplicate = useCallback(async (page, e) => {
|
|
206
|
+
e.stopPropagation()
|
|
207
|
+
|
|
208
|
+
// Smart copy naming: if title ends with ` N` or `vN`, increment the number.
|
|
209
|
+
// Skip `#N` (could be issue refs) and years (2000-2099).
|
|
210
|
+
const numberedMatch = page.title.match(/^(.+?)(\s+|v)(\d+)$/)
|
|
211
|
+
let copyTitle
|
|
212
|
+
const num = numberedMatch ? parseInt(numberedMatch[3], 10) : null
|
|
213
|
+
const isYear = num !== null && num >= 2000 && num <= 2099
|
|
214
|
+
if (numberedMatch && !isYear) {
|
|
215
|
+
const [, base, sep, numStr] = numberedMatch
|
|
216
|
+
let next = parseInt(numStr, 10) + 1
|
|
217
|
+
const titles = new Set(realPages.map(p => p.title))
|
|
218
|
+
while (titles.has(`${base}${sep}${next}`)) next++
|
|
219
|
+
copyTitle = `${base}${sep}${next}`
|
|
220
|
+
} else {
|
|
221
|
+
const baseTitle = page.title.replace(/ Copy( \d+)?$/, '')
|
|
222
|
+
const existingCopies = realPages
|
|
223
|
+
.filter(p => {
|
|
224
|
+
const t = p.title
|
|
225
|
+
return t === `${baseTitle} Copy` || (/^.+ Copy \d+$/.test(t) && t.startsWith(baseTitle))
|
|
226
|
+
})
|
|
227
|
+
.length
|
|
228
|
+
copyTitle = existingCopies === 0
|
|
229
|
+
? `${baseTitle} Copy`
|
|
230
|
+
: `${baseTitle} Copy ${existingCopies + 1}`
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const result = await duplicateCanvas(page.name, copyTitle)
|
|
235
|
+
if (result?.error) {
|
|
236
|
+
console.error('Failed to duplicate page:', result.error)
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
const route = result?.route
|
|
240
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
241
|
+
const targetUrl = base + route
|
|
242
|
+
|
|
243
|
+
// Optimistically add the new page to the list
|
|
244
|
+
const pageName = result.name || copyTitle
|
|
245
|
+
setPages(prev => [...prev, { name: pageName, route, title: copyTitle }])
|
|
246
|
+
setSuccessMsg(`"${copyTitle}" created`)
|
|
247
|
+
|
|
248
|
+
try { sessionStorage.setItem('sb-open-page-selector', '1') } catch { /* ignore */ }
|
|
249
|
+
|
|
250
|
+
if (import.meta.hot) {
|
|
251
|
+
const timer = setTimeout(() => { window.location.href = targetUrl }, 3000)
|
|
252
|
+
import.meta.hot.on('vite:beforeFullReload', () => {
|
|
253
|
+
clearTimeout(timer)
|
|
254
|
+
sessionStorage.setItem('sb-pending-navigate', targetUrl)
|
|
255
|
+
})
|
|
256
|
+
} else {
|
|
257
|
+
setTimeout(() => { window.location.href = targetUrl }, 1000)
|
|
258
|
+
}
|
|
259
|
+
} catch (err) {
|
|
260
|
+
console.error('Failed to duplicate page:', err)
|
|
261
|
+
}
|
|
262
|
+
}, [realPages])
|
|
263
|
+
|
|
264
|
+
// Drag and drop handlers
|
|
265
|
+
const handleDragStart = useCallback((index, e) => {
|
|
266
|
+
didDragRef.current = true
|
|
267
|
+
setDragIndex(index)
|
|
268
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
269
|
+
e.dataTransfer.setData('text/plain', String(index))
|
|
270
|
+
}, [])
|
|
271
|
+
|
|
272
|
+
const handleDragOver = useCallback((index, e) => {
|
|
273
|
+
e.preventDefault()
|
|
274
|
+
e.dataTransfer.dropEffect = 'move'
|
|
275
|
+
setDropTarget(index)
|
|
276
|
+
}, [])
|
|
277
|
+
|
|
278
|
+
const handleDragEnd = useCallback(() => {
|
|
279
|
+
setDragIndex(null)
|
|
280
|
+
setDropTarget(null)
|
|
281
|
+
}, [])
|
|
282
|
+
|
|
283
|
+
const handleDrop = useCallback(async (toIndex, e) => {
|
|
284
|
+
e.preventDefault()
|
|
285
|
+
const fromIndex = dragIndex
|
|
286
|
+
setDragIndex(null)
|
|
287
|
+
setDropTarget(null)
|
|
288
|
+
if (fromIndex == null || fromIndex === toIndex) return
|
|
289
|
+
|
|
290
|
+
const newItems = [...orderedItems]
|
|
291
|
+
const [moved] = newItems.splice(fromIndex, 1)
|
|
292
|
+
newItems.splice(toIndex, 0, moved)
|
|
293
|
+
setOrderedItems(newItems)
|
|
294
|
+
|
|
295
|
+
if (folder) {
|
|
296
|
+
const order = newItems.map(i => i.type === 'separator' ? i.id : i.name)
|
|
297
|
+
try { await reorderPages(folder, order) } catch (err) {
|
|
298
|
+
console.error('Failed to persist page order:', err)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}, [dragIndex, orderedItems, folder])
|
|
302
|
+
|
|
51
303
|
const handleAddPage = useCallback(async () => {
|
|
52
304
|
const trimmed = newName.trim()
|
|
53
305
|
if (!trimmed || creating) return
|
|
306
|
+
|
|
307
|
+
// Separator shortcut
|
|
308
|
+
if (trimmed === '---') {
|
|
309
|
+
const sepId = `sep-${Date.now()}`
|
|
310
|
+
const newItems = [...orderedItems, { type: 'separator', id: sepId }]
|
|
311
|
+
setOrderedItems(newItems)
|
|
312
|
+
setAdding(false)
|
|
313
|
+
setNewName('')
|
|
314
|
+
if (folder) {
|
|
315
|
+
const order = newItems.map(i => i.type === 'separator' ? i.id : i.name)
|
|
316
|
+
try { await reorderPages(folder, order) } catch (err) {
|
|
317
|
+
console.error('Failed to persist separator:', err)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
|
|
54
323
|
setCreating(true)
|
|
55
324
|
try {
|
|
56
325
|
// Single-page canvas (no folder) → convert to multi-page folder
|
|
@@ -98,7 +367,7 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
|
|
|
98
367
|
console.error('Failed to create canvas page:', err)
|
|
99
368
|
setCreating(false)
|
|
100
369
|
}
|
|
101
|
-
}, [newName, currentName, folder, creating])
|
|
370
|
+
}, [newName, currentName, folder, creating, orderedItems])
|
|
102
371
|
|
|
103
372
|
// Focus input when entering add mode
|
|
104
373
|
useEffect(() => {
|
|
@@ -114,6 +383,7 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
|
|
|
114
383
|
setAdding(false)
|
|
115
384
|
setNewName('')
|
|
116
385
|
setSuccessMsg(null)
|
|
386
|
+
setEditingPage(null)
|
|
117
387
|
}
|
|
118
388
|
}
|
|
119
389
|
document.addEventListener('mousedown', handleClick)
|
|
@@ -125,7 +395,9 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
|
|
|
125
395
|
if (!open) return
|
|
126
396
|
function handleKey(e) {
|
|
127
397
|
if (e.key === 'Escape') {
|
|
128
|
-
if (
|
|
398
|
+
if (editingPage) {
|
|
399
|
+
setEditingPage(null)
|
|
400
|
+
} else if (adding) {
|
|
129
401
|
setAdding(false)
|
|
130
402
|
setNewName('')
|
|
131
403
|
} else {
|
|
@@ -135,7 +407,7 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
|
|
|
135
407
|
}
|
|
136
408
|
document.addEventListener('keydown', handleKey)
|
|
137
409
|
return () => document.removeEventListener('keydown', handleKey)
|
|
138
|
-
}, [open, adding])
|
|
410
|
+
}, [open, adding, editingPage])
|
|
139
411
|
|
|
140
412
|
// Show selector when there are multiple pages, or in dev mode (to allow adding pages)
|
|
141
413
|
if (!pages || (pages.length < 2 && !isLocalDev)) return null
|
|
@@ -151,7 +423,7 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
|
|
|
151
423
|
>
|
|
152
424
|
<span className={styles.label}>{currentLabel}</span>
|
|
153
425
|
<span className={styles.badge}>
|
|
154
|
-
{currentIndex + 1}/{
|
|
426
|
+
{currentIndex + 1}/{realPages.length}
|
|
155
427
|
</span>
|
|
156
428
|
<svg
|
|
157
429
|
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
|
|
@@ -166,29 +438,96 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
|
|
|
166
438
|
</button>
|
|
167
439
|
{open && (
|
|
168
440
|
<ul className={styles.menu} role="listbox" aria-label="Canvas pages">
|
|
169
|
-
{
|
|
441
|
+
{orderedItems.map((item, index) => {
|
|
442
|
+
if (item.type === 'separator') {
|
|
443
|
+
return (
|
|
444
|
+
<li
|
|
445
|
+
key={item.id}
|
|
446
|
+
className={`${styles.separatorRow} ${dragIndex === index ? styles.itemDragging : ''}`}
|
|
447
|
+
role="separator"
|
|
448
|
+
draggable={isLocalDev}
|
|
449
|
+
onDragStart={(e) => handleDragStart(index, e)}
|
|
450
|
+
onDragOver={(e) => handleDragOver(index, e)}
|
|
451
|
+
onDrop={(e) => handleDrop(index, e)}
|
|
452
|
+
onDragEnd={handleDragEnd}
|
|
453
|
+
>
|
|
454
|
+
{dropTarget === index && dragIndex !== index && <div className={styles.dropIndicator} />}
|
|
455
|
+
{isLocalDev && <DragGrip />}
|
|
456
|
+
<div className={styles.separatorLine} />
|
|
457
|
+
</li>
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
const page = item
|
|
461
|
+
const isEditing = editingPage === page.name
|
|
462
|
+
return (
|
|
463
|
+
<li
|
|
464
|
+
key={page.name}
|
|
465
|
+
role="option"
|
|
466
|
+
aria-selected={page.name === currentName}
|
|
467
|
+
className={`${styles.item} ${page.name === currentName ? styles.itemActive : ''} ${dragIndex === index ? styles.itemDragging : ''}`}
|
|
468
|
+
onClick={(e) => handleItemClick(page, e)}
|
|
469
|
+
onDoubleClick={() => handleItemDblClick(page)}
|
|
470
|
+
onKeyDown={(e) => {
|
|
471
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
472
|
+
e.preventDefault()
|
|
473
|
+
handleSelect(page)
|
|
474
|
+
}
|
|
475
|
+
}}
|
|
476
|
+
tabIndex={0}
|
|
477
|
+
draggable={isLocalDev && !isEditing}
|
|
478
|
+
onDragStart={(e) => handleDragStart(index, e)}
|
|
479
|
+
onDragOver={(e) => handleDragOver(index, e)}
|
|
480
|
+
onDrop={(e) => handleDrop(index, e)}
|
|
481
|
+
onDragEnd={handleDragEnd}
|
|
482
|
+
>
|
|
483
|
+
{dropTarget === index && dragIndex !== index && <div className={styles.dropIndicator} />}
|
|
484
|
+
{isLocalDev && <DragGrip />}
|
|
485
|
+
{isEditing ? (
|
|
486
|
+
<input
|
|
487
|
+
ref={editInputRef}
|
|
488
|
+
className={styles.addInput}
|
|
489
|
+
type="text"
|
|
490
|
+
value={editValue}
|
|
491
|
+
onChange={(e) => setEditValue(e.target.value)}
|
|
492
|
+
onKeyDown={(e) => {
|
|
493
|
+
if (e.key === 'Enter') { e.preventDefault(); handleRenameCommit() }
|
|
494
|
+
if (e.key === 'Escape') { e.preventDefault(); setEditingPage(null) }
|
|
495
|
+
e.stopPropagation()
|
|
496
|
+
}}
|
|
497
|
+
onClick={(e) => e.stopPropagation()}
|
|
498
|
+
onDoubleClick={(e) => e.stopPropagation()}
|
|
499
|
+
onBlur={handleRenameCommit}
|
|
500
|
+
/>
|
|
501
|
+
) : (
|
|
502
|
+
<>
|
|
503
|
+
<span className={styles.itemContent}>{page.title}</span>
|
|
504
|
+
{isLocalDev && (
|
|
505
|
+
<button
|
|
506
|
+
className={styles.duplicateBtn}
|
|
507
|
+
onClick={(e) => handleDuplicate(page, e)}
|
|
508
|
+
onDoubleClick={(e) => e.stopPropagation()}
|
|
509
|
+
title="Duplicate page"
|
|
510
|
+
aria-label="Duplicate page"
|
|
511
|
+
>
|
|
512
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
513
|
+
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25ZM5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" />
|
|
514
|
+
</svg>
|
|
515
|
+
</button>
|
|
516
|
+
)}
|
|
517
|
+
</>
|
|
518
|
+
)}
|
|
519
|
+
</li>
|
|
520
|
+
)
|
|
521
|
+
})}
|
|
522
|
+
{isLocalDev && (
|
|
170
523
|
<li
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
className={`${styles.item} ${page.name === currentName ? styles.itemActive : ''}`}
|
|
175
|
-
onClick={() => handleSelect(page)}
|
|
176
|
-
onKeyDown={(e) => {
|
|
177
|
-
if (e.key === 'Enter' || e.key === ' ') {
|
|
178
|
-
e.preventDefault()
|
|
179
|
-
handleSelect(page)
|
|
180
|
-
}
|
|
181
|
-
}}
|
|
182
|
-
tabIndex={0}
|
|
524
|
+
className={styles.dropZoneEnd}
|
|
525
|
+
onDragOver={(e) => handleDragOver(orderedItems.length, e)}
|
|
526
|
+
onDrop={(e) => handleDrop(orderedItems.length, e)}
|
|
183
527
|
>
|
|
184
|
-
{
|
|
185
|
-
</li>
|
|
186
|
-
))}
|
|
187
|
-
{isLocalDev && (
|
|
188
|
-
<>
|
|
189
|
-
<li className={styles.separator} role="separator" />
|
|
528
|
+
{dropTarget === orderedItems.length && dragIndex != null && <div className={styles.dropIndicator} />}
|
|
190
529
|
{adding ? (
|
|
191
|
-
<
|
|
530
|
+
<div className={styles.addForm}>
|
|
192
531
|
<input
|
|
193
532
|
ref={inputRef}
|
|
194
533
|
className={styles.addInput}
|
|
@@ -211,9 +550,9 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
|
|
|
211
550
|
>
|
|
212
551
|
{creating ? '…' : 'Add'}
|
|
213
552
|
</button>
|
|
214
|
-
</
|
|
553
|
+
</div>
|
|
215
554
|
) : (
|
|
216
|
-
<
|
|
555
|
+
<div
|
|
217
556
|
className={styles.addItem}
|
|
218
557
|
onClick={() => setAdding(true)}
|
|
219
558
|
tabIndex={0}
|
|
@@ -224,13 +563,13 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
|
|
|
224
563
|
}
|
|
225
564
|
}}
|
|
226
565
|
>
|
|
227
|
-
+ Add
|
|
228
|
-
</
|
|
566
|
+
+ Add page
|
|
567
|
+
</div>
|
|
229
568
|
)}
|
|
230
569
|
{successMsg && (
|
|
231
|
-
<
|
|
570
|
+
<div className={styles.successMsg}>✓ {successMsg}</div>
|
|
232
571
|
)}
|
|
233
|
-
|
|
572
|
+
</li>
|
|
234
573
|
)}
|
|
235
574
|
</ul>
|
|
236
575
|
)}
|
|
@@ -51,12 +51,12 @@
|
|
|
51
51
|
position: absolute;
|
|
52
52
|
top: calc(100% + 4px);
|
|
53
53
|
left: 0;
|
|
54
|
-
min-width:
|
|
55
|
-
max-width:
|
|
54
|
+
min-width: 260px;
|
|
55
|
+
max-width: 360px;
|
|
56
56
|
max-height: 320px;
|
|
57
57
|
overflow-y: auto;
|
|
58
58
|
margin: 0;
|
|
59
|
-
padding:
|
|
59
|
+
padding: 6px;
|
|
60
60
|
list-style: none;
|
|
61
61
|
background: var(--bgColor-default, #fff);
|
|
62
62
|
border: 1px solid var(--borderColor-default, rgba(0, 0, 0, 0.15));
|
|
@@ -65,13 +65,78 @@
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
.item {
|
|
68
|
-
|
|
68
|
+
position: relative;
|
|
69
|
+
display: flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
gap: 4px;
|
|
72
|
+
padding: 8px 10px 8px 4px;
|
|
69
73
|
border-radius: 4px;
|
|
70
74
|
cursor: pointer;
|
|
71
|
-
|
|
75
|
+
color: var(--fgColor-default, #1f2328);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.itemContent {
|
|
79
|
+
flex: 1;
|
|
80
|
+
min-width: 0;
|
|
72
81
|
overflow: hidden;
|
|
73
82
|
text-overflow: ellipsis;
|
|
83
|
+
white-space: nowrap;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.dragHandle {
|
|
87
|
+
opacity: 0;
|
|
88
|
+
color: var(--fgColor-muted, #656d76);
|
|
89
|
+
cursor: grab;
|
|
90
|
+
flex-shrink: 0;
|
|
91
|
+
padding: 2px;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.item:hover .dragHandle {
|
|
95
|
+
opacity: 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.duplicateBtn {
|
|
99
|
+
opacity: 0;
|
|
100
|
+
display: flex;
|
|
101
|
+
align-items: center;
|
|
102
|
+
justify-content: center;
|
|
103
|
+
padding: 2px;
|
|
104
|
+
border: none;
|
|
105
|
+
border-radius: 4px;
|
|
106
|
+
background: transparent;
|
|
107
|
+
color: var(--fgColor-muted, #656d76);
|
|
108
|
+
cursor: pointer;
|
|
109
|
+
flex-shrink: 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.duplicateBtn:hover {
|
|
74
113
|
color: var(--fgColor-default, #1f2328);
|
|
114
|
+
background: var(--bgColor-neutral-muted, rgba(0, 0, 0, 0.06));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.item:hover .duplicateBtn {
|
|
118
|
+
opacity: 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.itemDragging {
|
|
122
|
+
opacity: 0.5;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.dropIndicator {
|
|
126
|
+
position: absolute;
|
|
127
|
+
top: -1px;
|
|
128
|
+
left: 4px;
|
|
129
|
+
right: 4px;
|
|
130
|
+
height: 2px;
|
|
131
|
+
background: var(--focus-outlineColor, #0969da);
|
|
132
|
+
border-radius: 1px;
|
|
133
|
+
pointer-events: none;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.dropZoneEnd {
|
|
137
|
+
position: relative;
|
|
138
|
+
padding-top: 8px;
|
|
139
|
+
list-style: none;
|
|
75
140
|
}
|
|
76
141
|
|
|
77
142
|
.item:hover {
|
|
@@ -99,6 +164,27 @@
|
|
|
99
164
|
list-style: none;
|
|
100
165
|
}
|
|
101
166
|
|
|
167
|
+
.separatorRow {
|
|
168
|
+
position: relative;
|
|
169
|
+
display: flex;
|
|
170
|
+
align-items: center;
|
|
171
|
+
gap: 4px;
|
|
172
|
+
padding: 6px 10px 6px 4px;
|
|
173
|
+
border-radius: 4px;
|
|
174
|
+
list-style: none;
|
|
175
|
+
cursor: default;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.separatorRow:hover .dragHandle {
|
|
179
|
+
opacity: 1;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.separatorLine {
|
|
183
|
+
flex: 1;
|
|
184
|
+
height: 1px;
|
|
185
|
+
background: var(--borderColor-default, rgba(0, 0, 0, 0.15));
|
|
186
|
+
}
|
|
187
|
+
|
|
102
188
|
.addItem {
|
|
103
189
|
padding: 6px 10px;
|
|
104
190
|
border-radius: 4px;
|
|
@@ -106,10 +192,11 @@
|
|
|
106
192
|
white-space: nowrap;
|
|
107
193
|
color: var(--fgColor-muted, #656d76);
|
|
108
194
|
font-size: 12px;
|
|
195
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
109
196
|
}
|
|
110
197
|
|
|
111
198
|
.addItem:hover {
|
|
112
|
-
background: var(--bgColor-muted,
|
|
199
|
+
background: var(--bgColor-neutral-muted, rgba(0, 0, 0, 0.08));
|
|
113
200
|
color: var(--fgColor-default, #1f2328);
|
|
114
201
|
}
|
|
115
202
|
|
package/src/canvas/canvasApi.js
CHANGED
|
@@ -61,3 +61,38 @@ export function checkGitHubCliAvailable() {
|
|
|
61
61
|
export function fetchGitHubEmbed(url) {
|
|
62
62
|
return request('/github/embed', 'POST', { url })
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
export function renamePage(canvasId, newTitle) {
|
|
66
|
+
return request('/rename-page', 'PUT', { name: canvasId, newTitle })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function reorderPages(folder, order) {
|
|
70
|
+
return request('/reorder-pages', 'PUT', { folder, order })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function getPageOrder(folder) {
|
|
74
|
+
return request(`/page-order?folder=${encodeURIComponent(folder)}`, 'GET')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function updateFolderMeta(folder, title) {
|
|
78
|
+
return request('/update-folder-meta', 'PUT', { folder, title })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function duplicateCanvas(canvasId, newTitle) {
|
|
82
|
+
return request('/duplicate', 'POST', { name: canvasId, newTitle })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function addConnector(canvasId, { startWidgetId, startAnchor, endWidgetId, endAnchor, connectorType }) {
|
|
86
|
+
return request('/connector', 'POST', {
|
|
87
|
+
name: canvasId,
|
|
88
|
+
startWidgetId,
|
|
89
|
+
startAnchor,
|
|
90
|
+
endWidgetId,
|
|
91
|
+
endAnchor,
|
|
92
|
+
connectorType,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function removeConnector(canvasId, connectorId) {
|
|
97
|
+
return request('/connector', 'DELETE', { name: canvasId, connectorId })
|
|
98
|
+
}
|