@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.
- package/package.json +3 -3
- package/src/Viewfinder.jsx +5 -3
- package/src/__mocks__/virtual-storyboard-data-index.js +1 -0
- package/src/canvas/CanvasControls.jsx +2 -59
- package/src/canvas/CanvasControls.module.css +0 -29
- package/src/canvas/CanvasPage.bridge.test.jsx +68 -42
- package/src/canvas/CanvasPage.jsx +791 -68
- package/src/canvas/CanvasPage.module.css +47 -2
- package/src/canvas/CanvasPage.multiselect.test.jsx +345 -0
- package/src/canvas/canvasApi.js +8 -0
- package/src/canvas/computeCanvasBounds.test.js +121 -0
- package/src/canvas/useCanvas.js +2 -1
- package/src/canvas/useUndoRedo.js +86 -0
- package/src/canvas/useUndoRedo.test.js +231 -0
- package/src/canvas/widgets/ComponentWidget.jsx +9 -7
- package/src/canvas/widgets/FigmaEmbed.jsx +195 -0
- package/src/canvas/widgets/FigmaEmbed.module.css +147 -0
- package/src/canvas/widgets/ImageWidget.jsx +115 -0
- package/src/canvas/widgets/ImageWidget.module.css +39 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +25 -8
- package/src/canvas/widgets/MarkdownBlock.test.jsx +53 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +132 -26
- package/src/canvas/widgets/PrototypeEmbed.module.css +66 -2
- package/src/canvas/widgets/StickyNote.jsx +21 -16
- package/src/canvas/widgets/StickyNote.test.jsx +24 -4
- package/src/canvas/widgets/WidgetChrome.jsx +276 -50
- package/src/canvas/widgets/WidgetChrome.module.css +91 -10
- package/src/canvas/widgets/figmaUrl.js +118 -0
- package/src/canvas/widgets/figmaUrl.test.js +139 -0
- package/src/canvas/widgets/index.js +4 -0
- package/src/canvas/widgets/widgetConfig.js +74 -6
- package/src/canvas/widgets/widgetConfig.test.js +46 -0
- package/src/canvas/widgets/widgetProps.js +2 -0
- package/src/context.jsx +34 -4
- 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 (
|
|
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
|
-
}, [
|
|
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
|
-
{
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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) ||
|
|
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
|
|
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.
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 (
|
|
32
|
+
if (editingActive && textareaRef.current) {
|
|
31
33
|
textareaRef.current.focus()
|
|
32
34
|
textareaRef.current.selectionStart = textareaRef.current.value.length
|
|
33
35
|
}
|
|
34
|
-
}, [
|
|
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={
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
)
|