@dfosco/storyboard-react 3.10.0-beta.1 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "3.10.0-beta.1",
3
+ "version": "3.10.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "3.10.0-beta.1",
7
- "@dfosco/tiny-canvas": "3.10.0-beta.1",
6
+ "@dfosco/storyboard-core": "3.10.0",
7
+ "@dfosco/tiny-canvas": "3.10.0",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1"
@@ -1,4 +1,5 @@
1
1
  import { createElement, useCallback, useEffect, useRef, useState } from 'react'
2
+ import { flushSync } from 'react-dom'
2
3
  import { Canvas } from '@dfosco/tiny-canvas'
3
4
  import '@dfosco/tiny-canvas/style.css'
4
5
  import { useCanvas } from './useCanvas.js'
@@ -56,12 +57,41 @@ function debounce(fn, ms) {
56
57
  }
57
58
 
58
59
  /**
59
- * Get viewport-center coordinates for placing a new widget.
60
+ * Get viewport-center coordinates in canvas space for placing a new widget.
61
+ * Converts the visible center of the scroll container to unscaled canvas coordinates.
60
62
  */
61
- function getViewportCenter() {
63
+ function getViewportCenter(scrollEl, scale) {
64
+ if (!scrollEl) {
65
+ return { x: 0, y: 0 }
66
+ }
67
+ const cx = scrollEl.scrollLeft + scrollEl.clientWidth / 2
68
+ const cy = scrollEl.scrollTop + scrollEl.clientHeight / 2
69
+ return {
70
+ x: Math.round(cx / scale),
71
+ y: Math.round(cy / scale),
72
+ }
73
+ }
74
+
75
+ /** Fallback sizes for widget types without explicit width/height defaults. */
76
+ const WIDGET_FALLBACK_SIZES = {
77
+ 'sticky-note': { width: 180, height: 60 },
78
+ 'markdown': { width: 360, height: 200 },
79
+ 'prototype': { width: 800, height: 600 },
80
+ 'link-preview': { width: 320, height: 120 },
81
+ 'component': { width: 200, height: 150 },
82
+ }
83
+
84
+ /**
85
+ * Offset a position so the widget's center (not its top-left corner)
86
+ * lands on the given point.
87
+ */
88
+ function centerPositionForWidget(pos, type, props) {
89
+ const fallback = WIDGET_FALLBACK_SIZES[type] || { width: 200, height: 150 }
90
+ const w = props?.width ?? fallback.width
91
+ const h = props?.height ?? fallback.height
62
92
  return {
63
- x: Math.round(window.innerWidth / 2 - 120),
64
- y: Math.round(window.innerHeight / 2 - 80),
93
+ x: Math.round(pos.x - w / 2),
94
+ y: Math.round(pos.y - h / 2),
65
95
  }
66
96
  }
67
97
 
@@ -257,6 +287,43 @@ export default function CanvasPage({ name }) {
257
287
  zoomRef.current = zoom
258
288
  }, [zoom])
259
289
 
290
+ /**
291
+ * Zoom to a new level, anchoring on an optional client-space point.
292
+ * When a cursor position is provided (e.g. from a wheel event), the
293
+ * canvas point under the cursor stays fixed. Otherwise falls back to
294
+ * the viewport center.
295
+ */
296
+ function applyZoom(newZoom, clientX, clientY) {
297
+ const el = scrollRef.current
298
+ const clampedZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom))
299
+
300
+ if (!el) {
301
+ setZoom(clampedZoom)
302
+ return
303
+ }
304
+
305
+ const oldScale = zoomRef.current / 100
306
+ const newScale = clampedZoom / 100
307
+
308
+ // Anchor point in scroll-container space
309
+ const rect = el.getBoundingClientRect()
310
+ const useViewportCenter = clientX == null || clientY == null
311
+ const anchorX = useViewportCenter ? el.clientWidth / 2 : clientX - rect.left
312
+ const anchorY = useViewportCenter ? el.clientHeight / 2 : clientY - rect.top
313
+
314
+ // Anchor → canvas coordinate
315
+ const canvasX = (el.scrollLeft + anchorX) / oldScale
316
+ const canvasY = (el.scrollTop + anchorY) / oldScale
317
+
318
+ // Synchronous render so the DOM has the new transform before we adjust scroll
319
+ zoomRef.current = clampedZoom
320
+ flushSync(() => setZoom(clampedZoom))
321
+
322
+ // Scroll so the same canvas point stays under the anchor
323
+ el.scrollLeft = canvasX * newScale - anchorX
324
+ el.scrollTop = canvasY * newScale - anchorY
325
+ }
326
+
260
327
  // Signal canvas mount/unmount to CoreUIBar
261
328
  useEffect(() => {
262
329
  window[CANVAS_BRIDGE_STATE_KEY] = { active: true, name, zoom: zoomRef.current }
@@ -281,7 +348,8 @@ export default function CanvasPage({ name }) {
281
348
  // Add a widget by type — used by CanvasControls and CoreUIBar event
282
349
  const addWidget = useCallback(async (type) => {
283
350
  const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
284
- const pos = getViewportCenter()
351
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
352
+ const pos = centerPositionForWidget(center, type, defaultProps)
285
353
  try {
286
354
  const result = await addWidgetApi(name, {
287
355
  type,
@@ -310,7 +378,7 @@ export default function CanvasPage({ name }) {
310
378
  function handleZoom(e) {
311
379
  const { zoom: newZoom } = e.detail
312
380
  if (typeof newZoom === 'number') {
313
- setZoom(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom)))
381
+ applyZoom(newZoom)
314
382
  }
315
383
  }
316
384
  document.addEventListener('storyboard:canvas:set-zoom', handleZoom)
@@ -419,7 +487,8 @@ export default function CanvasPage({ name }) {
419
487
  props = { content: text }
420
488
  }
421
489
 
422
- const pos = getViewportCenter()
490
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
491
+ const pos = centerPositionForWidget(center, type, props)
423
492
  try {
424
493
  const result = await addWidgetApi(name, {
425
494
  type,
@@ -449,7 +518,7 @@ export default function CanvasPage({ name }) {
449
518
  const step = Math.trunc(zoomAccum.current)
450
519
  if (step === 0) return
451
520
  zoomAccum.current -= step
452
- setZoom((z) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z + step)))
521
+ applyZoom(zoomRef.current + step, e.clientX, e.clientY)
453
522
  }
454
523
  document.addEventListener('wheel', handleWheel, { passive: false })
455
524
  return () => document.removeEventListener('wheel', handleWheel)