@dfosco/storyboard-react 4.0.0-beta.12 → 4.0.0-beta.13

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 CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "4.0.0-beta.12",
3
+ "version": "4.0.0-beta.13",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "4.0.0-beta.12",
7
- "@dfosco/tiny-canvas": "4.0.0-beta.12",
6
+ "@dfosco/storyboard-core": "4.0.0-beta.13",
7
+ "@dfosco/tiny-canvas": "4.0.0-beta.13",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1",
@@ -12,7 +12,7 @@ import { isFigmaUrl, sanitizeFigmaUrl } from './widgets/figmaUrl.js'
12
12
  import WidgetChrome from './widgets/WidgetChrome.jsx'
13
13
  import ComponentWidget from './widgets/ComponentWidget.jsx'
14
14
  import useUndoRedo from './useUndoRedo.js'
15
- import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage } from './canvasApi.js'
15
+ import { addWidget as addWidgetApi, updateCanvas, removeWidget as removeWidgetApi, uploadImage, getCanvas as getCanvasApi } from './canvasApi.js'
16
16
  import styles from './CanvasPage.module.css'
17
17
 
18
18
  const ZOOM_MIN = 25
@@ -51,6 +51,7 @@ function resolveCanvasThemeFromStorage() {
51
51
  * Get the copyable URL for a widget based on its type.
52
52
  * Returns the most relevant URL/path for the widget content.
53
53
  */
54
+ // eslint-disable-next-line no-unused-vars
54
55
  function getWidgetCopyableUrl(widget) {
55
56
  const { type, props = {} } = widget
56
57
  const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
@@ -403,13 +404,13 @@ export default function CanvasPage({ name }) {
403
404
  // Flag to suppress the click-based selection reset that fires after a drag
404
405
  const justDraggedRef = useRef(false)
405
406
 
406
- const handleItemDragStart = useCallback((dragId, position) => {
407
+ const handleItemDragStart = useCallback((dragId) => {
407
408
  const ids = selectedIdsRef.current
408
409
  peerArticlesRef.current.clear()
409
410
  if (ids.size <= 1 || !ids.has(dragId)) return
410
411
 
411
412
  // Suppress selection changes for the duration of the drag
412
- justDraggedRef.current = true
413
+ justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
413
414
 
414
415
  // Collect peer article elements for transition on drag end
415
416
  for (const id of ids) {
@@ -449,6 +450,8 @@ export default function CanvasPage({ name }) {
449
450
  setLocalWidgets(canvas?.widgets ?? null)
450
451
  setLocalSources(canvas?.sources ?? [])
451
452
  setCanvasTitle(canvas?.title || name)
453
+ setSnapEnabled(canvas?.snapToGrid ?? false)
454
+ setSnapGridSize(canvas?.gridSize || 40)
452
455
  undoRedo.reset()
453
456
  }
454
457
 
@@ -575,7 +578,7 @@ export default function CanvasPage({ name }) {
575
578
  if (ids.size > 1 && ids.has(dragId)) {
576
579
  transitionPeers()
577
580
  // Suppress the click-based selection reset that fires after pointerup
578
- justDraggedRef.current = true
581
+ justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
579
582
  requestAnimationFrame(() => { justDraggedRef.current = false })
580
583
  undoRedo.snapshot(stateRef.current, 'multi-move')
581
584
 
@@ -911,7 +914,7 @@ export default function CanvasPage({ name }) {
911
914
  function handleSnapToggle() {
912
915
  setSnapEnabled((prev) => {
913
916
  const next = !prev
914
- updateCanvas(name, { snapToGrid: next }).catch((err) =>
917
+ updateCanvas(name, { settings: { snapToGrid: next } }).catch((err) =>
915
918
  console.error('[canvas] Failed to persist snap setting:', err)
916
919
  )
917
920
  return next
@@ -929,6 +932,17 @@ export default function CanvasPage({ name }) {
929
932
  snapEnabledRef.current = snapEnabled
930
933
  }, [snapEnabled])
931
934
 
935
+ // Respond to snap-state requests from Svelte toolbar (handles mount-order race)
936
+ useEffect(() => {
937
+ function handleRequest() {
938
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
939
+ detail: { snapEnabled: snapEnabledRef.current }
940
+ }))
941
+ }
942
+ document.addEventListener('storyboard:canvas:snap-state-request', handleRequest)
943
+ return () => document.removeEventListener('storyboard:canvas:snap-state-request', handleRequest)
944
+ }, [])
945
+
932
946
  // Listen for gridSize from Svelte toolbar config
933
947
  useEffect(() => {
934
948
  function handleGridSize(e) {
@@ -1015,33 +1029,13 @@ export default function CanvasPage({ name }) {
1015
1029
  e.preventDefault()
1016
1030
  setSelectedWidgetIds(new Set())
1017
1031
  }
1018
- // Copy shortcuts (single widget selected):
1019
- // - cmd+c → copy URL/content
1020
- // - Shift+C (no cmd) → copy widget ID (or file path for images)
1032
+ // Copy shortcut (single widget selected):
1033
+ // cmd+c → copy canvasName/widgetId (for cross-canvas paste-duplicate)
1021
1034
  const mod = e.metaKey || e.ctrlKey
1022
1035
  if (mod && e.key === 'c' && !e.shiftKey && selectedWidgetIds.size === 1) {
1023
1036
  const widgetId = [...selectedWidgetIds][0]
1024
- const widget = localWidgets?.find(w => w.id === widgetId)
1025
- if (widget) {
1026
- e.preventDefault()
1027
- const url = getWidgetCopyableUrl(widget)
1028
- if (url) {
1029
- navigator.clipboard.writeText(url).catch(() => {})
1030
- }
1031
- }
1032
- }
1033
- // Shift+C (uppercase C, no cmd) → copy ID or file path
1034
- if (e.key === 'C' && e.shiftKey && !mod && selectedWidgetIds.size === 1) {
1035
- const widgetId = [...selectedWidgetIds][0]
1036
- const widget = localWidgets?.find(w => w.id === widgetId)
1037
- if (widget) {
1038
- e.preventDefault()
1039
- if (widget.type === 'image' && widget.props?.src) {
1040
- navigator.clipboard.writeText(`src/canvas/images/${widget.props.src}`).catch(() => {})
1041
- } else {
1042
- navigator.clipboard.writeText(widgetId).catch(() => {})
1043
- }
1044
- }
1037
+ e.preventDefault()
1038
+ navigator.clipboard.writeText(`${name}/${widgetId}`).catch(() => {})
1045
1039
  }
1046
1040
  if (e.key === 'Delete' || e.key === 'Backspace') {
1047
1041
  e.preventDefault()
@@ -1217,6 +1211,40 @@ export default function CanvasPage({ name }) {
1217
1211
 
1218
1212
  e.preventDefault()
1219
1213
 
1214
+ // Detect canvasName/widgetId format for widget duplication (cross-canvas copy-paste)
1215
+ const widgetRefMatch = text.match(/^([^/]+)\/([^/]+)$/)
1216
+ if (widgetRefMatch) {
1217
+ const [, sourceCanvas, sourceWidgetId] = widgetRefMatch
1218
+ // Component widgets are code, not duplicable data — silently consume the ref
1219
+ if (sourceWidgetId.startsWith('jsx-')) return
1220
+ try {
1221
+ let sourceWidget = null
1222
+ if (sourceCanvas === name) {
1223
+ sourceWidget = (localWidgets ?? []).find(w => w.id === sourceWidgetId)
1224
+ } else {
1225
+ const canvasData = await getCanvasApi(sourceCanvas)
1226
+ sourceWidget = (canvasData?.widgets ?? []).find(w => w.id === sourceWidgetId)
1227
+ }
1228
+ if (sourceWidget) {
1229
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1230
+ const pos = centerPositionForWidget(center, sourceWidget.type, sourceWidget.props)
1231
+ undoRedo.snapshot(stateRef.current, 'add')
1232
+ const result = await addWidgetApi(name, {
1233
+ type: sourceWidget.type,
1234
+ props: { ...sourceWidget.props },
1235
+ position: pos,
1236
+ })
1237
+ if (result.success && result.widget) {
1238
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
1239
+ }
1240
+ }
1241
+ } catch (err) {
1242
+ console.error('[canvas] Failed to paste widget reference:', err)
1243
+ }
1244
+ // Always consume the ref — never fall through to markdown creation
1245
+ return
1246
+ }
1247
+
1220
1248
  let type, props
1221
1249
  const url = looksLikeWebUrl(text)
1222
1250
  if (url) {
@@ -1256,7 +1284,7 @@ export default function CanvasPage({ name }) {
1256
1284
 
1257
1285
  document.addEventListener('paste', handlePaste)
1258
1286
  return () => document.removeEventListener('paste', handlePaste)
1259
- }, [name, undoRedo])
1287
+ }, [name, undoRedo, localWidgets])
1260
1288
 
1261
1289
  // --- Drag and drop handlers for images from Finder/file manager ---
1262
1290
  // Separate effect to ensure listeners attach after scroll container mounts (loading=false)
@@ -103,6 +103,12 @@
103
103
  overflow: visible;
104
104
  }
105
105
 
106
+ /* Elevate stacking context for hovered/selected widgets so their chrome
107
+ (toolbar, menus, selection outline) renders above sibling widgets. */
108
+ :global(.tc-drag:has([data-tc-elevated])) {
109
+ z-index: 1;
110
+ }
111
+
106
112
  .localEditingLabel {
107
113
  display: inline-flex;
108
114
  align-items: center;
@@ -47,3 +47,7 @@ export function uploadImage(dataUrl, canvasName) {
47
47
  export function toggleImagePrivacy(filename) {
48
48
  return request('/image/toggle-private', 'POST', { filename })
49
49
  }
50
+
51
+ export function getCanvas(name) {
52
+ return request(`/read?name=${encodeURIComponent(name)}`, 'GET')
53
+ }
@@ -421,6 +421,7 @@ export default function WidgetChrome({
421
421
  return (
422
422
  <div
423
423
  className={styles.chromeContainer}
424
+ data-tc-elevated={(hovered || selected) || undefined}
424
425
  onMouseEnter={(readOnly && !hasFeatures) ? undefined : handleMouseEnter}
425
426
  onMouseLeave={(readOnly && !hasFeatures) ? undefined : handleMouseLeave}
426
427
  >
@@ -17,10 +17,12 @@ import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
17
17
  *
18
18
  * @param {string} [path] - Dot-notation path (e.g. 'user.profile.name').
19
19
  * Omit to get the entire flow object.
20
+ * @param {{ optional?: boolean }} [opts] - Pass { optional: true } to suppress
21
+ * the "path not found" warning for optional data.
20
22
  * @returns {*} The resolved value. Returns {} if path is missing after loading.
21
23
  * @throws If used outside a StoryboardProvider.
22
24
  */
23
- export function useFlowData(path) {
25
+ export function useFlowData(path, opts) {
24
26
  const context = useContext(StoryboardContext)
25
27
 
26
28
  if (context === null) {
@@ -73,7 +75,7 @@ export function useFlowData(path) {
73
75
  }
74
76
 
75
77
  if (sceneValue === undefined) {
76
- if (data != null && Object.keys(data).length > 0) {
78
+ if (!opts?.optional && data != null && Object.keys(data).length > 0) {
77
79
  console.warn(`[useFlowData] Path "${path}" not found in flow data.`)
78
80
  }
79
81
  return {}