@dfosco/storyboard-react 3.11.1-beta.0 → 3.11.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.
Files changed (35) hide show
  1. package/package.json +3 -3
  2. package/src/Viewfinder.jsx +5 -3
  3. package/src/__mocks__/virtual-storyboard-data-index.js +1 -0
  4. package/src/canvas/CanvasControls.jsx +2 -59
  5. package/src/canvas/CanvasControls.module.css +0 -29
  6. package/src/canvas/CanvasPage.bridge.test.jsx +68 -42
  7. package/src/canvas/CanvasPage.jsx +791 -68
  8. package/src/canvas/CanvasPage.module.css +47 -2
  9. package/src/canvas/CanvasPage.multiselect.test.jsx +345 -0
  10. package/src/canvas/canvasApi.js +8 -0
  11. package/src/canvas/computeCanvasBounds.test.js +121 -0
  12. package/src/canvas/useCanvas.js +2 -1
  13. package/src/canvas/useUndoRedo.js +86 -0
  14. package/src/canvas/useUndoRedo.test.js +231 -0
  15. package/src/canvas/widgets/ComponentWidget.jsx +9 -7
  16. package/src/canvas/widgets/FigmaEmbed.jsx +195 -0
  17. package/src/canvas/widgets/FigmaEmbed.module.css +147 -0
  18. package/src/canvas/widgets/ImageWidget.jsx +115 -0
  19. package/src/canvas/widgets/ImageWidget.module.css +39 -0
  20. package/src/canvas/widgets/MarkdownBlock.jsx +25 -8
  21. package/src/canvas/widgets/MarkdownBlock.test.jsx +53 -0
  22. package/src/canvas/widgets/PrototypeEmbed.jsx +132 -26
  23. package/src/canvas/widgets/PrototypeEmbed.module.css +66 -2
  24. package/src/canvas/widgets/StickyNote.jsx +21 -16
  25. package/src/canvas/widgets/StickyNote.test.jsx +24 -4
  26. package/src/canvas/widgets/WidgetChrome.jsx +276 -50
  27. package/src/canvas/widgets/WidgetChrome.module.css +91 -10
  28. package/src/canvas/widgets/figmaUrl.js +118 -0
  29. package/src/canvas/widgets/figmaUrl.test.js +139 -0
  30. package/src/canvas/widgets/index.js +4 -0
  31. package/src/canvas/widgets/widgetConfig.js +74 -6
  32. package/src/canvas/widgets/widgetConfig.test.js +46 -0
  33. package/src/canvas/widgets/widgetProps.js +2 -0
  34. package/src/context.jsx +34 -4
  35. package/src/context.test.jsx +13 -0
@@ -0,0 +1,115 @@
1
+ import { useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react'
2
+ import WidgetWrapper from './WidgetWrapper.jsx'
3
+ import ResizeHandle from './ResizeHandle.jsx'
4
+ import { readProp } from './widgetProps.js'
5
+ import { schemas } from './widgetConfig.js'
6
+ import { toggleImagePrivacy } from '../canvasApi.js'
7
+ import styles from './ImageWidget.module.css'
8
+
9
+ const imageSchema = schemas['image']
10
+
11
+ function getImageUrl(src) {
12
+ if (!src) return ''
13
+ const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
14
+ return `${base}/_storyboard/canvas/images/${src}`
15
+ }
16
+
17
+ /**
18
+ * Canvas widget that displays a pasted image.
19
+ * Supports aspect-ratio locked resize and privacy toggle.
20
+ */
21
+ const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate, resizable }, ref) {
22
+ const containerRef = useRef(null)
23
+ const [naturalRatio, setNaturalRatio] = useState(null)
24
+
25
+ const src = readProp(props, 'src', imageSchema)
26
+ const isPrivate = readProp(props, 'private', imageSchema)
27
+ const width = readProp(props, 'width', imageSchema)
28
+ const height = readProp(props, 'height', imageSchema)
29
+
30
+ const handleImageLoad = useCallback((e) => {
31
+ const img = e.target
32
+ if (img.naturalWidth && img.naturalHeight) {
33
+ setNaturalRatio(img.naturalWidth / img.naturalHeight)
34
+ }
35
+ }, [])
36
+
37
+ const handleResize = useCallback((newWidth) => {
38
+ const ratio = naturalRatio || (width && height ? width / height : 4 / 3)
39
+ const newHeight = Math.round(newWidth / ratio)
40
+ onUpdate?.({ width: newWidth, height: newHeight })
41
+ }, [naturalRatio, width, height, onUpdate])
42
+
43
+ useImperativeHandle(ref, () => ({
44
+ handleAction(actionId) {
45
+ if (actionId === 'toggle-private') {
46
+ if (!src) return
47
+ toggleImagePrivacy(src).then((result) => {
48
+ if (result.success) {
49
+ onUpdate?.({ src: result.filename, private: result.private })
50
+ }
51
+ }).catch((err) => {
52
+ console.error('[canvas] Failed to toggle image privacy:', err)
53
+ })
54
+ } else if (actionId === 'download-image') {
55
+ if (!src) return
56
+ const url = getImageUrl(src)
57
+ const a = document.createElement('a')
58
+ a.href = url
59
+ a.download = src.replace(/^_/, '')
60
+ document.body.appendChild(a)
61
+ a.click()
62
+ document.body.removeChild(a)
63
+ } else if (actionId === 'copy-as-png') {
64
+ if (!src) return
65
+ const url = getImageUrl(src)
66
+ fetch(url)
67
+ .then((r) => r.blob())
68
+ .then((blob) => {
69
+ const pngBlob = blob.type === 'image/png' ? blob : blob
70
+ navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]).catch(() => {})
71
+ })
72
+ .catch((err) => console.error('[canvas] Failed to copy image:', err))
73
+ } else if (actionId === 'copy-file-path') {
74
+ if (!src) return
75
+ navigator.clipboard.writeText(`src/canvas/images/${src}`).catch(() => {})
76
+ }
77
+ }
78
+ }), [src, onUpdate])
79
+
80
+ if (!src) return null
81
+
82
+ const sizeStyle = {}
83
+ if (typeof width === 'number') sizeStyle.width = `${width}px`
84
+
85
+ return (
86
+ <WidgetWrapper className={styles.imageWrapper}>
87
+ <div ref={containerRef} className={styles.container} style={sizeStyle}>
88
+ <div className={styles.frame}>
89
+ <img
90
+ src={getImageUrl(src)}
91
+ alt=""
92
+ className={styles.image}
93
+ onLoad={handleImageLoad}
94
+ draggable={false}
95
+ />
96
+ {isPrivate && (
97
+ <span className={styles.privateBadge} title="Private — not committed to git">
98
+ Private
99
+ </span>
100
+ )}
101
+ </div>
102
+ {resizable && (
103
+ <ResizeHandle
104
+ targetRef={containerRef}
105
+ minWidth={100}
106
+ minHeight={60}
107
+ onResize={(w) => handleResize(w)}
108
+ />
109
+ )}
110
+ </div>
111
+ </WidgetWrapper>
112
+ )
113
+ })
114
+
115
+ export default ImageWidget
@@ -0,0 +1,39 @@
1
+ .imageWrapper {
2
+ min-width: unset;
3
+ }
4
+
5
+ .container {
6
+ position: relative;
7
+ overflow: hidden;
8
+ min-width: 100px;
9
+ }
10
+
11
+ .frame {
12
+ position: relative;
13
+ width: 100%;
14
+ box-sizing: border-box;
15
+ }
16
+
17
+ .image {
18
+ display: block;
19
+ width: 100%;
20
+ height: auto;
21
+ border-radius: 4px;
22
+ user-select: none;
23
+ pointer-events: none;
24
+ }
25
+
26
+ .privateBadge {
27
+ position: absolute;
28
+ top: 20px;
29
+ right: 20px;
30
+ padding: 2px 6px;
31
+ border-radius: 4px;
32
+ font-size: 10px;
33
+ font-weight: 600;
34
+ line-height: 1.4;
35
+ letter-spacing: 0.02em;
36
+ color: var(--fgColor-onEmphasis, #fff);
37
+ background: var(--bgColor-neutral-emphasis, rgba(0, 0, 0, 0.55));
38
+ pointer-events: none;
39
+ }
@@ -28,7 +28,9 @@ function renderMarkdown(text) {
28
28
  export default function MarkdownBlock({ props, onUpdate }) {
29
29
  const content = readProp(props, 'content', markdownSchema)
30
30
  const width = readProp(props, 'width', markdownSchema)
31
+ const canEdit = typeof onUpdate === 'function'
31
32
  const [editing, setEditing] = useState(false)
33
+ const editingActive = canEdit && editing
32
34
  const textareaRef = useRef(null)
33
35
  const blockRef = useRef(null)
34
36
  const [editHeight, setEditHeight] = useState(null)
@@ -37,8 +39,17 @@ export default function MarkdownBlock({ props, onUpdate }) {
37
39
  onUpdate?.({ content: e.target.value })
38
40
  }, [onUpdate])
39
41
 
42
+ const handleReadOnlyCopy = useCallback((e) => {
43
+ if (canEdit) return
44
+ e.preventDefault()
45
+ e.stopPropagation()
46
+ if (e.clipboardData?.setData) {
47
+ e.clipboardData.setData('text/plain', content || '')
48
+ }
49
+ }, [canEdit, content])
50
+
40
51
  useEffect(() => {
41
- if (editing) {
52
+ if (editingActive) {
42
53
  // Capture the preview height before switching to editor
43
54
  if (blockRef.current && !editHeight) {
44
55
  setEditHeight(blockRef.current.offsetHeight)
@@ -49,7 +60,7 @@ export default function MarkdownBlock({ props, onUpdate }) {
49
60
  } else {
50
61
  setEditHeight(null)
51
62
  }
52
- }, [editing, editHeight])
63
+ }, [editingActive, editHeight])
53
64
 
54
65
  return (
55
66
  <WidgetWrapper>
@@ -58,7 +69,7 @@ export default function MarkdownBlock({ props, onUpdate }) {
58
69
  className={styles.block}
59
70
  style={{ width, minHeight: editHeight || undefined }}
60
71
  >
61
- {editing ? (
72
+ {editingActive ? (
62
73
  <textarea
63
74
  ref={textareaRef}
64
75
  className={styles.editor}
@@ -77,12 +88,18 @@ export default function MarkdownBlock({ props, onUpdate }) {
77
88
  ) : (
78
89
  <div
79
90
  className={styles.preview}
80
- onDoubleClick={() => setEditing(true)}
81
- role="button"
82
- tabIndex={0}
83
- onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}
91
+ style={!canEdit ? { cursor: 'default' } : undefined}
92
+ data-canvas-allow-text-selection={!canEdit ? '' : undefined}
93
+ onClick={!canEdit ? (e) => e.stopPropagation() : undefined}
94
+ onCopy={!canEdit ? handleReadOnlyCopy : undefined}
95
+ onDoubleClick={canEdit ? () => setEditing(true) : undefined}
96
+ role={canEdit ? 'button' : undefined}
97
+ tabIndex={canEdit ? 0 : undefined}
98
+ onKeyDown={canEdit ? (e) => { if (e.key === 'Enter') setEditing(true) } : undefined}
84
99
  dangerouslySetInnerHTML={{
85
- __html: renderMarkdown(content) || '<p class="placeholder">Double-click to edit…</p>',
100
+ __html: renderMarkdown(content) || (canEdit
101
+ ? '<p class="placeholder">Double-click to edit…</p>'
102
+ : '<p class="placeholder">No content</p>'),
86
103
  }}
87
104
  />
88
105
  )}
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { fireEvent, render, screen } from '@testing-library/react'
3
+ import MarkdownBlock from './MarkdownBlock.jsx'
4
+
5
+ describe('MarkdownBlock', () => {
6
+ it('does not enter edit mode when onUpdate is unavailable (read-only/prod)', () => {
7
+ const { container } = render(<MarkdownBlock props={{ content: 'Hello', width: 420 }} />)
8
+
9
+ fireEvent.doubleClick(screen.getByText('Hello'))
10
+
11
+ expect(screen.queryByRole('textbox')).toBeNull()
12
+ expect(container.querySelector('[data-canvas-allow-text-selection]')).not.toBeNull()
13
+ })
14
+
15
+ it('enters edit mode when onUpdate is available', () => {
16
+ const onUpdate = vi.fn()
17
+ render(<MarkdownBlock props={{ content: 'Hello', width: 420 }} onUpdate={onUpdate} />)
18
+
19
+ fireEvent.doubleClick(screen.getByText('Hello'))
20
+
21
+ expect(screen.queryByRole('textbox')).not.toBeNull()
22
+ })
23
+
24
+ it('shows a non-editable empty-state message in read-only mode', () => {
25
+ render(<MarkdownBlock props={{ content: '', width: 420 }} />)
26
+
27
+ expect(screen.getByText('No content')).toBeTruthy()
28
+ expect(screen.queryByText('Double-click to edit…')).toBeNull()
29
+ })
30
+
31
+ it('stops click propagation in read-only mode', () => {
32
+ const onParentClick = vi.fn()
33
+ render(
34
+ <div onClick={onParentClick}>
35
+ <MarkdownBlock props={{ content: 'Hello', width: 420 }} />
36
+ </div>
37
+ )
38
+
39
+ fireEvent.click(screen.getByText('Hello'))
40
+
41
+ expect(onParentClick).not.toHaveBeenCalled()
42
+ })
43
+
44
+ it('copies markdown source in read-only mode', () => {
45
+ render(<MarkdownBlock props={{ content: '**Hello**\n- item', width: 420 }} />)
46
+
47
+ const preview = screen.getByText('Hello').closest('[data-canvas-allow-text-selection]')
48
+ const setData = vi.fn()
49
+ fireEvent.copy(preview, { clipboardData: { setData } })
50
+
51
+ expect(setData).toHaveBeenCalledWith('text/plain', '**Hello**\n- item')
52
+ })
53
+ })
@@ -1,4 +1,5 @@
1
1
  import { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'
2
+ import { createPortal } from 'react-dom'
2
3
  import { buildPrototypeIndex } from '@dfosco/storyboard-core'
3
4
  import WidgetWrapper from './WidgetWrapper.jsx'
4
5
  import { readProp, prototypeEmbedSchema } from './widgetProps.js'
@@ -28,7 +29,7 @@ function resolveCanvasThemeFromStorage() {
28
29
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
29
30
  }
30
31
 
31
- export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
32
+ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }, ref) {
32
33
  const src = readProp(props, 'src', prototypeEmbedSchema)
33
34
  const width = readProp(props, 'width', prototypeEmbedSchema)
34
35
  const height = readProp(props, 'height', prototypeEmbedSchema)
@@ -51,11 +52,15 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
51
52
 
52
53
  const [editing, setEditing] = useState(false)
53
54
  const [interactive, setInteractive] = useState(false)
55
+ const [expanded, setExpanded] = useState(false)
54
56
  const [filter, setFilter] = useState('')
55
57
  const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
56
58
  const inputRef = useRef(null)
57
59
  const filterRef = useRef(null)
58
60
  const embedRef = useRef(null)
61
+ const iframeRef = useRef(null)
62
+ const inlineContainerRef = useRef(null)
63
+ const modalContainerRef = useRef(null)
59
64
 
60
65
  const iframeSrc = useMemo(() => {
61
66
  if (!rawSrc) return ''
@@ -177,6 +182,69 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
177
182
  return () => document.removeEventListener('storyboard:theme:changed', readToolbarTheme)
178
183
  }, [])
179
184
 
185
+ // Close expanded modal on Escape
186
+ useEffect(() => {
187
+ if (!expanded) return
188
+ function handleKeyDown(e) {
189
+ if (e.key === 'Escape') {
190
+ e.stopPropagation()
191
+ setExpanded(false)
192
+ }
193
+ }
194
+ document.addEventListener('keydown', handleKeyDown, true)
195
+ return () => document.removeEventListener('keydown', handleKeyDown, true)
196
+ }, [expanded])
197
+
198
+ // Reparent iframe DOM node between inline container and modal.
199
+ // Uses moveBefore() (Chrome 133+) which preserves the iframe's
200
+ // browsing context — no reload. Falls back to appendChild which
201
+ // will reload but still works functionally.
202
+ useEffect(() => {
203
+ const iframe = iframeRef.current
204
+ if (!iframe) return
205
+
206
+ if (expanded && modalContainerRef.current) {
207
+ iframe._savedClassName = iframe.className
208
+ iframe._savedStyle = iframe.getAttribute('style') || ''
209
+ iframe.className = styles.expandIframe
210
+ iframe.removeAttribute('style')
211
+ const target = modalContainerRef.current
212
+ if (target.moveBefore) {
213
+ target.moveBefore(iframe, target.firstChild)
214
+ } else {
215
+ target.prepend(iframe)
216
+ }
217
+ } else if (!expanded && inlineContainerRef.current) {
218
+ if (iframe._savedClassName !== undefined) {
219
+ iframe.className = iframe._savedClassName
220
+ iframe.setAttribute('style', iframe._savedStyle)
221
+ delete iframe._savedClassName
222
+ delete iframe._savedStyle
223
+ }
224
+ const target = inlineContainerRef.current
225
+ if (target.moveBefore) {
226
+ target.moveBefore(iframe, null)
227
+ } else {
228
+ target.appendChild(iframe)
229
+ }
230
+ }
231
+ }, [expanded])
232
+
233
+ // Listen for navigation events from the embedded prototype iframe
234
+ useEffect(() => {
235
+ function handleMessage(e) {
236
+ if (e.source !== iframeRef.current?.contentWindow) return
237
+ if (e.data?.type !== 'storyboard:embed:navigate') return
238
+ const newSrc = e.data.src
239
+ if (newSrc && newSrc !== src) {
240
+ const originalSrc = readProp(props, 'originalSrc', prototypeEmbedSchema)
241
+ onUpdate?.({ src: newSrc, originalSrc: originalSrc || src })
242
+ }
243
+ }
244
+ window.addEventListener('message', handleMessage)
245
+ return () => window.removeEventListener('message', handleMessage)
246
+ }, [src, props, onUpdate])
247
+
180
248
  const chromeVars = useMemo(() => getEmbedChromeVars(canvasTheme), [canvasTheme])
181
249
 
182
250
  const enterInteractive = useCallback(() => setInteractive(true), [])
@@ -186,6 +254,10 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
186
254
  handleAction(actionId) {
187
255
  if (actionId === 'edit') {
188
256
  setEditing(true)
257
+ } else if (actionId === 'expand') {
258
+ setExpanded(true)
259
+ } else if (actionId === 'open-external') {
260
+ if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
189
261
  } else if (actionId === 'zoom-in') {
190
262
  const step = zoom < 75 ? 5 : 25
191
263
  onUpdate?.({ zoom: Math.min(200, zoom + step) })
@@ -194,7 +266,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
194
266
  onUpdate?.({ zoom: Math.max(25, zoom - step) })
195
267
  }
196
268
  },
197
- }), [zoom, onUpdate])
269
+ }), [rawSrc, zoom, onUpdate])
198
270
 
199
271
  function handlePickRoute(route) {
200
272
  onUpdate?.({ src: route })
@@ -216,6 +288,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
216
288
  }
217
289
 
218
290
  return (
291
+ <>
219
292
  <WidgetWrapper>
220
293
  <div
221
294
  ref={embedRef}
@@ -305,8 +378,13 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
305
378
  </div>
306
379
  ) : iframeSrc ? (
307
380
  <>
308
- <div className={styles.iframeContainer}>
381
+ <div
382
+ ref={inlineContainerRef}
383
+ className={styles.iframeContainer}
384
+ style={expanded ? { visibility: 'hidden' } : undefined}
385
+ >
309
386
  <iframe
387
+ ref={iframeRef}
310
388
  src={iframeSrc}
311
389
  className={styles.iframe}
312
390
  style={{
@@ -319,7 +397,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
319
397
  sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
320
398
  />
321
399
  </div>
322
- {!interactive && (
400
+ {!interactive && !expanded && (
323
401
  <div
324
402
  className={styles.dragOverlay}
325
403
  onDoubleClick={enterInteractive}
@@ -338,29 +416,57 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
338
416
  </div>
339
417
  )}
340
418
  </div>
419
+ {resizable && (
420
+ <div
421
+ className={styles.resizeHandle}
422
+ onMouseDown={(e) => {
423
+ e.stopPropagation()
424
+ e.preventDefault()
425
+ const startX = e.clientX
426
+ const startY = e.clientY
427
+ const startW = width
428
+ const startH = height
429
+ function onMove(ev) {
430
+ const newW = Math.max(200, startW + ev.clientX - startX)
431
+ const newH = Math.max(150, startH + ev.clientY - startY)
432
+ onUpdate?.({ width: newW, height: newH })
433
+ }
434
+ function onUp() {
435
+ document.removeEventListener('mousemove', onMove)
436
+ document.removeEventListener('mouseup', onUp)
437
+ }
438
+ document.addEventListener('mousemove', onMove)
439
+ document.addEventListener('mouseup', onUp)
440
+ }}
441
+ onPointerDown={(e) => e.stopPropagation()}
442
+ />
443
+ )}
444
+ </WidgetWrapper>
445
+ {createPortal(
341
446
  <div
342
- className={styles.resizeHandle}
343
- onMouseDown={(e) => {
344
- e.stopPropagation()
345
- e.preventDefault()
346
- const startX = e.clientX
347
- const startY = e.clientY
348
- const startW = width
349
- const startH = height
350
- function onMove(ev) {
351
- const newW = Math.max(200, startW + ev.clientX - startX)
352
- const newH = Math.max(150, startH + ev.clientY - startY)
353
- onUpdate?.({ width: newW, height: newH })
354
- }
355
- function onUp() {
356
- document.removeEventListener('mousemove', onMove)
357
- document.removeEventListener('mouseup', onUp)
358
- }
359
- document.addEventListener('mousemove', onMove)
360
- document.addEventListener('mouseup', onUp)
361
- }}
447
+ className={styles.expandBackdrop}
448
+ style={expanded ? undefined : { display: 'none' }}
449
+ onClick={() => setExpanded(false)}
362
450
  onPointerDown={(e) => e.stopPropagation()}
363
- />
364
- </WidgetWrapper>
451
+ onKeyDown={(e) => e.stopPropagation()}
452
+ onWheel={(e) => e.stopPropagation()}
453
+ >
454
+ <div
455
+ ref={modalContainerRef}
456
+ className={styles.expandContainer}
457
+ onClick={(e) => e.stopPropagation()}
458
+ >
459
+ {/* iframe is reparented here via useEffect */}
460
+ <button
461
+ className={styles.expandClose}
462
+ onClick={() => setExpanded(false)}
463
+ aria-label="Close expanded view"
464
+ autoFocus
465
+ >✕</button>
466
+ </div>
467
+ </div>,
468
+ document.body
469
+ )}
470
+ </>
365
471
  )
366
472
  })
@@ -3,7 +3,7 @@
3
3
  overflow: hidden;
4
4
  background: var(--bgColor-default, #ffffff);
5
5
  border: 3px solid var(--borderColor-default, #d0d7de);
6
- border-radius: 8px;
6
+ border-radius: 12px;
7
7
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
8
8
  }
9
9
 
@@ -150,7 +150,7 @@
150
150
  }
151
151
 
152
152
  .pickerItem:focus-visible {
153
- outline: 2px solid var(--bgColor-accent-emphasis, #2f81f7);
153
+ outline: 4px solid var(--bgColor-accent-emphasis, #2f81f7);
154
154
  outline-offset: -2px;
155
155
  }
156
156
 
@@ -326,3 +326,67 @@
326
326
  border-right: 1.5px solid var(--trigger-border, var(--borderColor-muted, #d0d7de));
327
327
  user-select: none;
328
328
  }
329
+
330
+ /* Expand modal — fullscreen overlay for expanded iframe */
331
+ .expandBackdrop {
332
+ position: fixed;
333
+ inset: 0;
334
+ z-index: 100000;
335
+ background: rgba(0, 0, 0, 0.8);
336
+ display: flex;
337
+ align-items: center;
338
+ justify-content: center;
339
+ animation: expandFadeIn 0.15s ease;
340
+ }
341
+
342
+ @keyframes expandFadeIn {
343
+ from { opacity: 0; }
344
+ to { opacity: 1; }
345
+ }
346
+
347
+ .expandContainer {
348
+ width: 90vw;
349
+ height: 90vh;
350
+ position: relative;
351
+ border-radius: 12px;
352
+ overflow: hidden;
353
+ background: var(--bgColor-default, #ffffff);
354
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
355
+ animation: expandScaleIn 0.2s ease;
356
+ }
357
+
358
+ @keyframes expandScaleIn {
359
+ from { transform: scale(0.95); opacity: 0; }
360
+ to { transform: scale(1); opacity: 1; }
361
+ }
362
+
363
+ .expandIframe {
364
+ border: none;
365
+ display: block;
366
+ width: 100%;
367
+ height: 100%;
368
+ }
369
+
370
+ .expandClose {
371
+ all: unset;
372
+ cursor: pointer;
373
+ position: absolute;
374
+ top: 12px;
375
+ right: 12px;
376
+ width: 32px;
377
+ height: 32px;
378
+ display: flex;
379
+ align-items: center;
380
+ justify-content: center;
381
+ border-radius: 8px;
382
+ background: rgba(0, 0, 0, 0.5);
383
+ color: #ffffff;
384
+ font-size: 16px;
385
+ z-index: 1;
386
+ transition: background 100ms;
387
+ backdrop-filter: blur(4px);
388
+ }
389
+
390
+ .expandClose:hover {
391
+ background: rgba(0, 0, 0, 0.7);
392
+ }
@@ -12,26 +12,28 @@ const COLORS = {
12
12
  orange: { bg: '#fff1e5', border: '#d18616', dot: '#e8a844' },
13
13
  }
14
14
 
15
- export default function StickyNote({ props, onUpdate }) {
15
+ export default function StickyNote({ props, onUpdate, resizable }) {
16
16
  const text = readProp(props, 'text', stickyNoteSchema)
17
17
  const color = readProp(props, 'color', stickyNoteSchema)
18
18
  const width = readProp(props, 'width', stickyNoteSchema)
19
19
  const height = readProp(props, 'height', stickyNoteSchema)
20
+ const canEdit = typeof onUpdate === 'function'
20
21
  const palette = COLORS[color] ?? COLORS.yellow
21
22
  const textareaRef = useRef(null)
22
23
  const stickyRef = useRef(null)
23
24
  const [editing, setEditing] = useState(false)
25
+ const editingActive = canEdit && editing
24
26
 
25
27
  const handleResize = useCallback((w, h) => {
26
28
  onUpdate?.({ width: w, height: h })
27
29
  }, [onUpdate])
28
30
 
29
31
  useEffect(() => {
30
- if (editing && textareaRef.current) {
32
+ if (editingActive && textareaRef.current) {
31
33
  textareaRef.current.focus()
32
34
  textareaRef.current.selectionStart = textareaRef.current.value.length
33
35
  }
34
- }, [editing])
36
+ }, [editingActive])
35
37
 
36
38
  const handleTextChange = useCallback((e) => {
37
39
  onUpdate?.({ text: e.target.value })
@@ -51,15 +53,16 @@ export default function StickyNote({ props, onUpdate }) {
51
53
  >
52
54
  <p
53
55
  className={styles.text}
54
- style={editing ? { visibility: 'hidden' } : undefined}
55
- onDoubleClick={() => setEditing(true)}
56
- role="button"
57
- tabIndex={0}
58
- onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}
56
+ style={editingActive ? { visibility: 'hidden' } : undefined}
57
+ data-canvas-allow-text-selection={!canEdit ? '' : undefined}
58
+ onDoubleClick={canEdit ? () => setEditing(true) : undefined}
59
+ role={canEdit ? 'button' : undefined}
60
+ tabIndex={canEdit ? 0 : undefined}
61
+ onKeyDown={canEdit ? (e) => { if (e.key === 'Enter') setEditing(true) } : undefined}
59
62
  >
60
- {text || 'Double-click to edit…'}
63
+ {text || (canEdit ? 'Double-click to edit…' : 'No content')}
61
64
  </p>
62
- {editing && (
65
+ {editingActive && (
63
66
  <textarea
64
67
  ref={textareaRef}
65
68
  className={styles.textarea}
@@ -75,12 +78,14 @@ export default function StickyNote({ props, onUpdate }) {
75
78
  placeholder="Type here…"
76
79
  />
77
80
  )}
78
- <ResizeHandle
79
- targetRef={stickyRef}
80
- minWidth={180}
81
- minHeight={60}
82
- onResize={handleResize}
83
- />
81
+ {resizable && (
82
+ <ResizeHandle
83
+ targetRef={stickyRef}
84
+ minWidth={180}
85
+ minHeight={60}
86
+ onResize={handleResize}
87
+ />
88
+ )}
84
89
  </article>
85
90
  </div>
86
91
  )