@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.
@@ -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={{ '--sticky-bg': palette.bg, '--sticky-border': palette.border }}
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
- </article>
69
-
70
- {/* Color picker — dot trigger below the sticky */}
71
- <div
72
- className={styles.pickerArea}
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
- <div className={styles.pickerPopup}>
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
+ }