@dfosco/storyboard-react 2.8.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -3
- package/src/Viewfinder.jsx +5 -4
- package/src/canvas/CanvasControls.jsx +123 -0
- package/src/canvas/CanvasControls.module.css +133 -0
- package/src/canvas/CanvasPage.jsx +433 -0
- package/src/canvas/CanvasPage.module.css +73 -0
- package/src/canvas/CanvasToolbar.jsx +76 -0
- package/src/canvas/CanvasToolbar.module.css +92 -0
- package/src/canvas/canvasApi.js +41 -0
- package/src/canvas/useCanvas.js +74 -0
- package/src/canvas/widgets/ComponentWidget.jsx +15 -0
- package/src/canvas/widgets/LinkPreview.jsx +34 -0
- package/src/canvas/widgets/LinkPreview.module.css +51 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +91 -0
- package/src/canvas/widgets/MarkdownBlock.module.css +78 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +179 -0
- package/src/canvas/widgets/PrototypeEmbed.module.css +242 -0
- package/src/canvas/widgets/StickyNote.jsx +98 -0
- package/src/canvas/widgets/StickyNote.module.css +111 -0
- package/src/canvas/widgets/WidgetWrapper.jsx +15 -0
- package/src/canvas/widgets/WidgetWrapper.module.css +23 -0
- package/src/canvas/widgets/index.js +23 -0
- package/src/canvas/widgets/widgetProps.js +151 -0
- package/src/hooks/useFeatureFlag.js +2 -4
- package/src/hooks/useFlows.js +50 -0
- package/src/hooks/useFlows.test.js +134 -0
- package/src/index.js +5 -0
- package/src/vite/data-plugin.js +131 -29
- package/src/vite/data-plugin.test.js +3 -3
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo } from 'react'
|
|
2
|
+
import { getCanvasData } from '@dfosco/storyboard-core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fetch fresh canvas data from the server's .canvas.json file.
|
|
6
|
+
* Falls back to build-time data if the server is unavailable.
|
|
7
|
+
*/
|
|
8
|
+
async function fetchCanvasFromServer(name) {
|
|
9
|
+
try {
|
|
10
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
11
|
+
const res = await fetch(`${base}/_storyboard/canvas/read?name=${encodeURIComponent(name)}`)
|
|
12
|
+
if (res.ok) return res.json()
|
|
13
|
+
} catch { /* fall back to build-time data */ }
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Hook to load canvas data by name.
|
|
19
|
+
* Uses build-time data for static config (routes, JSX path), but fetches
|
|
20
|
+
* fresh widget data from the server to pick up persisted edits.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} name - Canvas name as indexed by the data plugin
|
|
23
|
+
* @returns {{ canvas: object|null, jsxExports: object|null, loading: boolean }}
|
|
24
|
+
*/
|
|
25
|
+
export function useCanvas(name) {
|
|
26
|
+
const buildTimeCanvas = useMemo(() => getCanvasData(name), [name])
|
|
27
|
+
const [canvas, setCanvas] = useState(buildTimeCanvas)
|
|
28
|
+
const [jsxExports, setJsxExports] = useState(null)
|
|
29
|
+
const [loading, setLoading] = useState(true)
|
|
30
|
+
|
|
31
|
+
// Fetch fresh data from server on mount
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!buildTimeCanvas) {
|
|
34
|
+
setCanvas(null)
|
|
35
|
+
setLoading(false)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
setLoading(true)
|
|
40
|
+
fetchCanvasFromServer(name).then((fresh) => {
|
|
41
|
+
if (fresh) {
|
|
42
|
+
// Merge: use server data for widgets/sources, keep build-time for _route/_jsxModule
|
|
43
|
+
setCanvas({ ...buildTimeCanvas, ...fresh })
|
|
44
|
+
} else {
|
|
45
|
+
setCanvas(buildTimeCanvas)
|
|
46
|
+
}
|
|
47
|
+
setLoading(false)
|
|
48
|
+
})
|
|
49
|
+
}, [name, buildTimeCanvas])
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!canvas?._jsxModule) {
|
|
53
|
+
setJsxExports(null)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
import(/* @vite-ignore */ canvas._jsxModule)
|
|
58
|
+
.then((mod) => {
|
|
59
|
+
const exports = {}
|
|
60
|
+
for (const [key, value] of Object.entries(mod)) {
|
|
61
|
+
if (key !== 'default' && typeof value === 'function') {
|
|
62
|
+
exports[key] = value
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
setJsxExports(exports)
|
|
66
|
+
})
|
|
67
|
+
.catch((err) => {
|
|
68
|
+
console.error(`[storyboard] Failed to load canvas JSX module: ${canvas._jsxModule}`, err)
|
|
69
|
+
setJsxExports(null)
|
|
70
|
+
})
|
|
71
|
+
}, [canvas?._jsxModule])
|
|
72
|
+
|
|
73
|
+
return { canvas, jsxExports, loading }
|
|
74
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Renders a live JSX export from a .canvas.jsx companion file.
|
|
5
|
+
* Content is read-only (re-renders on HMR), only position is mutable.
|
|
6
|
+
*/
|
|
7
|
+
export default function ComponentWidget({ component: Component }) {
|
|
8
|
+
if (!Component) return null
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<WidgetWrapper>
|
|
12
|
+
<Component />
|
|
13
|
+
</WidgetWrapper>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
2
|
+
import { readProp, linkPreviewSchema } from './widgetProps.js'
|
|
3
|
+
import styles from './LinkPreview.module.css'
|
|
4
|
+
|
|
5
|
+
export default function LinkPreview({ props }) {
|
|
6
|
+
const url = readProp(props, 'url', linkPreviewSchema)
|
|
7
|
+
const title = readProp(props, 'title', linkPreviewSchema)
|
|
8
|
+
|
|
9
|
+
let hostname = ''
|
|
10
|
+
try {
|
|
11
|
+
hostname = new URL(url).hostname
|
|
12
|
+
} catch { /* invalid URL */ }
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<WidgetWrapper>
|
|
16
|
+
<div className={styles.card}>
|
|
17
|
+
<span className={styles.icon}>🔗</span>
|
|
18
|
+
<div className={styles.text}>
|
|
19
|
+
{title && <p className={styles.title}>{title}</p>}
|
|
20
|
+
<a
|
|
21
|
+
href={url}
|
|
22
|
+
target="_blank"
|
|
23
|
+
rel="noopener noreferrer"
|
|
24
|
+
className={styles.url}
|
|
25
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
26
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
27
|
+
>
|
|
28
|
+
{hostname || url}
|
|
29
|
+
</a>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</WidgetWrapper>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
.card {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: 12px;
|
|
5
|
+
padding: 14px 16px;
|
|
6
|
+
text-decoration: none;
|
|
7
|
+
color: inherit;
|
|
8
|
+
min-width: 240px;
|
|
9
|
+
transition: background 150ms;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.card:hover {
|
|
13
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.icon {
|
|
17
|
+
font-size: 20px;
|
|
18
|
+
flex-shrink: 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.text {
|
|
22
|
+
overflow: hidden;
|
|
23
|
+
min-width: 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.title {
|
|
27
|
+
margin: 0;
|
|
28
|
+
font-size: 14px;
|
|
29
|
+
font-weight: 600;
|
|
30
|
+
line-height: 1.4;
|
|
31
|
+
color: var(--fgColor-default, #1f2328);
|
|
32
|
+
white-space: nowrap;
|
|
33
|
+
overflow: hidden;
|
|
34
|
+
text-overflow: ellipsis;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.url {
|
|
38
|
+
margin: 0;
|
|
39
|
+
font-size: 12px;
|
|
40
|
+
color: var(--fgColor-muted, #656d76);
|
|
41
|
+
white-space: nowrap;
|
|
42
|
+
overflow: hidden;
|
|
43
|
+
text-overflow: ellipsis;
|
|
44
|
+
text-decoration: none;
|
|
45
|
+
display: block;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.url:hover {
|
|
49
|
+
text-decoration: underline;
|
|
50
|
+
color: var(--fgColor-accent, #0969da);
|
|
51
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
2
|
+
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
3
|
+
import { readProp, markdownSchema } from './widgetProps.js'
|
|
4
|
+
import styles from './MarkdownBlock.module.css'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Renders markdown as plain HTML using a minimal built-in converter.
|
|
8
|
+
*/
|
|
9
|
+
function renderMarkdown(text) {
|
|
10
|
+
if (!text) return ''
|
|
11
|
+
return text
|
|
12
|
+
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
|
13
|
+
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
|
14
|
+
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
|
15
|
+
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
16
|
+
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
17
|
+
.replace(/`(.+?)`/g, '<code>$1</code>')
|
|
18
|
+
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
|
19
|
+
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
|
|
20
|
+
.replace(/\n\n/g, '</p><p>')
|
|
21
|
+
.replace(/\n/g, '<br>')
|
|
22
|
+
.replace(/^(.+)$/gm, (line) => {
|
|
23
|
+
if (line.startsWith('<')) return line
|
|
24
|
+
return `<p>${line}</p>`
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default function MarkdownBlock({ props, onUpdate }) {
|
|
29
|
+
const content = readProp(props, 'content', markdownSchema)
|
|
30
|
+
const width = readProp(props, 'width', markdownSchema)
|
|
31
|
+
const [editing, setEditing] = useState(false)
|
|
32
|
+
const textareaRef = useRef(null)
|
|
33
|
+
const blockRef = useRef(null)
|
|
34
|
+
const [editHeight, setEditHeight] = useState(null)
|
|
35
|
+
|
|
36
|
+
const handleContentChange = useCallback((e) => {
|
|
37
|
+
onUpdate?.({ content: e.target.value })
|
|
38
|
+
}, [onUpdate])
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (editing) {
|
|
42
|
+
// Capture the preview height before switching to editor
|
|
43
|
+
if (blockRef.current && !editHeight) {
|
|
44
|
+
setEditHeight(blockRef.current.offsetHeight)
|
|
45
|
+
}
|
|
46
|
+
if (textareaRef.current) {
|
|
47
|
+
textareaRef.current.focus()
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
setEditHeight(null)
|
|
51
|
+
}
|
|
52
|
+
}, [editing, editHeight])
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<WidgetWrapper>
|
|
56
|
+
<div
|
|
57
|
+
ref={blockRef}
|
|
58
|
+
className={styles.block}
|
|
59
|
+
style={{ width, minHeight: editHeight || undefined }}
|
|
60
|
+
>
|
|
61
|
+
{editing ? (
|
|
62
|
+
<textarea
|
|
63
|
+
ref={textareaRef}
|
|
64
|
+
className={styles.editor}
|
|
65
|
+
style={{ minHeight: editHeight ? editHeight - 2 : undefined }}
|
|
66
|
+
value={content}
|
|
67
|
+
onChange={handleContentChange}
|
|
68
|
+
onBlur={() => setEditing(false)}
|
|
69
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
70
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
71
|
+
onKeyDown={(e) => {
|
|
72
|
+
if (e.key === 'Escape') setEditing(false)
|
|
73
|
+
}}
|
|
74
|
+
placeholder="Write markdown…"
|
|
75
|
+
/>
|
|
76
|
+
) : (
|
|
77
|
+
<div
|
|
78
|
+
className={styles.preview}
|
|
79
|
+
onDoubleClick={() => setEditing(true)}
|
|
80
|
+
role="button"
|
|
81
|
+
tabIndex={0}
|
|
82
|
+
onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}
|
|
83
|
+
dangerouslySetInnerHTML={{
|
|
84
|
+
__html: renderMarkdown(content) || '<p class="placeholder">Double-click to edit…</p>',
|
|
85
|
+
}}
|
|
86
|
+
/>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
</WidgetWrapper>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
.block {
|
|
2
|
+
min-height: 80px;
|
|
3
|
+
background: var(--bgColor-default, #ffffff);
|
|
4
|
+
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.preview {
|
|
8
|
+
padding: 16px 20px;
|
|
9
|
+
font-size: 14px;
|
|
10
|
+
line-height: 1.6;
|
|
11
|
+
color: var(--fgColor-default, #1f2328);
|
|
12
|
+
cursor: text;
|
|
13
|
+
min-height: 60px;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.preview h1 {
|
|
17
|
+
font-size: 20px;
|
|
18
|
+
font-weight: 700;
|
|
19
|
+
margin: 0 0 8px;
|
|
20
|
+
line-height: 1.3;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.preview h2 {
|
|
24
|
+
font-size: 17px;
|
|
25
|
+
font-weight: 600;
|
|
26
|
+
margin: 0 0 6px;
|
|
27
|
+
line-height: 1.3;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.preview h3 {
|
|
31
|
+
font-size: 15px;
|
|
32
|
+
font-weight: 600;
|
|
33
|
+
margin: 0 0 4px;
|
|
34
|
+
line-height: 1.3;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.preview p {
|
|
38
|
+
margin: 0 0 8px;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.preview code {
|
|
42
|
+
background: var(--bgColor-neutral-muted, #afb8c133);
|
|
43
|
+
padding: 2px 5px;
|
|
44
|
+
border-radius: 4px;
|
|
45
|
+
font-size: 13px;
|
|
46
|
+
font-family: ui-monospace, monospace;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.preview ul {
|
|
50
|
+
margin: 0 0 8px;
|
|
51
|
+
padding-left: 20px;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.preview li {
|
|
55
|
+
margin: 0 0 2px;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.preview :global(.placeholder) {
|
|
59
|
+
color: var(--fgColor-muted, #656d76);
|
|
60
|
+
font-style: italic;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.editor {
|
|
64
|
+
display: block;
|
|
65
|
+
width: 100%;
|
|
66
|
+
height: 100%;
|
|
67
|
+
box-sizing: border-box;
|
|
68
|
+
min-height: 120px;
|
|
69
|
+
padding: 16px 20px;
|
|
70
|
+
border: none;
|
|
71
|
+
outline: none;
|
|
72
|
+
background: var(--bgColor-default, #ffffff);
|
|
73
|
+
font-family: ui-monospace, SFMono-Regular, monospace;
|
|
74
|
+
font-size: 13px;
|
|
75
|
+
line-height: 1.5;
|
|
76
|
+
color: var(--fgColor-default, #1f2328);
|
|
77
|
+
resize: none;
|
|
78
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
2
|
+
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
3
|
+
import { readProp, prototypeEmbedSchema } from './widgetProps.js'
|
|
4
|
+
import styles from './PrototypeEmbed.module.css'
|
|
5
|
+
|
|
6
|
+
export default function PrototypeEmbed({ props, onUpdate }) {
|
|
7
|
+
const src = readProp(props, 'src', prototypeEmbedSchema)
|
|
8
|
+
const width = readProp(props, 'width', prototypeEmbedSchema)
|
|
9
|
+
const height = readProp(props, 'height', prototypeEmbedSchema)
|
|
10
|
+
const zoom = readProp(props, 'zoom', prototypeEmbedSchema)
|
|
11
|
+
const label = readProp(props, 'label', prototypeEmbedSchema) || src
|
|
12
|
+
|
|
13
|
+
const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
14
|
+
const rawSrc = src ? `${basePath}${src}` : ''
|
|
15
|
+
const iframeSrc = rawSrc ? `${rawSrc}${rawSrc.includes('?') ? '&' : '?'}_sb_embed` : ''
|
|
16
|
+
|
|
17
|
+
const scale = zoom / 100
|
|
18
|
+
|
|
19
|
+
const [editing, setEditing] = useState(false)
|
|
20
|
+
const [interactive, setInteractive] = useState(false)
|
|
21
|
+
const inputRef = useRef(null)
|
|
22
|
+
const embedRef = useRef(null)
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (editing && inputRef.current) {
|
|
26
|
+
inputRef.current.focus()
|
|
27
|
+
inputRef.current.select()
|
|
28
|
+
}
|
|
29
|
+
}, [editing])
|
|
30
|
+
|
|
31
|
+
// Exit interactive mode when clicking outside the embed
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!interactive) return
|
|
34
|
+
function handlePointerDown(e) {
|
|
35
|
+
if (embedRef.current && !embedRef.current.contains(e.target)) {
|
|
36
|
+
setInteractive(false)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
document.addEventListener('pointerdown', handlePointerDown)
|
|
40
|
+
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
41
|
+
}, [interactive])
|
|
42
|
+
|
|
43
|
+
const enterInteractive = useCallback(() => setInteractive(true), [])
|
|
44
|
+
|
|
45
|
+
function handleSubmit(e) {
|
|
46
|
+
e.preventDefault()
|
|
47
|
+
const value = inputRef.current?.value?.trim() || ''
|
|
48
|
+
onUpdate?.({ src: value })
|
|
49
|
+
setEditing(false)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<WidgetWrapper>
|
|
54
|
+
<div
|
|
55
|
+
ref={embedRef}
|
|
56
|
+
className={styles.embed}
|
|
57
|
+
style={{ width, height }}
|
|
58
|
+
>
|
|
59
|
+
{editing ? (
|
|
60
|
+
<form
|
|
61
|
+
className={styles.urlForm}
|
|
62
|
+
onSubmit={handleSubmit}
|
|
63
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
64
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
65
|
+
>
|
|
66
|
+
<label className={styles.urlLabel}>Prototype URL path</label>
|
|
67
|
+
<input
|
|
68
|
+
ref={inputRef}
|
|
69
|
+
className={styles.urlInput}
|
|
70
|
+
type="text"
|
|
71
|
+
defaultValue={src}
|
|
72
|
+
placeholder="/MyPrototype/page"
|
|
73
|
+
onKeyDown={(e) => { if (e.key === 'Escape') setEditing(false) }}
|
|
74
|
+
/>
|
|
75
|
+
<div className={styles.urlActions}>
|
|
76
|
+
<button type="submit" className={styles.urlSave}>Save</button>
|
|
77
|
+
<button type="button" className={styles.urlCancel} onClick={() => setEditing(false)}>Cancel</button>
|
|
78
|
+
</div>
|
|
79
|
+
</form>
|
|
80
|
+
) : iframeSrc ? (
|
|
81
|
+
<>
|
|
82
|
+
<div className={styles.iframeContainer}>
|
|
83
|
+
<iframe
|
|
84
|
+
src={iframeSrc}
|
|
85
|
+
className={styles.iframe}
|
|
86
|
+
style={{
|
|
87
|
+
width: width / scale,
|
|
88
|
+
height: height / scale,
|
|
89
|
+
transform: `scale(${scale})`,
|
|
90
|
+
transformOrigin: '0 0',
|
|
91
|
+
}}
|
|
92
|
+
title={label || 'Prototype embed'}
|
|
93
|
+
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
{!interactive && (
|
|
97
|
+
<div
|
|
98
|
+
className={styles.dragOverlay}
|
|
99
|
+
onDoubleClick={enterInteractive}
|
|
100
|
+
/>
|
|
101
|
+
)}
|
|
102
|
+
</>
|
|
103
|
+
) : (
|
|
104
|
+
<div
|
|
105
|
+
className={styles.empty}
|
|
106
|
+
onDoubleClick={() => setEditing(true)}
|
|
107
|
+
role="button"
|
|
108
|
+
tabIndex={0}
|
|
109
|
+
onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}
|
|
110
|
+
>
|
|
111
|
+
<p>Double-click to set prototype URL</p>
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
{iframeSrc && !editing && (
|
|
115
|
+
<button
|
|
116
|
+
className={styles.editBtn}
|
|
117
|
+
onClick={(e) => { e.stopPropagation(); setEditing(true) }}
|
|
118
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
119
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
120
|
+
title="Edit URL"
|
|
121
|
+
aria-label="Edit prototype URL"
|
|
122
|
+
>
|
|
123
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><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"/></svg>
|
|
124
|
+
</button>
|
|
125
|
+
)}
|
|
126
|
+
{iframeSrc && !editing && (
|
|
127
|
+
<div
|
|
128
|
+
className={styles.zoomBar}
|
|
129
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
130
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
131
|
+
>
|
|
132
|
+
<button
|
|
133
|
+
className={styles.zoomBtn}
|
|
134
|
+
onClick={() => {
|
|
135
|
+
const step = zoom <= 75 ? 5 : 25
|
|
136
|
+
onUpdate?.({ zoom: Math.max(25, zoom - step) })
|
|
137
|
+
}}
|
|
138
|
+
disabled={zoom <= 25}
|
|
139
|
+
aria-label="Zoom out"
|
|
140
|
+
>−</button>
|
|
141
|
+
<span className={styles.zoomLabel}>{zoom}%</span>
|
|
142
|
+
<button
|
|
143
|
+
className={styles.zoomBtn}
|
|
144
|
+
onClick={() => {
|
|
145
|
+
const step = zoom < 75 ? 5 : 25
|
|
146
|
+
onUpdate?.({ zoom: Math.min(200, zoom + step) })
|
|
147
|
+
}}
|
|
148
|
+
disabled={zoom >= 200}
|
|
149
|
+
aria-label="Zoom in"
|
|
150
|
+
>+</button>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
<div
|
|
155
|
+
className={styles.resizeHandle}
|
|
156
|
+
onMouseDown={(e) => {
|
|
157
|
+
e.stopPropagation()
|
|
158
|
+
e.preventDefault()
|
|
159
|
+
const startX = e.clientX
|
|
160
|
+
const startY = e.clientY
|
|
161
|
+
const startW = width
|
|
162
|
+
const startH = height
|
|
163
|
+
function onMove(ev) {
|
|
164
|
+
const newW = Math.max(200, startW + ev.clientX - startX)
|
|
165
|
+
const newH = Math.max(150, startH + ev.clientY - startY)
|
|
166
|
+
onUpdate?.({ width: newW, height: newH })
|
|
167
|
+
}
|
|
168
|
+
function onUp() {
|
|
169
|
+
document.removeEventListener('mousemove', onMove)
|
|
170
|
+
document.removeEventListener('mouseup', onUp)
|
|
171
|
+
}
|
|
172
|
+
document.addEventListener('mousemove', onMove)
|
|
173
|
+
document.addEventListener('mouseup', onUp)
|
|
174
|
+
}}
|
|
175
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
176
|
+
/>
|
|
177
|
+
</WidgetWrapper>
|
|
178
|
+
)
|
|
179
|
+
}
|