@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 +3 -3
- package/src/canvas/widgets/PrototypeEmbed.jsx +16 -4
- package/src/canvas/widgets/ResizeHandle.jsx +56 -0
- package/src/canvas/widgets/ResizeHandle.module.css +29 -0
- package/src/canvas/widgets/StickyNote.jsx +21 -1
- package/src/canvas/widgets/StickyNote.module.css +1 -1
- package/src/canvas/widgets/StickyNote.test.jsx +96 -0
- package/src/canvas/widgets/widgetProps.js +2 -0
- package/src/vite/data-plugin.js +8 -0
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.0-beta.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "3.
|
|
7
|
-
"@dfosco/tiny-canvas": "
|
|
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
|
|
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 =
|
|
52
|
-
|
|
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={{
|
|
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 = {
|
package/src/vite/data-plugin.js
CHANGED
|
@@ -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]) {
|