@dfosco/storyboard-react 3.9.0 → 3.10.0-beta.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.9.0",
3
+ "version": "3.10.0-beta.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "3.9.0",
7
- "@dfosco/tiny-canvas": "^1.1.0",
6
+ "@dfosco/storyboard-core": "3.10.0-beta.0",
7
+ "@dfosco/tiny-canvas": "3.10.0-beta.0",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1"
@@ -36,7 +36,14 @@ export default function PrototypeEmbed({ props, onUpdate }) {
36
36
  const label = readProp(props, 'label', prototypeEmbedSchema) || src
37
37
 
38
38
  const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
39
- const rawSrc = src ? `${basePath}${src}` : ''
39
+ const baseSegment = basePath.replace(/^\//, '')
40
+ const rawSrc = useMemo(() => {
41
+ if (!src) return ''
42
+ if (/^https?:\/\//.test(src)) return src
43
+ if (baseSegment && src.startsWith(basePath)) return src
44
+ if (baseSegment && src.startsWith(baseSegment)) return `/${src}`
45
+ return `${basePath}${src}`
46
+ }, [src, basePath, baseSegment])
40
47
 
41
48
  const scale = zoom / 100
42
49
 
@@ -48,9 +55,14 @@ export default function PrototypeEmbed({ props, onUpdate }) {
48
55
  const filterRef = useRef(null)
49
56
  const embedRef = useRef(null)
50
57
 
51
- const iframeSrc = rawSrc
52
- ? `${rawSrc}${rawSrc.includes('?') ? '&' : '?'}_sb_embed&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}`
53
- : ''
58
+ const iframeSrc = useMemo(() => {
59
+ if (!rawSrc) return ''
60
+ const hashIdx = rawSrc.indexOf('#')
61
+ const base = hashIdx >= 0 ? rawSrc.slice(0, hashIdx) : rawSrc
62
+ const hash = hashIdx >= 0 ? rawSrc.slice(hashIdx) : ''
63
+ const sep = base.includes('?') ? '&' : '?'
64
+ return `${base}${sep}_sb_embed&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}${hash}`
65
+ }, [rawSrc, canvasTheme])
54
66
 
55
67
  // Build prototype index for the picker
56
68
  const prototypeIndex = useMemo(() => {
@@ -0,0 +1,56 @@
1
+ import { useCallback } from 'react'
2
+ import styles from './ResizeHandle.module.css'
3
+
4
+ /**
5
+ * Shared resize handle for canvas widgets.
6
+ *
7
+ * Renders a small drag handle in the bottom-right corner of the parent.
8
+ * On drag, calls `onResize(width, height)` with new dimensions.
9
+ *
10
+ * The parent must have `position: relative` for correct positioning.
11
+ *
12
+ * @param {Object} props
13
+ * @param {React.RefObject} props.targetRef - ref to the element being resized (reads offsetWidth/Height)
14
+ * @param {number} [props.minWidth=180] - minimum allowed width
15
+ * @param {number} [props.minHeight=60] - minimum allowed height
16
+ * @param {Function} props.onResize - callback: (width, height) => void
17
+ */
18
+ export default function ResizeHandle({ targetRef, minWidth = 180, minHeight = 60, onResize }) {
19
+ const handleMouseDown = useCallback((e) => {
20
+ e.stopPropagation()
21
+ e.preventDefault()
22
+
23
+ const el = targetRef?.current
24
+ if (!el) return
25
+
26
+ const startX = e.clientX
27
+ const startY = e.clientY
28
+ const startW = el.offsetWidth
29
+ const startH = el.offsetHeight
30
+
31
+ function onMove(ev) {
32
+ const newW = Math.max(minWidth, startW + ev.clientX - startX)
33
+ const newH = Math.max(minHeight, startH + ev.clientY - startY)
34
+ onResize?.(newW, newH)
35
+ }
36
+
37
+ function onUp() {
38
+ document.removeEventListener('mousemove', onMove)
39
+ document.removeEventListener('mouseup', onUp)
40
+ }
41
+
42
+ document.addEventListener('mousemove', onMove)
43
+ document.addEventListener('mouseup', onUp)
44
+ }, [targetRef, minWidth, minHeight, onResize])
45
+
46
+ return (
47
+ <div
48
+ className={styles.handle}
49
+ onMouseDown={handleMouseDown}
50
+ onPointerDown={(e) => e.stopPropagation()}
51
+ role="separator"
52
+ aria-orientation="horizontal"
53
+ aria-label="Resize"
54
+ />
55
+ )
56
+ }
@@ -0,0 +1,29 @@
1
+ .handle {
2
+ position: absolute;
3
+ bottom: 0;
4
+ right: 0;
5
+ width: 16px;
6
+ height: 16px;
7
+ cursor: nwse-resize;
8
+ background: linear-gradient(
9
+ 135deg,
10
+ transparent 40%,
11
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 40%,
12
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 50%,
13
+ transparent 50%,
14
+ transparent 65%,
15
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 65%,
16
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 75%,
17
+ transparent 75%
18
+ );
19
+ opacity: 0;
20
+ transition: opacity 150ms;
21
+ z-index: 2;
22
+ border-radius: 0 0 6px 0;
23
+ }
24
+
25
+ /* Show on parent hover or direct hover */
26
+ *:hover > .handle,
27
+ .handle:hover {
28
+ opacity: 1;
29
+ }
@@ -1,5 +1,6 @@
1
1
  import { useState, useRef, useEffect, useCallback } from 'react'
2
2
  import { readProp, stickyNoteSchema } from './widgetProps.js'
3
+ import ResizeHandle from './ResizeHandle.jsx'
3
4
  import styles from './StickyNote.module.css'
4
5
 
5
6
  const COLORS = {
@@ -14,10 +15,17 @@ const COLORS = {
14
15
  export default function StickyNote({ props, onUpdate }) {
15
16
  const text = readProp(props, 'text', stickyNoteSchema)
16
17
  const color = readProp(props, 'color', stickyNoteSchema)
18
+ const width = readProp(props, 'width', stickyNoteSchema)
19
+ const height = readProp(props, 'height', stickyNoteSchema)
17
20
  const palette = COLORS[color] ?? COLORS.yellow
18
21
  const textareaRef = useRef(null)
22
+ const stickyRef = useRef(null)
19
23
  const [editing, setEditing] = useState(false)
20
24
 
25
+ const handleResize = useCallback((w, h) => {
26
+ onUpdate?.({ width: w, height: h })
27
+ }, [onUpdate])
28
+
21
29
  useEffect(() => {
22
30
  if (editing && textareaRef.current) {
23
31
  textareaRef.current.focus()
@@ -36,8 +44,14 @@ export default function StickyNote({ props, onUpdate }) {
36
44
  return (
37
45
  <div className={styles.container}>
38
46
  <article
47
+ ref={stickyRef}
39
48
  className={styles.sticky}
40
- style={{ '--sticky-bg': palette.bg, '--sticky-border': palette.border }}
49
+ style={{
50
+ '--sticky-bg': palette.bg,
51
+ '--sticky-border': palette.border,
52
+ ...(typeof width === 'number' ? { width: `${width}px` } : undefined),
53
+ ...(typeof height === 'number' ? { height: `${height}px` } : undefined),
54
+ }}
41
55
  >
42
56
  <p
43
57
  className={styles.text}
@@ -65,6 +79,12 @@ export default function StickyNote({ props, onUpdate }) {
65
79
  placeholder="Type here…"
66
80
  />
67
81
  )}
82
+ <ResizeHandle
83
+ targetRef={stickyRef}
84
+ minWidth={180}
85
+ minHeight={60}
86
+ onResize={handleResize}
87
+ />
68
88
  </article>
69
89
 
70
90
  {/* Color picker — dot trigger below the sticky */}
@@ -6,10 +6,10 @@
6
6
  background: var(--sticky-bg, #fff8c5);
7
7
  border-radius: 6px;
8
8
  border: 2px solid color-mix(in srgb, var(--sticky-bg) 80%, rgb(0, 0, 0) 10%);
9
+ box-sizing: border-box;
9
10
  min-width: 180px;
10
11
  box-shadow: 2px 3px 8px rgba(0, 0, 0, 0.04);
11
12
  font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
12
- resize: both;
13
13
  overflow: auto;
14
14
  position: relative;
15
15
  }
@@ -0,0 +1,96 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { render, fireEvent } from '@testing-library/react'
3
+ import { readProp, getDefaults, stickyNoteSchema } from './widgetProps.js'
4
+ import StickyNote from './StickyNote.jsx'
5
+
6
+ describe('stickyNoteSchema', () => {
7
+ it('includes width and height in the size category', () => {
8
+ expect(stickyNoteSchema.width).toEqual(
9
+ expect.objectContaining({ type: 'number', category: 'size' })
10
+ )
11
+ expect(stickyNoteSchema.height).toEqual(
12
+ expect.objectContaining({ type: 'number', category: 'size' })
13
+ )
14
+ })
15
+
16
+ it('does not include default values for width/height so new widgets size naturally', () => {
17
+ const defaults = getDefaults(stickyNoteSchema)
18
+ expect(defaults).not.toHaveProperty('width')
19
+ expect(defaults).not.toHaveProperty('height')
20
+ })
21
+
22
+ it('returns null when width/height are not saved in props', () => {
23
+ const props = { text: 'hello', color: 'yellow' }
24
+ expect(readProp(props, 'width', stickyNoteSchema)).toBeNull()
25
+ expect(readProp(props, 'height', stickyNoteSchema)).toBeNull()
26
+ })
27
+
28
+ it('returns saved width/height when present in props', () => {
29
+ const props = { text: 'hello', width: 300, height: 200 }
30
+ expect(readProp(props, 'width', stickyNoteSchema)).toBe(300)
31
+ expect(readProp(props, 'height', stickyNoteSchema)).toBe(200)
32
+ })
33
+ })
34
+
35
+ describe('StickyNote', () => {
36
+ it('renders without explicit dimensions when width/height are not saved', () => {
37
+ const { container } = render(<StickyNote props={{ text: 'Hi' }} onUpdate={vi.fn()} />)
38
+ const sticky = container.querySelector('article')
39
+ expect(sticky.style.width).toBe('')
40
+ expect(sticky.style.height).toBe('')
41
+ })
42
+
43
+ it('applies saved dimensions as inline styles', () => {
44
+ const { container } = render(
45
+ <StickyNote props={{ text: 'Hi', width: 300, height: 200 }} onUpdate={vi.fn()} />
46
+ )
47
+ const sticky = container.querySelector('article')
48
+ expect(sticky.style.width).toBe('300px')
49
+ expect(sticky.style.height).toBe('200px')
50
+ })
51
+
52
+ it('renders a resize handle', () => {
53
+ const { container } = render(<StickyNote props={{ text: 'Hi' }} onUpdate={vi.fn()} />)
54
+ const handle = container.querySelector('[role="separator"]')
55
+ expect(handle).not.toBeNull()
56
+ })
57
+
58
+ it('calls onUpdate with new dimensions on resize drag', () => {
59
+ const onUpdate = vi.fn()
60
+ const { container } = render(
61
+ <StickyNote props={{ text: 'Hi', width: 200, height: 150 }} onUpdate={onUpdate} />
62
+ )
63
+ const handle = container.querySelector('[role="separator"]')
64
+ const sticky = container.querySelector('article')
65
+
66
+ // Mock offsetWidth/offsetHeight since jsdom doesn't compute layout
67
+ Object.defineProperty(sticky, 'offsetWidth', { value: 200, configurable: true })
68
+ Object.defineProperty(sticky, 'offsetHeight', { value: 150, configurable: true })
69
+
70
+ // Simulate drag: mousedown → mousemove → mouseup
71
+ fireEvent.mouseDown(handle, { clientX: 200, clientY: 150 })
72
+ fireEvent.mouseMove(document, { clientX: 250, clientY: 200 })
73
+ fireEvent.mouseUp(document)
74
+
75
+ expect(onUpdate).toHaveBeenCalledWith({ width: 250, height: 200 })
76
+ })
77
+
78
+ it('enforces minimum dimensions during resize', () => {
79
+ const onUpdate = vi.fn()
80
+ const { container } = render(
81
+ <StickyNote props={{ text: 'Hi', width: 200, height: 150 }} onUpdate={onUpdate} />
82
+ )
83
+ const handle = container.querySelector('[role="separator"]')
84
+ const sticky = container.querySelector('article')
85
+
86
+ Object.defineProperty(sticky, 'offsetWidth', { value: 200, configurable: true })
87
+ Object.defineProperty(sticky, 'offsetHeight', { value: 150, configurable: true })
88
+
89
+ // Drag far to the left/up — should clamp to mins
90
+ fireEvent.mouseDown(handle, { clientX: 200, clientY: 150 })
91
+ fireEvent.mouseMove(document, { clientX: 0, clientY: 0 })
92
+ fireEvent.mouseUp(document)
93
+
94
+ expect(onUpdate).toHaveBeenCalledWith({ width: 180, height: 60 })
95
+ })
96
+ })
@@ -120,6 +120,8 @@ export const stickyNoteSchema = {
120
120
  text: { type: 'text', label: 'Text', category: 'content', defaultValue: '' },
121
121
  color: { type: 'select', label: 'Color', category: 'settings', defaultValue: 'yellow',
122
122
  options: ['yellow', 'blue', 'green', 'pink', 'purple', 'orange'] },
123
+ width: { type: 'number', label: 'Width', category: 'size', min: 180 },
124
+ height: { type: 'number', label: 'Height', category: 'size', min: 60 },
123
125
  }
124
126
 
125
127
  export const markdownSchema = {
@@ -397,6 +397,14 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
397
397
  }
398
398
  }
399
399
 
400
+ // Auto-fill gitAuthor for canvas metadata from git history
401
+ if (suffix === 'canvas' && parsed && !parsed.gitAuthor) {
402
+ const gitAuthor = getGitAuthor(root, absPath)
403
+ if (gitAuthor) {
404
+ parsed = { ...parsed, gitAuthor }
405
+ }
406
+ }
407
+
400
408
  // Inject inferred route and resolve JSX companion for canvases
401
409
  if (suffix === 'canvas') {
402
410
  if (canvasRoutes[name]) {