@dfosco/storyboard-react 4.1.0 → 4.2.0-alpha.10
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 +5 -4
- package/src/CommandPalette/CommandPalette.jsx +69 -5
- package/src/CommandPalette/command-palette.css +5 -0
- package/src/canvas/CanvasPage.jsx +333 -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/ActionWidget.jsx +193 -0
- package/src/canvas/widgets/ActionWidget.module.css +122 -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 +280 -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 +82 -0
- package/src/canvas/widgets/index.js +4 -0
- package/src/canvas/widgets/widgetConfig.js +78 -0
- package/src/canvas/widgets/widgetProps.js +1 -0
- package/src/context.jsx +15 -0
package/package.json
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0-alpha.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@base-ui/react": "^1.4.0",
|
|
7
|
-
"@dfosco/storyboard-core": "4.
|
|
8
|
-
"@dfosco/tiny-canvas": "4.
|
|
7
|
+
"@dfosco/storyboard-core": "4.2.0-alpha.10",
|
|
8
|
+
"@dfosco/tiny-canvas": "4.2.0-alpha.10",
|
|
9
9
|
"@neodrag/react": "^2.3.1",
|
|
10
10
|
"glob": "^11.0.0",
|
|
11
11
|
"jsonc-parser": "^3.3.1",
|
|
12
12
|
"remark": "^15.0.1",
|
|
13
13
|
"remark-gfm": "^4.0.1",
|
|
14
14
|
"react-cmdk": "^1.3.9",
|
|
15
|
-
"remark-html": "^16.0.1"
|
|
15
|
+
"remark-html": "^16.0.1",
|
|
16
|
+
"ghostty-web": "^0.4.0"
|
|
16
17
|
},
|
|
17
18
|
"license": "MIT",
|
|
18
19
|
"repository": {
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
getTheme,
|
|
21
21
|
isExcludedByRoute,
|
|
22
22
|
} from '@dfosco/storyboard-core'
|
|
23
|
+
import { widgetTypes } from '../canvas/widgets/widgetConfig.js'
|
|
23
24
|
import CreateDialog from './CreateDialog.jsx'
|
|
24
25
|
import BranchBar from '../BranchBar/BranchBar.jsx'
|
|
25
26
|
import AuthModal from '../AuthModal/AuthModal.jsx'
|
|
@@ -287,15 +288,78 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
|
|
|
287
288
|
const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true
|
|
288
289
|
if (!isLocalDev) return null
|
|
289
290
|
const createItems = [
|
|
290
|
-
{ id: 'create:canvas', children: '
|
|
291
|
-
{ id: 'create:prototype', children: '
|
|
292
|
-
{ id: 'create:component', children: '
|
|
293
|
-
{ id: 'create:flow', children: '
|
|
294
|
-
{ id: 'create:page', children: '
|
|
291
|
+
{ id: 'create:canvas', children: 'Canvas', keywords: ['create', 'canvas', 'new', 'board'], showType: false, onClick: () => onCreateAction?.('Canvas') },
|
|
292
|
+
{ id: 'create:prototype', children: 'Prototype', keywords: ['create', 'prototype', 'new', 'page'], showType: false, onClick: () => onCreateAction?.('Prototype') },
|
|
293
|
+
{ id: 'create:component', children: 'Component', keywords: ['create', 'component', 'new', 'story'], showType: false, onClick: () => onCreateAction?.('Component') },
|
|
294
|
+
{ id: 'create:flow', children: 'Prototype Flow', keywords: ['create', 'flow', 'new', 'data'], showType: false, onClick: () => onCreateAction?.('Flow') },
|
|
295
|
+
{ id: 'create:page', children: 'Prototype Page', keywords: ['create', 'page', 'new'], showType: false, onClick: () => onCreateAction?.('Page') },
|
|
295
296
|
]
|
|
296
297
|
return { group: { heading: section.title, id: `cfg:${section.id}`, items: createItems } }
|
|
297
298
|
}
|
|
298
299
|
|
|
300
|
+
// --- Create widget source (all canvas widget types) ---
|
|
301
|
+
if (section.source === 'create-widget') {
|
|
302
|
+
const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true
|
|
303
|
+
if (!isLocalDev) return null
|
|
304
|
+
const isCanvasRoute = typeof window !== 'undefined' && window.location.pathname.includes('/canvas/')
|
|
305
|
+
if (!isCanvasRoute) return null
|
|
306
|
+
const items = Object.entries(widgetTypes).map(([type, def]) => ({
|
|
307
|
+
id: `create-widget:${type}`,
|
|
308
|
+
children: def.label,
|
|
309
|
+
keywords: ['add', 'widget', 'create', type, def.label.toLowerCase()],
|
|
310
|
+
showType: false,
|
|
311
|
+
onClick: () => {
|
|
312
|
+
document.dispatchEvent(new CustomEvent('storyboard:canvas:add-widget', { detail: { type } }))
|
|
313
|
+
},
|
|
314
|
+
}))
|
|
315
|
+
return { group: { heading: section.title, id: `cfg:${section.id}`, items } }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// --- Starred source (reads from viewfinder localStorage) ---
|
|
319
|
+
if (section.source === 'starred') {
|
|
320
|
+
const STARRED_KEY = 'sb-viewfinder-starred'
|
|
321
|
+
let starredIds = []
|
|
322
|
+
try { starredIds = JSON.parse(localStorage.getItem(STARRED_KEY)) || [] } catch {}
|
|
323
|
+
if (starredIds.length === 0) return null
|
|
324
|
+
|
|
325
|
+
const index = buildPrototypeIndex()
|
|
326
|
+
// Build a lookup map of all artifacts
|
|
327
|
+
const artifactMap = new Map()
|
|
328
|
+
const allProtos = [...index.prototypes]
|
|
329
|
+
for (const folder of index.folders) {
|
|
330
|
+
allProtos.push(...folder.prototypes)
|
|
331
|
+
if (folder.canvases) folder.canvases.forEach(c => artifactMap.set(`canvas:${c.dirName}`, { ...c, _type: 'canvas' }))
|
|
332
|
+
}
|
|
333
|
+
for (const c of index.canvases) artifactMap.set(`canvas:${c.dirName}`, { ...c, _type: 'canvas' })
|
|
334
|
+
for (const p of allProtos) artifactMap.set(`proto:${p.dirName}`, { ...p, _type: 'prototype' })
|
|
335
|
+
|
|
336
|
+
const items = []
|
|
337
|
+
for (const id of starredIds) {
|
|
338
|
+
const artifact = artifactMap.get(id)
|
|
339
|
+
if (!artifact) continue
|
|
340
|
+
const route = artifact._type === 'canvas'
|
|
341
|
+
? `${prefix}/canvas/${artifact.dirName}`
|
|
342
|
+
: artifact.isExternal
|
|
343
|
+
? artifact.externalUrl
|
|
344
|
+
: `${prefix}/${artifact.dirName}`
|
|
345
|
+
items.push({
|
|
346
|
+
id: `starred:${id}`,
|
|
347
|
+
children: artifact.name,
|
|
348
|
+
keywords: ['starred', 'star', artifact.name.toLowerCase()],
|
|
349
|
+
showType: false,
|
|
350
|
+
onClick: () => {
|
|
351
|
+
if (artifact.isExternal) {
|
|
352
|
+
window.open(route, '_blank')
|
|
353
|
+
} else {
|
|
354
|
+
window.location.href = route
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
if (items.length === 0) return null
|
|
360
|
+
return { group: { heading: section.title, id: `cfg:${section.id}`, items } }
|
|
361
|
+
}
|
|
362
|
+
|
|
299
363
|
// --- Commands source (all registered toolbar actions) ---
|
|
300
364
|
if (section.source === 'commands') {
|
|
301
365
|
const mode = getCurrentMode() || 'default'
|
|
@@ -109,3 +109,8 @@ html[data-color-mode="dark"] .command-palette .border-b {
|
|
|
109
109
|
.command-palette .command-palette-list-item .text-gray-500.text-sm {
|
|
110
110
|
display: none !important;
|
|
111
111
|
}
|
|
112
|
+
|
|
113
|
+
/* Medium weight for item text */
|
|
114
|
+
.command-palette .command-palette-list-item {
|
|
115
|
+
font-weight: 500 !important;
|
|
116
|
+
}
|
|
@@ -6,7 +6,7 @@ import { shouldPreventCanvasTextSelection } from './textSelection.js'
|
|
|
6
6
|
import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
|
|
7
7
|
import { getWidgetComponent } from './widgets/index.js'
|
|
8
8
|
import { schemas, getDefaults } from './widgets/widgetProps.js'
|
|
9
|
-
import { getFeatures, isResizable } from './widgets/widgetConfig.js'
|
|
9
|
+
import { getFeatures, isResizable, getAnchorState, canAcceptConnection } from './widgets/widgetConfig.js'
|
|
10
10
|
import { createPasteContext, resolvePaste } from './widgets/pasteRules.js'
|
|
11
11
|
import { getPasteRules } from '@dfosco/storyboard-core'
|
|
12
12
|
import { registerSmoothCorners } from '@dfosco/storyboard-core/smooth-corners'
|
|
@@ -23,12 +23,16 @@ import {
|
|
|
23
23
|
getCanvas as getCanvasApi,
|
|
24
24
|
removeWidget as removeWidgetApi,
|
|
25
25
|
updateCanvas,
|
|
26
|
+
updateFolderMeta,
|
|
26
27
|
uploadImage,
|
|
28
|
+
addConnector as addConnectorApi,
|
|
29
|
+
removeConnector as removeConnectorApi,
|
|
27
30
|
} from './canvasApi.js'
|
|
28
31
|
import PageSelector from './PageSelector.jsx'
|
|
29
32
|
import Icon from '../Icon.jsx'
|
|
30
33
|
import { stories as storyIndex } from 'virtual:storyboard-data-index'
|
|
31
34
|
import styles from './CanvasPage.module.css'
|
|
35
|
+
import ConnectorLayer from './ConnectorLayer.jsx'
|
|
32
36
|
|
|
33
37
|
const ZOOM_MIN = 25
|
|
34
38
|
const ZOOM_MAX = 200
|
|
@@ -306,6 +310,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
306
310
|
onCopy,
|
|
307
311
|
onRefreshGitHub,
|
|
308
312
|
canRefreshGitHub,
|
|
313
|
+
onConnectorDragStart,
|
|
309
314
|
readOnly,
|
|
310
315
|
}) {
|
|
311
316
|
const widgetRef = useRef(null)
|
|
@@ -317,7 +322,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
317
322
|
return rawFeatures.map((f) => {
|
|
318
323
|
// Toggle collapse label and hide when content is short (no github = no collapse)
|
|
319
324
|
if (f.action === 'toggle-collapse') {
|
|
320
|
-
if (!isGitHub) return null
|
|
325
|
+
if (widget.type === 'link-preview' && !isGitHub) return null
|
|
321
326
|
return {
|
|
322
327
|
...f,
|
|
323
328
|
label: widget.props?.collapsed ? 'Expand height' : 'Collapse height',
|
|
@@ -376,6 +381,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
376
381
|
onDeselect={onDeselect}
|
|
377
382
|
onAction={handleAction}
|
|
378
383
|
onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
|
|
384
|
+
onConnectorDragStart={onConnectorDragStart}
|
|
379
385
|
readOnly={readOnly}
|
|
380
386
|
>
|
|
381
387
|
<WidgetRenderer
|
|
@@ -397,10 +403,101 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
397
403
|
prev.onDeselect === next.onDeselect &&
|
|
398
404
|
prev.onUpdate === next.onUpdate &&
|
|
399
405
|
prev.onRemove === next.onRemove &&
|
|
400
|
-
prev.onCopy === next.onCopy
|
|
406
|
+
prev.onCopy === next.onCopy &&
|
|
407
|
+
prev.onConnectorDragStart === next.onConnectorDragStart
|
|
401
408
|
)
|
|
402
409
|
})
|
|
403
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Editable canvas/folder title — always visible, double-click to edit in dev mode.
|
|
413
|
+
*/
|
|
414
|
+
function CanvasTitleEditable({ canvasId, canvasMeta, canvas, isLocalDev }) {
|
|
415
|
+
const [editing, setEditing] = useState(false)
|
|
416
|
+
const [titleValue, setTitleValue] = useState('')
|
|
417
|
+
const inputRef = useRef(null)
|
|
418
|
+
const displayTitle = canvasMeta?.title || canvas?.title || canvasId.split('/').pop()
|
|
419
|
+
|
|
420
|
+
useEffect(() => {
|
|
421
|
+
if (editing && inputRef.current) {
|
|
422
|
+
inputRef.current.focus()
|
|
423
|
+
inputRef.current.select()
|
|
424
|
+
}
|
|
425
|
+
}, [editing])
|
|
426
|
+
|
|
427
|
+
const handleCommit = useCallback(async () => {
|
|
428
|
+
const trimmed = titleValue.trim()
|
|
429
|
+
setEditing(false)
|
|
430
|
+
if (!trimmed || trimmed === displayTitle) return
|
|
431
|
+
try {
|
|
432
|
+
if (canvasId.includes('/')) {
|
|
433
|
+
const folder = canvasId.split('/')[0]
|
|
434
|
+
const result = await updateFolderMeta(folder, trimmed)
|
|
435
|
+
if (result?.renamed && result?.folder) {
|
|
436
|
+
// Folder was renamed on disk — navigate to new route
|
|
437
|
+
const pageName = canvasId.split('/').slice(1).join('/')
|
|
438
|
+
const newCanvasId = `${result.folder}/${pageName}`
|
|
439
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
440
|
+
const targetUrl = `${base}/canvas/${newCanvasId}`
|
|
441
|
+
if (import.meta.hot) {
|
|
442
|
+
const timer = setTimeout(() => { window.location.href = targetUrl }, 3000)
|
|
443
|
+
import.meta.hot.on('vite:beforeFullReload', () => {
|
|
444
|
+
clearTimeout(timer)
|
|
445
|
+
sessionStorage.setItem('sb-pending-navigate', targetUrl)
|
|
446
|
+
})
|
|
447
|
+
} else {
|
|
448
|
+
setTimeout(() => { window.location.href = targetUrl }, 1000)
|
|
449
|
+
}
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
} else {
|
|
453
|
+
await updateCanvas(canvasId, { settings: { title: trimmed } })
|
|
454
|
+
}
|
|
455
|
+
// Reload to pick up the updated metadata from the data plugin
|
|
456
|
+
if (import.meta.hot) {
|
|
457
|
+
const timer = setTimeout(() => { window.location.reload() }, 2000)
|
|
458
|
+
import.meta.hot.on('vite:beforeFullReload', () => clearTimeout(timer))
|
|
459
|
+
} else {
|
|
460
|
+
setTimeout(() => { window.location.reload() }, 1000)
|
|
461
|
+
}
|
|
462
|
+
} catch (err) {
|
|
463
|
+
console.error('Failed to update title:', err)
|
|
464
|
+
}
|
|
465
|
+
}, [titleValue, displayTitle, canvasId])
|
|
466
|
+
|
|
467
|
+
const handleDblClick = useCallback(() => {
|
|
468
|
+
if (!isLocalDev) return
|
|
469
|
+
setTitleValue(displayTitle)
|
|
470
|
+
setEditing(true)
|
|
471
|
+
}, [isLocalDev, displayTitle])
|
|
472
|
+
|
|
473
|
+
if (editing) {
|
|
474
|
+
return (
|
|
475
|
+
<input
|
|
476
|
+
ref={inputRef}
|
|
477
|
+
className={styles.canvasTitleEditing}
|
|
478
|
+
type="text"
|
|
479
|
+
value={titleValue}
|
|
480
|
+
onChange={(e) => setTitleValue(e.target.value)}
|
|
481
|
+
onKeyDown={(e) => {
|
|
482
|
+
if (e.key === 'Enter') { e.preventDefault(); handleCommit() }
|
|
483
|
+
if (e.key === 'Escape') { e.preventDefault(); setEditing(false) }
|
|
484
|
+
}}
|
|
485
|
+
onBlur={handleCommit}
|
|
486
|
+
/>
|
|
487
|
+
)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return (
|
|
491
|
+
<h1
|
|
492
|
+
className={styles.canvasTitleStatic}
|
|
493
|
+
onDoubleClick={handleDblClick}
|
|
494
|
+
style={isLocalDev ? { cursor: 'default' } : undefined}
|
|
495
|
+
>
|
|
496
|
+
{displayTitle}
|
|
497
|
+
</h1>
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
|
|
404
501
|
/**
|
|
405
502
|
* Generic canvas page component.
|
|
406
503
|
* Reads canvas data from the index and renders all widgets on a draggable surface.
|
|
@@ -414,6 +511,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
414
511
|
|
|
415
512
|
// Local mutable copy of widgets for instant UI updates
|
|
416
513
|
const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
|
|
514
|
+
const [localConnectors, setLocalConnectors] = useState(canvas?.connectors ?? [])
|
|
417
515
|
const [trackedCanvas, setTrackedCanvas] = useState(canvas)
|
|
418
516
|
const [selectedWidgetIds, setSelectedWidgetIds] = useState(() => new Set())
|
|
419
517
|
const initialViewport = loadViewportState(canvasId)
|
|
@@ -468,10 +566,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
468
566
|
|
|
469
567
|
// Undo/redo history — tracks both widgets and sources as a combined snapshot
|
|
470
568
|
const undoRedo = useUndoRedo()
|
|
471
|
-
const stateRef = useRef({ widgets: localWidgets, sources: localSources })
|
|
569
|
+
const stateRef = useRef({ widgets: localWidgets, sources: localSources, connectors: localConnectors })
|
|
472
570
|
useEffect(() => {
|
|
473
|
-
stateRef.current = { widgets: localWidgets, sources: localSources }
|
|
474
|
-
}, [localWidgets, localSources])
|
|
571
|
+
stateRef.current = { widgets: localWidgets, sources: localSources, connectors: localConnectors }
|
|
572
|
+
}, [localWidgets, localSources, localConnectors])
|
|
475
573
|
|
|
476
574
|
// Serialized write queue — ensures JSONL events land in the right order
|
|
477
575
|
const writeQueueRef = useRef(Promise.resolve())
|
|
@@ -522,6 +620,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
522
620
|
const justDraggedRef = useRef(false)
|
|
523
621
|
|
|
524
622
|
const handleItemDragStart = useCallback((dragId) => {
|
|
623
|
+
setWidgetDragging(true)
|
|
525
624
|
const ids = selectedIdsRef.current
|
|
526
625
|
peerArticlesRef.current.clear()
|
|
527
626
|
if (ids.size <= 1 || !ids.has(dragId)) return
|
|
@@ -567,6 +666,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
567
666
|
console.log('[viewport] canvas changed —', isCanvasSwitch ? 'new canvas, resetting viewport' : 'same canvas, updating widgets only')
|
|
568
667
|
setTrackedCanvas(canvas)
|
|
569
668
|
setLocalWidgets(canvas?.widgets ?? null)
|
|
669
|
+
setLocalConnectors(canvas?.connectors ?? [])
|
|
570
670
|
setLocalSources(canvas?.sources ?? [])
|
|
571
671
|
setSnapEnabled(canvas?.snapToGrid ?? false)
|
|
572
672
|
setSnapGridSize(canvas?.gridSize || 40)
|
|
@@ -613,6 +713,19 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
613
713
|
const handleWidgetRemove = useCallback((widgetId) => {
|
|
614
714
|
undoRedo.snapshot(stateRef.current, 'remove', widgetId)
|
|
615
715
|
setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
|
|
716
|
+
// Cascade: remove connectors referencing this widget
|
|
717
|
+
setLocalConnectors((prev) => {
|
|
718
|
+
const orphaned = prev.filter((c) => c.start.widgetId === widgetId || c.end.widgetId === widgetId)
|
|
719
|
+
if (orphaned.length === 0) return prev
|
|
720
|
+
for (const c of orphaned) {
|
|
721
|
+
queueWrite(() =>
|
|
722
|
+
removeConnectorApi(canvasId, c.id).catch((err) =>
|
|
723
|
+
console.error('[canvas] Failed to remove orphaned connector:', err)
|
|
724
|
+
)
|
|
725
|
+
)
|
|
726
|
+
}
|
|
727
|
+
return prev.filter((c) => c.start.widgetId !== widgetId && c.end.widgetId !== widgetId)
|
|
728
|
+
})
|
|
616
729
|
queueWrite(() =>
|
|
617
730
|
removeWidgetApi(canvasId, widgetId).catch((err) =>
|
|
618
731
|
console.error('[canvas] Failed to remove widget:', err)
|
|
@@ -620,6 +733,180 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
620
733
|
)
|
|
621
734
|
}, [canvasId, undoRedo])
|
|
622
735
|
|
|
736
|
+
const handleConnectorAdd = useCallback(async ({ startWidgetId, startAnchor, endWidgetId, endAnchor }) => {
|
|
737
|
+
try {
|
|
738
|
+
undoRedo.snapshot(stateRef.current, 'connector-add')
|
|
739
|
+
const result = await addConnectorApi(canvasId, { startWidgetId, startAnchor, endWidgetId, endAnchor })
|
|
740
|
+
if (result.success && result.connector) {
|
|
741
|
+
setLocalConnectors((prev) => [...prev, result.connector])
|
|
742
|
+
}
|
|
743
|
+
} catch (err) {
|
|
744
|
+
console.error('[canvas] Failed to add connector:', err)
|
|
745
|
+
}
|
|
746
|
+
}, [canvasId, undoRedo])
|
|
747
|
+
|
|
748
|
+
const handleConnectorRemove = useCallback((connectorId) => {
|
|
749
|
+
undoRedo.snapshot(stateRef.current, 'connector-remove')
|
|
750
|
+
setLocalConnectors((prev) => prev.filter((c) => c.id !== connectorId))
|
|
751
|
+
queueWrite(() =>
|
|
752
|
+
removeConnectorApi(canvasId, connectorId).catch((err) =>
|
|
753
|
+
console.error('[canvas] Failed to remove connector:', err)
|
|
754
|
+
)
|
|
755
|
+
)
|
|
756
|
+
}, [canvasId, undoRedo])
|
|
757
|
+
|
|
758
|
+
// Connector drag state
|
|
759
|
+
const [connectorDrag, setConnectorDrag] = useState(null)
|
|
760
|
+
const [widgetDragging, setWidgetDragging] = useState(false)
|
|
761
|
+
|
|
762
|
+
const handleConnectorDragStart = useCallback((widgetId, anchor, e) => {
|
|
763
|
+
e.stopPropagation()
|
|
764
|
+
e.preventDefault()
|
|
765
|
+
const scrollEl = scrollRef.current
|
|
766
|
+
if (!scrollEl) return
|
|
767
|
+
const scale = zoomRef.current / 100
|
|
768
|
+
const rect = scrollEl.getBoundingClientRect()
|
|
769
|
+
|
|
770
|
+
const widgets = stateRef.current.widgets ?? []
|
|
771
|
+
const startWidget = widgets.find((w) => w.id === widgetId)
|
|
772
|
+
if (!startWidget) return
|
|
773
|
+
|
|
774
|
+
// Don't start drag from a disabled/unavailable anchor
|
|
775
|
+
const srcAnchorState = getAnchorState(startWidget.type, anchor)
|
|
776
|
+
if (srcAnchorState !== 'available') return
|
|
777
|
+
|
|
778
|
+
const computeAnchorPt = (widget, anch) => {
|
|
779
|
+
let ww, wh
|
|
780
|
+
const el = document.getElementById(widget.id)
|
|
781
|
+
if (el) {
|
|
782
|
+
const inner = el.querySelector('[data-widget-id]') || el.firstElementChild
|
|
783
|
+
if (inner) { ww = inner.offsetWidth; wh = inner.offsetHeight }
|
|
784
|
+
}
|
|
785
|
+
if (!ww) ww = widget.props?.width ?? widget.bounds?.width ?? 270
|
|
786
|
+
if (!wh) wh = widget.props?.height ?? widget.bounds?.height ?? 170
|
|
787
|
+
const px = widget.position?.x ?? 0
|
|
788
|
+
const py = widget.position?.y ?? 0
|
|
789
|
+
switch (anch) {
|
|
790
|
+
case 'top': return { x: px + ww / 2, y: py }
|
|
791
|
+
case 'bottom': return { x: px + ww / 2, y: py + wh }
|
|
792
|
+
case 'left': return { x: px, y: py + wh / 2 }
|
|
793
|
+
case 'right': return { x: px + ww, y: py + wh / 2 }
|
|
794
|
+
default: return { x: px + ww / 2, y: py + wh / 2 }
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const startPt = computeAnchorPt(startWidget, anchor)
|
|
799
|
+
|
|
800
|
+
const toCanvasPoint = (clientX, clientY) => ({
|
|
801
|
+
x: (scrollEl.scrollLeft + clientX - rect.left) / scale,
|
|
802
|
+
y: (scrollEl.scrollTop + clientY - rect.top) / scale,
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
// Find nearest anchor on any other widget within a rectangular snap zone.
|
|
806
|
+
// Each anchor has a 30px-wide strip (15px each side) extending from the widget edge.
|
|
807
|
+
const SNAP_EXTEND = 15
|
|
808
|
+
const SNAP_DEPTH = 40
|
|
809
|
+
const SNAP_CROSS = 20 // perpendicular expansion so you can approach from any direction
|
|
810
|
+
const sourceType = startWidget.type
|
|
811
|
+
const findNearestAnchor = (canvasPt) => {
|
|
812
|
+
const currentWidgets = stateRef.current.widgets ?? []
|
|
813
|
+
let best = null
|
|
814
|
+
let bestDist = Infinity
|
|
815
|
+
for (const w of currentWidgets) {
|
|
816
|
+
if (w.id === widgetId) continue
|
|
817
|
+
if (!canAcceptConnection(w.type, sourceType)) continue
|
|
818
|
+
|
|
819
|
+
let ww, wh
|
|
820
|
+
const el = document.getElementById(w.id)
|
|
821
|
+
if (el) {
|
|
822
|
+
const inner = el.querySelector('[data-widget-id]') || el.firstElementChild
|
|
823
|
+
if (inner) { ww = inner.offsetWidth; wh = inner.offsetHeight }
|
|
824
|
+
}
|
|
825
|
+
if (!ww) ww = w.props?.width ?? w.bounds?.width ?? 270
|
|
826
|
+
if (!wh) wh = w.props?.height ?? w.bounds?.height ?? 170
|
|
827
|
+
const wx = w.position?.x ?? 0
|
|
828
|
+
const wy = w.position?.y ?? 0
|
|
829
|
+
|
|
830
|
+
for (const anch of ['top', 'bottom', 'left', 'right']) {
|
|
831
|
+
const anchorState = getAnchorState(w.type, anch)
|
|
832
|
+
if (anchorState !== 'available') continue
|
|
833
|
+
|
|
834
|
+
// Build a rectangular hit zone for this anchor
|
|
835
|
+
let inZone = false
|
|
836
|
+
if (anch === 'top') {
|
|
837
|
+
inZone = canvasPt.x >= wx - SNAP_CROSS && canvasPt.x <= wx + ww + SNAP_CROSS &&
|
|
838
|
+
canvasPt.y >= wy - SNAP_DEPTH && canvasPt.y <= wy + SNAP_EXTEND
|
|
839
|
+
} else if (anch === 'bottom') {
|
|
840
|
+
inZone = canvasPt.x >= wx - SNAP_CROSS && canvasPt.x <= wx + ww + SNAP_CROSS &&
|
|
841
|
+
canvasPt.y >= wy + wh - SNAP_EXTEND && canvasPt.y <= wy + wh + SNAP_DEPTH
|
|
842
|
+
} else if (anch === 'left') {
|
|
843
|
+
inZone = canvasPt.x >= wx - SNAP_DEPTH && canvasPt.x <= wx + SNAP_EXTEND &&
|
|
844
|
+
canvasPt.y >= wy - SNAP_CROSS && canvasPt.y <= wy + wh + SNAP_CROSS
|
|
845
|
+
} else if (anch === 'right') {
|
|
846
|
+
inZone = canvasPt.x >= wx + ww - SNAP_EXTEND && canvasPt.x <= wx + ww + SNAP_DEPTH &&
|
|
847
|
+
canvasPt.y >= wy - SNAP_CROSS && canvasPt.y <= wy + wh + SNAP_CROSS
|
|
848
|
+
}
|
|
849
|
+
if (!inZone) continue
|
|
850
|
+
|
|
851
|
+
const pt = computeAnchorPt(w, anch)
|
|
852
|
+
const dist = Math.hypot(pt.x - canvasPt.x, pt.y - canvasPt.y)
|
|
853
|
+
if (dist < bestDist) {
|
|
854
|
+
bestDist = dist
|
|
855
|
+
best = { widgetId: w.id, anchor: anch, pt }
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
return best
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const cursorPt = toCanvasPoint(e.clientX, e.clientY)
|
|
863
|
+
const snap = findNearestAnchor(cursorPt)
|
|
864
|
+
setConnectorDrag({
|
|
865
|
+
startWidgetId: widgetId,
|
|
866
|
+
startAnchor: anchor,
|
|
867
|
+
startPt,
|
|
868
|
+
endPt: snap ? snap.pt : cursorPt,
|
|
869
|
+
endAnchor: snap ? snap.anchor : anchor,
|
|
870
|
+
snapTarget: snap,
|
|
871
|
+
})
|
|
872
|
+
|
|
873
|
+
const handlePointerMove = (moveE) => {
|
|
874
|
+
const pt = toCanvasPoint(moveE.clientX, moveE.clientY)
|
|
875
|
+
const nearSnap = findNearestAnchor(pt)
|
|
876
|
+
setConnectorDrag((prev) => prev ? {
|
|
877
|
+
...prev,
|
|
878
|
+
endPt: nearSnap ? nearSnap.pt : pt,
|
|
879
|
+
endAnchor: nearSnap ? nearSnap.anchor : prev.startAnchor,
|
|
880
|
+
snapTarget: nearSnap,
|
|
881
|
+
} : null)
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const handlePointerUp = (upE) => {
|
|
885
|
+
document.removeEventListener('pointermove', handlePointerMove)
|
|
886
|
+
document.removeEventListener('pointerup', handlePointerUp)
|
|
887
|
+
|
|
888
|
+
const pt = toCanvasPoint(upE.clientX, upE.clientY)
|
|
889
|
+
const nearSnap = findNearestAnchor(pt)
|
|
890
|
+
|
|
891
|
+
if (nearSnap) {
|
|
892
|
+
handleConnectorAdd({
|
|
893
|
+
startWidgetId: widgetId,
|
|
894
|
+
startAnchor: anchor,
|
|
895
|
+
endWidgetId: nearSnap.widgetId,
|
|
896
|
+
endAnchor: nearSnap.anchor,
|
|
897
|
+
})
|
|
898
|
+
}
|
|
899
|
+
setConnectorDrag(null)
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
document.addEventListener('pointermove', handlePointerMove)
|
|
903
|
+
document.addEventListener('pointerup', handlePointerUp)
|
|
904
|
+
}, [handleConnectorAdd])
|
|
905
|
+
|
|
906
|
+
// Endpoint drag removed — dragging from a filled anchor now always
|
|
907
|
+
// creates a new connection via handleConnectorDragStart instead of
|
|
908
|
+
// repositioning the existing one.
|
|
909
|
+
|
|
623
910
|
const handleWidgetCopy = useCallback(async (widget) => {
|
|
624
911
|
// Find the next free offset — check how many copies already exist at +n*40
|
|
625
912
|
const baseX = widget.position?.x ?? 0
|
|
@@ -641,6 +928,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
641
928
|
})
|
|
642
929
|
if (result.success && result.widget) {
|
|
643
930
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
931
|
+
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
644
932
|
}
|
|
645
933
|
} catch (err) {
|
|
646
934
|
console.error('[canvas] Failed to copy widget:', err)
|
|
@@ -725,6 +1013,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
725
1013
|
}, [canvasId, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
|
|
726
1014
|
|
|
727
1015
|
const handleItemDragEnd = useCallback((dragId, position) => {
|
|
1016
|
+
setWidgetDragging(false)
|
|
728
1017
|
if (!dragId || !position) {
|
|
729
1018
|
clearDragPreview()
|
|
730
1019
|
return
|
|
@@ -1189,6 +1478,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1189
1478
|
if (result.success && result.widget) {
|
|
1190
1479
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
1191
1480
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1481
|
+
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
1192
1482
|
}
|
|
1193
1483
|
} catch (err) {
|
|
1194
1484
|
console.error('[canvas] Failed to add widget:', err)
|
|
@@ -1209,6 +1499,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1209
1499
|
if (result.success && result.widget) {
|
|
1210
1500
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
1211
1501
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1502
|
+
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
1212
1503
|
}
|
|
1213
1504
|
} catch (err) {
|
|
1214
1505
|
console.error('[canvas] Failed to add story widget:', err)
|
|
@@ -1486,6 +1777,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1486
1777
|
if (result.success && result.widget) {
|
|
1487
1778
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
1488
1779
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1780
|
+
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
1489
1781
|
}
|
|
1490
1782
|
return true
|
|
1491
1783
|
} catch (err) {
|
|
@@ -1704,8 +1996,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1704
1996
|
debouncedSourceSave.cancel()
|
|
1705
1997
|
setLocalWidgets(previous.widgets)
|
|
1706
1998
|
setLocalSources(previous.sources)
|
|
1999
|
+
setLocalConnectors(previous.connectors ?? [])
|
|
1707
2000
|
queueWrite(() =>
|
|
1708
|
-
updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources }).catch((err) =>
|
|
2001
|
+
updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources, connectors: previous.connectors }).catch((err) =>
|
|
1709
2002
|
console.error('[canvas] Failed to persist undo:', err)
|
|
1710
2003
|
)
|
|
1711
2004
|
)
|
|
@@ -1718,8 +2011,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1718
2011
|
debouncedSourceSave.cancel()
|
|
1719
2012
|
setLocalWidgets(next.widgets)
|
|
1720
2013
|
setLocalSources(next.sources)
|
|
2014
|
+
setLocalConnectors(next.connectors ?? [])
|
|
1721
2015
|
queueWrite(() =>
|
|
1722
|
-
updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources }).catch((err) =>
|
|
2016
|
+
updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources, connectors: next.connectors }).catch((err) =>
|
|
1723
2017
|
console.error('[canvas] Failed to persist redo:', err)
|
|
1724
2018
|
)
|
|
1725
2019
|
)
|
|
@@ -2006,7 +2300,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2006
2300
|
}
|
|
2007
2301
|
|
|
2008
2302
|
// 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
|
|
2009
|
-
|
|
2303
|
+
// Sort so selected widgets render last (visually on top via DOM order)
|
|
2304
|
+
const sortedWidgets = (localWidgets ?? []).slice().sort((a, b) => {
|
|
2305
|
+
const aSelected = selectedWidgetIds.has(a.id) ? 1 : 0
|
|
2306
|
+
const bSelected = selectedWidgetIds.has(b.id) ? 1 : 0
|
|
2307
|
+
return aSelected - bSelected
|
|
2308
|
+
})
|
|
2309
|
+
for (const widget of sortedWidgets) {
|
|
2310
|
+
if (!isLocalDev && widget.type === 'terminal') continue
|
|
2010
2311
|
allChildren.push(
|
|
2011
2312
|
<div
|
|
2012
2313
|
key={widget.id}
|
|
@@ -2034,6 +2335,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2034
2335
|
onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
|
|
2035
2336
|
onRefreshGitHub={isLocalDev ? handleRefreshGitHubWidget : undefined}
|
|
2036
2337
|
canRefreshGitHub={isLocalDev}
|
|
2338
|
+
onConnectorDragStart={isLocalDev ? handleConnectorDragStart : undefined}
|
|
2037
2339
|
readOnly={!isLocalDev}
|
|
2038
2340
|
/>
|
|
2039
2341
|
</div>
|
|
@@ -2042,13 +2344,26 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2042
2344
|
|
|
2043
2345
|
const scale = zoom / 100
|
|
2044
2346
|
|
|
2347
|
+
const terminalWidgetIds = !isLocalDev
|
|
2348
|
+
? new Set((localWidgets ?? []).filter(w => w.type === 'terminal').map(w => w.id))
|
|
2349
|
+
: null
|
|
2350
|
+
|
|
2351
|
+
const filteredConnectors = terminalWidgetIds?.size
|
|
2352
|
+
? localConnectors.filter(c => !terminalWidgetIds.has(c.startWidgetId) && !terminalWidgetIds.has(c.endWidgetId))
|
|
2353
|
+
: localConnectors
|
|
2354
|
+
|
|
2045
2355
|
return (
|
|
2046
2356
|
<>
|
|
2047
2357
|
<div className={styles.canvasTitle}>
|
|
2048
2358
|
<a href={(import.meta.env?.BASE_URL || '/')} className={styles.canvasLogo} aria-label="Go to homepage">
|
|
2049
2359
|
<Icon name="iconoir/key-command" size={16} color="#fff" />
|
|
2050
2360
|
</a>
|
|
2051
|
-
<
|
|
2361
|
+
<CanvasTitleEditable
|
|
2362
|
+
canvasId={canvasId}
|
|
2363
|
+
canvasMeta={canvasMeta}
|
|
2364
|
+
canvas={canvas}
|
|
2365
|
+
isLocalDev={isLocalDev}
|
|
2366
|
+
/>
|
|
2052
2367
|
<PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
|
|
2053
2368
|
{isLocalDev && (
|
|
2054
2369
|
<span className={styles.localEditingLabel}>Local editing</span>
|
|
@@ -2080,6 +2395,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2080
2395
|
...(spaceHeld ? { pointerEvents: 'none' } : {}),
|
|
2081
2396
|
}}
|
|
2082
2397
|
>
|
|
2398
|
+
<ConnectorLayer
|
|
2399
|
+
connectors={filteredConnectors}
|
|
2400
|
+
widgets={localWidgets ?? []}
|
|
2401
|
+
onRemove={isLocalDev ? handleConnectorRemove : undefined}
|
|
2402
|
+
onEndpointDrag={undefined}
|
|
2403
|
+
dragPreview={connectorDrag}
|
|
2404
|
+
hidden={widgetDragging}
|
|
2405
|
+
/>
|
|
2083
2406
|
<Canvas {...canvasProps} onDragStart={isLocalDev ? handleItemDragStart : undefined} onDrag={isLocalDev ? handleItemDrag : undefined} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
|
|
2084
2407
|
{allChildren}
|
|
2085
2408
|
</Canvas>
|