@dfosco/storyboard-react 3.9.1 → 3.10.0-beta.1
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/CanvasControls.jsx +2 -5
- package/src/canvas/CanvasPage.jsx +139 -20
- package/src/canvas/CanvasPage.module.css +1 -5
- package/src/canvas/CanvasToolbar.jsx +2 -5
- package/src/canvas/widgets/ComponentWidget.jsx +51 -3
- package/src/canvas/widgets/ComponentWidget.module.css +18 -0
- package/src/canvas/widgets/MarkdownBlock.module.css +1 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +36 -46
- package/src/canvas/widgets/ResizeHandle.jsx +56 -0
- package/src/canvas/widgets/ResizeHandle.module.css +29 -0
- package/src/canvas/widgets/StickyNote.jsx +21 -32
- package/src/canvas/widgets/StickyNote.module.css +1 -71
- package/src/canvas/widgets/StickyNote.test.jsx +96 -0
- package/src/canvas/widgets/WidgetChrome.jsx +244 -0
- package/src/canvas/widgets/WidgetChrome.module.css +209 -0
- package/src/canvas/widgets/widgetConfig.js +79 -0
- package/src/canvas/widgets/widgetProps.js +13 -35
- package/src/vite/data-plugin.js +8 -0
|
@@ -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()
|
|
@@ -29,15 +37,17 @@ export default function StickyNote({ props, onUpdate }) {
|
|
|
29
37
|
onUpdate?.({ text: e.target.value })
|
|
30
38
|
}, [onUpdate])
|
|
31
39
|
|
|
32
|
-
const handleColorChange = useCallback((newColor) => {
|
|
33
|
-
onUpdate?.({ color: newColor })
|
|
34
|
-
}, [onUpdate])
|
|
35
|
-
|
|
36
40
|
return (
|
|
37
41
|
<div className={styles.container}>
|
|
38
42
|
<article
|
|
43
|
+
ref={stickyRef}
|
|
39
44
|
className={styles.sticky}
|
|
40
|
-
style={{
|
|
45
|
+
style={{
|
|
46
|
+
'--sticky-bg': palette.bg,
|
|
47
|
+
'--sticky-border': palette.border,
|
|
48
|
+
...(typeof width === 'number' ? { width: `${width}px` } : undefined),
|
|
49
|
+
...(typeof height === 'number' ? { height: `${height}px` } : undefined),
|
|
50
|
+
}}
|
|
41
51
|
>
|
|
42
52
|
<p
|
|
43
53
|
className={styles.text}
|
|
@@ -65,34 +75,13 @@ export default function StickyNote({ props, onUpdate }) {
|
|
|
65
75
|
placeholder="Type here…"
|
|
66
76
|
/>
|
|
67
77
|
)}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
onMouseDown={(e) => e.stopPropagation()}
|
|
74
|
-
onPointerDown={(e) => e.stopPropagation()}
|
|
75
|
-
>
|
|
76
|
-
<span
|
|
77
|
-
className={styles.pickerDot}
|
|
78
|
-
style={{ background: palette.dot }}
|
|
78
|
+
<ResizeHandle
|
|
79
|
+
targetRef={stickyRef}
|
|
80
|
+
minWidth={180}
|
|
81
|
+
minHeight={60}
|
|
82
|
+
onResize={handleResize}
|
|
79
83
|
/>
|
|
80
|
-
|
|
81
|
-
{Object.entries(COLORS).map(([colorName, c]) => (
|
|
82
|
-
<button
|
|
83
|
-
key={colorName}
|
|
84
|
-
className={`${styles.colorDot} ${colorName === color ? styles.active : ''}`}
|
|
85
|
-
style={{ background: c.bg, borderColor: c.border }}
|
|
86
|
-
onClick={(e) => {
|
|
87
|
-
e.stopPropagation()
|
|
88
|
-
handleColorChange(colorName)
|
|
89
|
-
}}
|
|
90
|
-
title={colorName}
|
|
91
|
-
aria-label={`Set color to ${colorName}`}
|
|
92
|
-
/>
|
|
93
|
-
))}
|
|
94
|
-
</div>
|
|
95
|
-
</div>
|
|
84
|
+
</article>
|
|
96
85
|
</div>
|
|
97
86
|
)
|
|
98
87
|
}
|
|
@@ -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
|
}
|
|
@@ -60,73 +60,3 @@
|
|
|
60
60
|
:global([data-sb-canvas-theme^='dark']) .textarea {
|
|
61
61
|
color: color-mix(in srgb, var(--sticky-bg) 26%, #f0f6fc 74%);
|
|
62
62
|
}
|
|
63
|
-
|
|
64
|
-
/* Color picker area — sits below the sticky */
|
|
65
|
-
|
|
66
|
-
.pickerArea {
|
|
67
|
-
display: flex;
|
|
68
|
-
justify-content: center;
|
|
69
|
-
padding-top: 6px;
|
|
70
|
-
position: relative;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
.pickerDot {
|
|
74
|
-
width: 8px;
|
|
75
|
-
height: 8px;
|
|
76
|
-
border-radius: 50%;
|
|
77
|
-
opacity: 0.5;
|
|
78
|
-
transition: opacity 150ms;
|
|
79
|
-
cursor: pointer;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
.pickerPopup {
|
|
83
|
-
position: absolute;
|
|
84
|
-
top: 4px;
|
|
85
|
-
display: flex;
|
|
86
|
-
gap: 5px;
|
|
87
|
-
padding: 6px 10px;
|
|
88
|
-
background: var(--bgColor-default, #ffffff);
|
|
89
|
-
border-radius: 20px;
|
|
90
|
-
box-shadow:
|
|
91
|
-
0 0 0 1px rgba(0, 0, 0, 0.08),
|
|
92
|
-
0 4px 12px rgba(0, 0, 0, 0.12);
|
|
93
|
-
opacity: 0;
|
|
94
|
-
pointer-events: none;
|
|
95
|
-
transition: opacity 150ms;
|
|
96
|
-
z-index: 10;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
:global([data-sb-canvas-theme^='dark']) .pickerPopup {
|
|
100
|
-
background: var(--bgColor-muted, #161b22);
|
|
101
|
-
box-shadow:
|
|
102
|
-
0 0 0 1px rgba(255, 255, 255, 0.08),
|
|
103
|
-
0 4px 12px rgba(0, 0, 0, 0.45);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
.pickerArea:hover .pickerDot {
|
|
107
|
-
opacity: 0;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
.pickerArea:hover .pickerPopup {
|
|
111
|
-
opacity: 1;
|
|
112
|
-
pointer-events: auto;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
.colorDot {
|
|
116
|
-
all: unset;
|
|
117
|
-
width: 20px;
|
|
118
|
-
height: 20px;
|
|
119
|
-
border-radius: 50%;
|
|
120
|
-
border: 2px solid transparent;
|
|
121
|
-
cursor: pointer;
|
|
122
|
-
transition: transform 100ms;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
.colorDot:hover {
|
|
126
|
-
transform: scale(1.15);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
.colorDot.active {
|
|
130
|
-
border-color: var(--sticky-border);
|
|
131
|
-
box-shadow: 0 0 0 1px var(--sticky-border);
|
|
132
|
-
}
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from 'react'
|
|
2
|
+
import styles from './WidgetChrome.module.css'
|
|
3
|
+
|
|
4
|
+
const STICKY_NOTE_COLORS = {
|
|
5
|
+
yellow: { bg: '#fff8c5', border: '#d4a72c', dot: '#e8c846' },
|
|
6
|
+
blue: { bg: '#ddf4ff', border: '#54aeff', dot: '#74b9ff' },
|
|
7
|
+
green: { bg: '#dafbe1', border: '#4ac26b', dot: '#6dd58c' },
|
|
8
|
+
pink: { bg: '#ffebe9', border: '#ff8182', dot: '#ff9a9e' },
|
|
9
|
+
purple: { bg: '#fbefff', border: '#c297ff', dot: '#d4a8ff' },
|
|
10
|
+
orange: { bg: '#fff1e5', border: '#d18616', dot: '#e8a844' },
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function DeleteIcon() {
|
|
14
|
+
return (
|
|
15
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
16
|
+
<path d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.15l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z" />
|
|
17
|
+
</svg>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ZoomInIcon() {
|
|
22
|
+
return (
|
|
23
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
24
|
+
<path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z" />
|
|
25
|
+
</svg>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ZoomOutIcon() {
|
|
30
|
+
return (
|
|
31
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
32
|
+
<path d="M2.75 7.25h10.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5Z" />
|
|
33
|
+
</svg>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function EditIcon() {
|
|
38
|
+
return (
|
|
39
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
40
|
+
<path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z" />
|
|
41
|
+
</svg>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ACTION_ICONS = {
|
|
46
|
+
'delete': DeleteIcon,
|
|
47
|
+
'zoom-in': ZoomInIcon,
|
|
48
|
+
'zoom-out': ZoomOutIcon,
|
|
49
|
+
'edit': EditIcon,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ACTION_LABELS = {
|
|
53
|
+
'delete': 'Delete widget',
|
|
54
|
+
'zoom-in': 'Zoom in',
|
|
55
|
+
'zoom-out': 'Zoom out',
|
|
56
|
+
'edit': 'Edit',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* ColorPicker feature button — shows a dot that reveals color options on hover.
|
|
61
|
+
*/
|
|
62
|
+
function ColorPickerFeature({ currentColor, options, onColorChange }) {
|
|
63
|
+
const palette = STICKY_NOTE_COLORS[currentColor] ?? STICKY_NOTE_COLORS.yellow
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
className={styles.colorPickerWrapper}
|
|
68
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
69
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
70
|
+
>
|
|
71
|
+
<button
|
|
72
|
+
className={styles.featureBtn}
|
|
73
|
+
style={{ background: palette.dot }}
|
|
74
|
+
aria-label="Change color"
|
|
75
|
+
title="Change color"
|
|
76
|
+
>
|
|
77
|
+
<span className={styles.colorDotInner} style={{ background: palette.dot }} />
|
|
78
|
+
</button>
|
|
79
|
+
<div className={styles.colorPopup}>
|
|
80
|
+
{(options || Object.keys(STICKY_NOTE_COLORS)).map((colorName) => {
|
|
81
|
+
const c = STICKY_NOTE_COLORS[colorName]
|
|
82
|
+
if (!c) return null
|
|
83
|
+
return (
|
|
84
|
+
<button
|
|
85
|
+
key={colorName}
|
|
86
|
+
className={`${styles.colorOption} ${colorName === currentColor ? styles.colorOptionActive : ''}`}
|
|
87
|
+
style={{ background: c.bg, borderColor: c.border }}
|
|
88
|
+
onClick={(e) => {
|
|
89
|
+
e.stopPropagation()
|
|
90
|
+
onColorChange(colorName)
|
|
91
|
+
}}
|
|
92
|
+
title={colorName}
|
|
93
|
+
aria-label={`Set color to ${colorName}`}
|
|
94
|
+
/>
|
|
95
|
+
)
|
|
96
|
+
})}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* WidgetChrome — universal hover toolbar rendered below every canvas widget.
|
|
104
|
+
*
|
|
105
|
+
* Provides:
|
|
106
|
+
* - A trigger dot (visible at rest) that transitions to a toolbar on hover
|
|
107
|
+
* - Feature buttons (left) driven by widget config
|
|
108
|
+
* - A select handle (right) for selection toggling
|
|
109
|
+
*
|
|
110
|
+
* Widget components can expose imperative action handlers via a ref:
|
|
111
|
+
* useImperativeHandle(ref, () => ({ handleAction(actionId) { ... } }))
|
|
112
|
+
* WidgetChrome will call widgetRef.current.handleAction(actionId) for
|
|
113
|
+
* non-standard actions (anything other than 'delete').
|
|
114
|
+
*/
|
|
115
|
+
export default function WidgetChrome({
|
|
116
|
+
features = [],
|
|
117
|
+
selected = false,
|
|
118
|
+
widgetProps,
|
|
119
|
+
widgetRef,
|
|
120
|
+
onSelect,
|
|
121
|
+
onDeselect,
|
|
122
|
+
onAction,
|
|
123
|
+
onUpdate,
|
|
124
|
+
children,
|
|
125
|
+
}) {
|
|
126
|
+
const [hovered, setHovered] = useState(false)
|
|
127
|
+
const leaveTimer = useRef(null)
|
|
128
|
+
const pointerStartPos = useRef(null)
|
|
129
|
+
|
|
130
|
+
const handleMouseEnter = useCallback(() => {
|
|
131
|
+
clearTimeout(leaveTimer.current)
|
|
132
|
+
setHovered(true)
|
|
133
|
+
}, [])
|
|
134
|
+
|
|
135
|
+
const handleMouseLeave = useCallback(() => {
|
|
136
|
+
leaveTimer.current = setTimeout(() => setHovered(false), 80)
|
|
137
|
+
}, [])
|
|
138
|
+
|
|
139
|
+
// Track pointer position on the handle to distinguish click from drag.
|
|
140
|
+
const handleHandlePointerDown = useCallback((e) => {
|
|
141
|
+
pointerStartPos.current = { x: e.clientX, y: e.clientY }
|
|
142
|
+
}, [])
|
|
143
|
+
|
|
144
|
+
const handleHandlePointerUp = useCallback((e) => {
|
|
145
|
+
if (!pointerStartPos.current) return
|
|
146
|
+
const start = pointerStartPos.current
|
|
147
|
+
pointerStartPos.current = null
|
|
148
|
+
// Only toggle selection if the pointer stayed close (click, not drag)
|
|
149
|
+
const dist = Math.hypot(e.clientX - start.x, e.clientY - start.y)
|
|
150
|
+
if (dist > 10) return
|
|
151
|
+
e.stopPropagation()
|
|
152
|
+
if (selected) {
|
|
153
|
+
onDeselect?.()
|
|
154
|
+
} else {
|
|
155
|
+
onSelect?.()
|
|
156
|
+
}
|
|
157
|
+
}, [selected, onSelect, onDeselect])
|
|
158
|
+
|
|
159
|
+
const handleActionClick = useCallback((actionId, e) => {
|
|
160
|
+
e.stopPropagation()
|
|
161
|
+
// Standard actions go through onAction (handled by CanvasPage)
|
|
162
|
+
if (actionId === 'delete') {
|
|
163
|
+
onAction?.(actionId)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
// Widget-specific actions go through the widget's imperative ref
|
|
167
|
+
if (widgetRef?.current?.handleAction) {
|
|
168
|
+
widgetRef.current.handleAction(actionId)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
// Fallback to generic handler
|
|
172
|
+
onAction?.(actionId)
|
|
173
|
+
}, [onAction, widgetRef])
|
|
174
|
+
|
|
175
|
+
const handleColorChange = useCallback((color) => {
|
|
176
|
+
onUpdate?.({ color })
|
|
177
|
+
}, [onUpdate])
|
|
178
|
+
|
|
179
|
+
const showToolbar = hovered || selected
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div
|
|
183
|
+
className={styles.chromeContainer}
|
|
184
|
+
onMouseEnter={handleMouseEnter}
|
|
185
|
+
onMouseLeave={handleMouseLeave}
|
|
186
|
+
>
|
|
187
|
+
<div className={`${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''}`}>
|
|
188
|
+
{children}
|
|
189
|
+
</div>
|
|
190
|
+
<div
|
|
191
|
+
className={styles.toolbar}
|
|
192
|
+
>
|
|
193
|
+
{/* Trigger dot — visible at rest */}
|
|
194
|
+
<span
|
|
195
|
+
className={`${styles.triggerDot} ${showToolbar ? styles.triggerDotHidden : ''}`}
|
|
196
|
+
/>
|
|
197
|
+
|
|
198
|
+
{/* Toolbar content — visible on hover */}
|
|
199
|
+
<div className={`${styles.toolbarContent} ${showToolbar ? styles.toolbarContentVisible : ''}`}>
|
|
200
|
+
<div className={styles.featureButtons}>
|
|
201
|
+
{features.map((feature) => {
|
|
202
|
+
if (feature.type === 'color-picker') {
|
|
203
|
+
return (
|
|
204
|
+
<ColorPickerFeature
|
|
205
|
+
key={feature.id}
|
|
206
|
+
currentColor={widgetProps?.[feature.prop] || 'yellow'}
|
|
207
|
+
options={feature.options}
|
|
208
|
+
onColorChange={handleColorChange}
|
|
209
|
+
/>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (feature.type === 'action') {
|
|
214
|
+
const Icon = ACTION_ICONS[feature.action]
|
|
215
|
+
return (
|
|
216
|
+
<button
|
|
217
|
+
key={feature.id}
|
|
218
|
+
className={styles.featureBtn}
|
|
219
|
+
onClick={(e) => handleActionClick(feature.action, e)}
|
|
220
|
+
title={ACTION_LABELS[feature.action] || feature.action}
|
|
221
|
+
aria-label={ACTION_LABELS[feature.action] || feature.action}
|
|
222
|
+
>
|
|
223
|
+
{Icon ? <Icon /> : feature.action}
|
|
224
|
+
</button>
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return null
|
|
229
|
+
})}
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<button
|
|
233
|
+
className={`tc-drag-handle ${styles.selectHandle} ${selected ? styles.selectHandleActive : ''}`}
|
|
234
|
+
onPointerDown={handleHandlePointerDown}
|
|
235
|
+
onPointerUp={handleHandlePointerUp}
|
|
236
|
+
title={selected ? 'Deselect' : 'Select'}
|
|
237
|
+
aria-label={selected ? 'Deselect widget' : 'Select widget'}
|
|
238
|
+
aria-pressed={selected}
|
|
239
|
+
/>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
)
|
|
244
|
+
}
|