@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,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figma URL utilities — detection, sanitization, and embed URL transformation.
|
|
3
|
+
*
|
|
4
|
+
* Supports three Figma link types:
|
|
5
|
+
* - Board: figma.com/board/{key}/{name}
|
|
6
|
+
* - Design: figma.com/design/{key}/{name}
|
|
7
|
+
* - Proto: figma.com/proto/{key}/{name}
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const FIGMA_HOST_RE = /^(www\.)?figma\.com$/
|
|
11
|
+
const FIGMA_PATH_RE = /^\/(board|design|proto)\/[A-Za-z0-9]+/
|
|
12
|
+
|
|
13
|
+
/** Params to strip from stored/embed URLs (session/tracking tokens). */
|
|
14
|
+
const STRIP_PARAMS = new Set(['t'])
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Check whether a URL string is a Figma board, design, or prototype link.
|
|
18
|
+
* @param {string} url
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
export function isFigmaUrl(url) {
|
|
22
|
+
try {
|
|
23
|
+
const parsed = new URL(url)
|
|
24
|
+
return FIGMA_HOST_RE.test(parsed.hostname) && FIGMA_PATH_RE.test(parsed.pathname)
|
|
25
|
+
} catch {
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Return the Figma link type: 'board', 'design', or 'proto'.
|
|
32
|
+
* Returns null for non-Figma URLs.
|
|
33
|
+
* @param {string} url
|
|
34
|
+
* @returns {'board' | 'design' | 'proto' | null}
|
|
35
|
+
*/
|
|
36
|
+
export function getFigmaType(url) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = new URL(url)
|
|
39
|
+
if (!FIGMA_HOST_RE.test(parsed.hostname)) return null
|
|
40
|
+
const match = parsed.pathname.match(FIGMA_PATH_RE)
|
|
41
|
+
if (!match) return null
|
|
42
|
+
return match[1]
|
|
43
|
+
} catch {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Sanitize a Figma URL for storage — strips tracking params like `t`.
|
|
50
|
+
* Returns a canonical www.figma.com URL safe to persist in canvas data.
|
|
51
|
+
* @param {string} url — raw pasted Figma URL
|
|
52
|
+
* @returns {string} sanitized URL
|
|
53
|
+
*/
|
|
54
|
+
export function sanitizeFigmaUrl(url) {
|
|
55
|
+
try {
|
|
56
|
+
const parsed = new URL(url)
|
|
57
|
+
if (!FIGMA_HOST_RE.test(parsed.hostname)) return url
|
|
58
|
+
// Normalize to www.figma.com
|
|
59
|
+
parsed.hostname = 'www.figma.com'
|
|
60
|
+
for (const key of STRIP_PARAMS) {
|
|
61
|
+
parsed.searchParams.delete(key)
|
|
62
|
+
}
|
|
63
|
+
return parsed.toString()
|
|
64
|
+
} catch {
|
|
65
|
+
return url
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Transform a Figma URL into its embed counterpart.
|
|
71
|
+
*
|
|
72
|
+
* - Replaces host with `embed.figma.com`
|
|
73
|
+
* - Strips tracking params (`t`)
|
|
74
|
+
* - Appends `embed-host=share`
|
|
75
|
+
*
|
|
76
|
+
* @param {string} url — original Figma URL
|
|
77
|
+
* @returns {string} embed URL, or the original URL if it can't be transformed
|
|
78
|
+
*/
|
|
79
|
+
export function toFigmaEmbedUrl(url) {
|
|
80
|
+
try {
|
|
81
|
+
const parsed = new URL(url)
|
|
82
|
+
if (!FIGMA_HOST_RE.test(parsed.hostname)) return url
|
|
83
|
+
|
|
84
|
+
parsed.hostname = 'embed.figma.com'
|
|
85
|
+
|
|
86
|
+
// Strip tracking/session params
|
|
87
|
+
for (const key of STRIP_PARAMS) {
|
|
88
|
+
parsed.searchParams.delete(key)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Ensure embed-host is set
|
|
92
|
+
parsed.searchParams.set('embed-host', 'share')
|
|
93
|
+
|
|
94
|
+
return parsed.toString()
|
|
95
|
+
} catch {
|
|
96
|
+
return url
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Extract a human-readable title from a Figma URL.
|
|
102
|
+
* Uses the name segment from the path (e.g. "Security-Products-HQ").
|
|
103
|
+
* @param {string} url
|
|
104
|
+
* @returns {string}
|
|
105
|
+
*/
|
|
106
|
+
export function getFigmaTitle(url) {
|
|
107
|
+
try {
|
|
108
|
+
const parsed = new URL(url)
|
|
109
|
+
// Path: /board|design|proto/{key}/{name}
|
|
110
|
+
const segments = parsed.pathname.split('/').filter(Boolean)
|
|
111
|
+
if (segments.length >= 3) {
|
|
112
|
+
return segments[2].replace(/-/g, ' ')
|
|
113
|
+
}
|
|
114
|
+
return 'Figma'
|
|
115
|
+
} catch {
|
|
116
|
+
return 'Figma'
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { isFigmaUrl, getFigmaType, toFigmaEmbedUrl, getFigmaTitle, sanitizeFigmaUrl } from './figmaUrl.js'
|
|
3
|
+
|
|
4
|
+
describe('isFigmaUrl', () => {
|
|
5
|
+
it('detects board URLs', () => {
|
|
6
|
+
expect(isFigmaUrl('https://www.figma.com/board/QlwxSiYxYQsHmnLNYpQRtR/Security-Products-HQ?node-id=0-1&t=XBF45Am0VgicAITG-0')).toBe(true)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('detects design URLs', () => {
|
|
10
|
+
expect(isFigmaUrl('https://www.figma.com/design/i8jI8RuxPnGQGp2QllfAr7/Darby-s-copilot-metric-sandbox?node-id=103-4739')).toBe(true)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('detects proto URLs', () => {
|
|
14
|
+
expect(isFigmaUrl('https://www.figma.com/proto/i8jI8RuxPnGQGp2QllfAr7/Darby-s-copilot-metric-sandbox?node-id=122-9632&p=f&t=9XSi047pSbt81sZS-0&scaling=min-zoom')).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('works without www prefix', () => {
|
|
18
|
+
expect(isFigmaUrl('https://figma.com/board/abc123/My-Board')).toBe(true)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('rejects non-Figma URLs', () => {
|
|
22
|
+
expect(isFigmaUrl('https://example.com/board/abc')).toBe(false)
|
|
23
|
+
expect(isFigmaUrl('https://www.figma.com/file/abc')).toBe(false)
|
|
24
|
+
expect(isFigmaUrl('not a url')).toBe(false)
|
|
25
|
+
expect(isFigmaUrl('')).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('getFigmaType', () => {
|
|
30
|
+
it('returns board for board URLs', () => {
|
|
31
|
+
expect(getFigmaType('https://www.figma.com/board/QlwxSiYxYQsHmnLNYpQRtR/Name')).toBe('board')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('returns design for design URLs', () => {
|
|
35
|
+
expect(getFigmaType('https://www.figma.com/design/i8jI8RuxPnGQGp2QllfAr7/Name')).toBe('design')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('returns proto for proto URLs', () => {
|
|
39
|
+
expect(getFigmaType('https://www.figma.com/proto/i8jI8RuxPnGQGp2QllfAr7/Name')).toBe('proto')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('returns null for non-Figma URLs', () => {
|
|
43
|
+
expect(getFigmaType('https://example.com')).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('toFigmaEmbedUrl', () => {
|
|
48
|
+
it('transforms board URL', () => {
|
|
49
|
+
const input = 'https://www.figma.com/board/QlwxSiYxYQsHmnLNYpQRtR/Security-Products-HQ?node-id=0-1&t=XBF45Am0VgicAITG-0'
|
|
50
|
+
const result = toFigmaEmbedUrl(input)
|
|
51
|
+
const parsed = new URL(result)
|
|
52
|
+
|
|
53
|
+
expect(parsed.hostname).toBe('embed.figma.com')
|
|
54
|
+
expect(parsed.pathname).toBe('/board/QlwxSiYxYQsHmnLNYpQRtR/Security-Products-HQ')
|
|
55
|
+
expect(parsed.searchParams.get('node-id')).toBe('0-1')
|
|
56
|
+
expect(parsed.searchParams.get('embed-host')).toBe('share')
|
|
57
|
+
expect(parsed.searchParams.has('t')).toBe(false)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('transforms design URL', () => {
|
|
61
|
+
const input = 'https://www.figma.com/design/i8jI8RuxPnGQGp2QllfAr7/Darby-s-copilot-metric-sandbox?node-id=103-4739'
|
|
62
|
+
const result = toFigmaEmbedUrl(input)
|
|
63
|
+
const parsed = new URL(result)
|
|
64
|
+
|
|
65
|
+
expect(parsed.hostname).toBe('embed.figma.com')
|
|
66
|
+
expect(parsed.pathname).toBe('/design/i8jI8RuxPnGQGp2QllfAr7/Darby-s-copilot-metric-sandbox')
|
|
67
|
+
expect(parsed.searchParams.get('node-id')).toBe('103-4739')
|
|
68
|
+
expect(parsed.searchParams.get('embed-host')).toBe('share')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('transforms proto URL and preserves relevant params', () => {
|
|
72
|
+
const input = 'https://www.figma.com/proto/i8jI8RuxPnGQGp2QllfAr7/Darby-s-copilot-metric-sandbox?node-id=122-9632&p=f&t=9XSi047pSbt81sZS-0&scaling=min-zoom&content-scaling=fixed&page-id=103%3A4739&starting-point-node-id=140%3A5949'
|
|
73
|
+
const result = toFigmaEmbedUrl(input)
|
|
74
|
+
const parsed = new URL(result)
|
|
75
|
+
|
|
76
|
+
expect(parsed.hostname).toBe('embed.figma.com')
|
|
77
|
+
expect(parsed.pathname).toBe('/proto/i8jI8RuxPnGQGp2QllfAr7/Darby-s-copilot-metric-sandbox')
|
|
78
|
+
expect(parsed.searchParams.get('node-id')).toBe('122-9632')
|
|
79
|
+
expect(parsed.searchParams.get('p')).toBe('f')
|
|
80
|
+
expect(parsed.searchParams.get('scaling')).toBe('min-zoom')
|
|
81
|
+
expect(parsed.searchParams.get('content-scaling')).toBe('fixed')
|
|
82
|
+
expect(parsed.searchParams.get('page-id')).toBe('103:4739')
|
|
83
|
+
expect(parsed.searchParams.get('starting-point-node-id')).toBe('140:5949')
|
|
84
|
+
expect(parsed.searchParams.get('embed-host')).toBe('share')
|
|
85
|
+
expect(parsed.searchParams.has('t')).toBe(false)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('returns original URL for non-Figma URLs', () => {
|
|
89
|
+
expect(toFigmaEmbedUrl('https://example.com')).toBe('https://example.com')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('getFigmaTitle', () => {
|
|
94
|
+
it('extracts title from board URL', () => {
|
|
95
|
+
expect(getFigmaTitle('https://www.figma.com/board/QlwxSiYxYQsHmnLNYpQRtR/Security-Products-HQ')).toBe('Security Products HQ')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('extracts title from design URL', () => {
|
|
99
|
+
expect(getFigmaTitle('https://www.figma.com/design/i8jI8RuxPnGQGp2QllfAr7/Darby-s-copilot-metric-sandbox')).toBe("Darby s copilot metric sandbox")
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('returns Figma for URLs without name segment', () => {
|
|
103
|
+
expect(getFigmaTitle('https://www.figma.com/board/abc')).toBe('Figma')
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('sanitizeFigmaUrl', () => {
|
|
108
|
+
it('strips tracking param and normalizes to www.figma.com', () => {
|
|
109
|
+
const input = 'https://www.figma.com/board/QlwxSiYxYQsHmnLNYpQRtR/Security-Products-HQ?node-id=0-1&t=XBF45Am0VgicAITG-0'
|
|
110
|
+
const result = sanitizeFigmaUrl(input)
|
|
111
|
+
const parsed = new URL(result)
|
|
112
|
+
|
|
113
|
+
expect(parsed.hostname).toBe('www.figma.com')
|
|
114
|
+
expect(parsed.searchParams.get('node-id')).toBe('0-1')
|
|
115
|
+
expect(parsed.searchParams.has('t')).toBe(false)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('normalizes figma.com to www.figma.com', () => {
|
|
119
|
+
const input = 'https://figma.com/board/abc/Name?node-id=0-1'
|
|
120
|
+
const result = sanitizeFigmaUrl(input)
|
|
121
|
+
expect(new URL(result).hostname).toBe('www.figma.com')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('preserves all non-tracking params for proto URLs', () => {
|
|
125
|
+
const input = 'https://www.figma.com/proto/abc/Name?node-id=1-2&p=f&t=TOKEN&scaling=min-zoom&page-id=103%3A4739'
|
|
126
|
+
const result = sanitizeFigmaUrl(input)
|
|
127
|
+
const parsed = new URL(result)
|
|
128
|
+
|
|
129
|
+
expect(parsed.searchParams.get('node-id')).toBe('1-2')
|
|
130
|
+
expect(parsed.searchParams.get('p')).toBe('f')
|
|
131
|
+
expect(parsed.searchParams.get('scaling')).toBe('min-zoom')
|
|
132
|
+
expect(parsed.searchParams.get('page-id')).toBe('103:4739')
|
|
133
|
+
expect(parsed.searchParams.has('t')).toBe(false)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('returns non-Figma URLs unchanged', () => {
|
|
137
|
+
expect(sanitizeFigmaUrl('https://example.com')).toBe('https://example.com')
|
|
138
|
+
})
|
|
139
|
+
})
|
|
@@ -2,6 +2,8 @@ import StickyNote from './StickyNote.jsx'
|
|
|
2
2
|
import MarkdownBlock from './MarkdownBlock.jsx'
|
|
3
3
|
import PrototypeEmbed from './PrototypeEmbed.jsx'
|
|
4
4
|
import LinkPreview from './LinkPreview.jsx'
|
|
5
|
+
import ImageWidget from './ImageWidget.jsx'
|
|
6
|
+
import FigmaEmbed from './FigmaEmbed.jsx'
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Maps widget type strings to their React components.
|
|
@@ -12,6 +14,8 @@ export const widgetRegistry = {
|
|
|
12
14
|
'markdown': MarkdownBlock,
|
|
13
15
|
'prototype': PrototypeEmbed,
|
|
14
16
|
'link-preview': LinkPreview,
|
|
17
|
+
'image': ImageWidget,
|
|
18
|
+
'figma-embed': FigmaEmbed,
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
/**
|
|
@@ -6,9 +6,44 @@
|
|
|
6
6
|
*
|
|
7
7
|
* The config is the single source of truth for widget definitions —
|
|
8
8
|
* prop schemas, feature lists, labels, and icons all come from here.
|
|
9
|
+
*
|
|
10
|
+
* Supports `$variable` references in string values, resolved from
|
|
11
|
+
* the top-level `variables` object in widgets.config.json.
|
|
9
12
|
*/
|
|
10
13
|
import widgetsConfig from '@dfosco/storyboard-core/widgets.config.json'
|
|
11
14
|
|
|
15
|
+
/** Variables defined in config — used to resolve `$key` references. */
|
|
16
|
+
const variables = widgetsConfig.variables || {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve `$variable` references in a string value.
|
|
20
|
+
* Returns the original value if it's not a string or doesn't start with `$`.
|
|
21
|
+
*/
|
|
22
|
+
function resolveVar(value) {
|
|
23
|
+
if (typeof value !== 'string' || !value.startsWith('$')) return value
|
|
24
|
+
const key = value.slice(1)
|
|
25
|
+
return variables[key] ?? value
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve all string values in a feature object, including nested items.
|
|
30
|
+
*/
|
|
31
|
+
function resolveFeature(feature) {
|
|
32
|
+
const resolved = {}
|
|
33
|
+
for (const [key, val] of Object.entries(feature)) {
|
|
34
|
+
if (key === 'items' && Array.isArray(val)) {
|
|
35
|
+
resolved[key] = val.map((item) => {
|
|
36
|
+
const r = {}
|
|
37
|
+
for (const [k, v] of Object.entries(item)) r[k] = resolveVar(v)
|
|
38
|
+
return r
|
|
39
|
+
})
|
|
40
|
+
} else {
|
|
41
|
+
resolved[key] = resolveVar(val)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return resolved
|
|
45
|
+
}
|
|
46
|
+
|
|
12
47
|
/**
|
|
13
48
|
* Convert a config prop definition to the schema shape used by widgetProps.js.
|
|
14
49
|
* Config uses `"default"`, schema uses `"defaultValue"`.
|
|
@@ -42,19 +77,52 @@ function buildSchemas() {
|
|
|
42
77
|
return result
|
|
43
78
|
}
|
|
44
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Build resolved widget type entries with variables expanded in features.
|
|
82
|
+
*/
|
|
83
|
+
function buildWidgetTypes() {
|
|
84
|
+
const result = {}
|
|
85
|
+
for (const [type, def] of Object.entries(widgetsConfig.widgets)) {
|
|
86
|
+
result[type] = {
|
|
87
|
+
...def,
|
|
88
|
+
features: (def.features || []).map(resolveFeature),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return result
|
|
92
|
+
}
|
|
93
|
+
|
|
45
94
|
/** All widget schemas, keyed by type string. */
|
|
46
95
|
export const schemas = buildSchemas()
|
|
47
96
|
|
|
48
|
-
/** Full widget config entries, keyed by type string. */
|
|
49
|
-
export const widgetTypes =
|
|
97
|
+
/** Full widget config entries (with resolved variables), keyed by type string. */
|
|
98
|
+
export const widgetTypes = buildWidgetTypes()
|
|
50
99
|
|
|
51
100
|
/**
|
|
52
101
|
* Get the feature list for a widget type.
|
|
102
|
+
* In production, only features with `prod: true` are returned.
|
|
103
|
+
* In dev, all features are returned.
|
|
53
104
|
* @param {string} type — widget type string
|
|
54
|
-
* @returns {Array} features array from config, or empty array
|
|
105
|
+
* @returns {Array} features array from config (variables resolved), or empty array
|
|
55
106
|
*/
|
|
56
107
|
export function getFeatures(type) {
|
|
57
|
-
|
|
108
|
+
const features = widgetTypes[type]?.features ?? []
|
|
109
|
+
if (import.meta.env?.PROD) {
|
|
110
|
+
return features.filter(f => f.prod)
|
|
111
|
+
}
|
|
112
|
+
return features
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Check if a widget type supports resize in the current environment.
|
|
117
|
+
* Returns false if resize is disabled, or if in production and prod is not true.
|
|
118
|
+
* @param {string} type — widget type string
|
|
119
|
+
* @returns {boolean}
|
|
120
|
+
*/
|
|
121
|
+
export function isResizable(type) {
|
|
122
|
+
const resize = widgetTypes[type]?.resize
|
|
123
|
+
if (!resize?.enabled) return false
|
|
124
|
+
if (import.meta.env?.PROD && !resize.prod) return false
|
|
125
|
+
return true
|
|
58
126
|
}
|
|
59
127
|
|
|
60
128
|
/**
|
|
@@ -70,10 +138,10 @@ export function getWidgetMeta(type) {
|
|
|
70
138
|
|
|
71
139
|
/**
|
|
72
140
|
* Get all widget types as an array of { type, label, icon } for menus.
|
|
73
|
-
* Excludes link-preview which
|
|
141
|
+
* Excludes link-preview, image, and figma-embed which are created via paste only.
|
|
74
142
|
*/
|
|
75
143
|
export function getMenuWidgetTypes() {
|
|
76
144
|
return Object.entries(widgetTypes)
|
|
77
|
-
.filter(([type]) => type !== 'link-preview')
|
|
145
|
+
.filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed')
|
|
78
146
|
.map(([type, def]) => ({ type, label: def.label, icon: def.icon }))
|
|
79
147
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { isResizable, getFeatures, getWidgetMeta } from './widgetConfig.js'
|
|
3
|
+
|
|
4
|
+
describe('isResizable', () => {
|
|
5
|
+
// Vitest runs with import.meta.env.PROD = true, so prod: false widgets
|
|
6
|
+
// correctly return false. This tests the production behavior.
|
|
7
|
+
it('returns false for resize-enabled widgets when prod is false (production env)', () => {
|
|
8
|
+
expect(isResizable('sticky-note')).toBe(false)
|
|
9
|
+
expect(isResizable('prototype')).toBe(false)
|
|
10
|
+
expect(isResizable('figma-embed')).toBe(false)
|
|
11
|
+
expect(isResizable('image')).toBe(false)
|
|
12
|
+
expect(isResizable('component')).toBe(false)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('returns false for widget types with resize disabled', () => {
|
|
16
|
+
expect(isResizable('markdown')).toBe(false)
|
|
17
|
+
expect(isResizable('link-preview')).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns false for unknown widget types', () => {
|
|
21
|
+
expect(isResizable('nonexistent')).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('getFeatures', () => {
|
|
26
|
+
it('returns features array for known widget types', () => {
|
|
27
|
+
const features = getFeatures('sticky-note')
|
|
28
|
+
expect(Array.isArray(features)).toBe(true)
|
|
29
|
+
expect(features.length).toBeGreaterThan(0)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('returns empty array for unknown widget types', () => {
|
|
33
|
+
expect(getFeatures('nonexistent')).toEqual([])
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('getWidgetMeta', () => {
|
|
38
|
+
it('returns label and icon for known types', () => {
|
|
39
|
+
const meta = getWidgetMeta('sticky-note')
|
|
40
|
+
expect(meta).toEqual({ label: 'Sticky Note', icon: '📝' })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('returns null for unknown types', () => {
|
|
44
|
+
expect(getWidgetMeta('nonexistent')).toBeNull()
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -127,3 +127,5 @@ export const stickyNoteSchema = schemas['sticky-note']
|
|
|
127
127
|
export const markdownSchema = schemas['markdown']
|
|
128
128
|
export const prototypeEmbedSchema = schemas['prototype']
|
|
129
129
|
export const linkPreviewSchema = schemas['link-preview']
|
|
130
|
+
export const imageSchema = schemas['image']
|
|
131
|
+
export const figmaEmbedSchema = schemas['figma-embed']
|
package/src/context.jsx
CHANGED
|
@@ -22,6 +22,11 @@ function matchCanvasRoute(pathname) {
|
|
|
22
22
|
return canvasRouteMap.get(normalized) || null
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
function isCanvasPath(pathname) {
|
|
26
|
+
const normalized = pathname.replace(/\/+$/, '') || '/'
|
|
27
|
+
return normalized === '/canvas' || normalized.startsWith('/canvas/')
|
|
28
|
+
}
|
|
29
|
+
|
|
25
30
|
/**
|
|
26
31
|
* Derives the top-level prototype name from a pathname.
|
|
27
32
|
* "/Dashboard" → "Dashboard", "/Dashboard/sub" → "Dashboard"
|
|
@@ -62,6 +67,10 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
62
67
|
|
|
63
68
|
// Canvas route detection — matches current URL against registered canvas routes
|
|
64
69
|
const canvasName = useMemo(() => matchCanvasRoute(location.pathname), [location.pathname])
|
|
70
|
+
const isMissingCanvasRoute = useMemo(
|
|
71
|
+
() => isCanvasPath(location.pathname) && !canvasName,
|
|
72
|
+
[location.pathname, canvasName],
|
|
73
|
+
)
|
|
65
74
|
|
|
66
75
|
const searchParams = new URLSearchParams(location.search)
|
|
67
76
|
const sceneParam = searchParams.get('flow') || searchParams.get('scene')
|
|
@@ -70,7 +79,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
70
79
|
|
|
71
80
|
// Resolve flow name with prototype scoping (skip for canvas pages)
|
|
72
81
|
const activeFlowName = useMemo(() => {
|
|
73
|
-
if (canvasName) return null
|
|
82
|
+
if (canvasName || isMissingCanvasRoute) return null
|
|
74
83
|
const requested = sceneParam || flowName || sceneName
|
|
75
84
|
if (requested) {
|
|
76
85
|
// Allow fully-scoped flow names from URLs/widgets without re-prefixing
|
|
@@ -94,7 +103,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
94
103
|
// 4. Global default — or null if no flow exists at all
|
|
95
104
|
if (flowExists('default')) return 'default'
|
|
96
105
|
return null
|
|
97
|
-
}, [canvasName, sceneParam, flowName, sceneName, prototypeName, pageFlow])
|
|
106
|
+
}, [canvasName, isMissingCanvasRoute, sceneParam, flowName, sceneName, prototypeName, pageFlow])
|
|
98
107
|
|
|
99
108
|
// Auto-install body class sync (sb-key--value classes on <body>)
|
|
100
109
|
useEffect(() => installBodyClassSync(), [])
|
|
@@ -117,7 +126,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
117
126
|
|
|
118
127
|
// Skip flow loading for canvas pages and flow-less pages
|
|
119
128
|
const { data, error } = useMemo(() => {
|
|
120
|
-
if (canvasName) return { data: null, error: null }
|
|
129
|
+
if (canvasName || isMissingCanvasRoute) return { data: null, error: null }
|
|
121
130
|
if (!activeFlowName) return { data: {}, error: null }
|
|
122
131
|
try {
|
|
123
132
|
let flowData = loadFlow(activeFlowName)
|
|
@@ -136,7 +145,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
136
145
|
} catch (err) {
|
|
137
146
|
return { data: null, error: err.message }
|
|
138
147
|
}
|
|
139
|
-
}, [canvasName, activeFlowName, recordName, recordParam, params, prototypeName])
|
|
148
|
+
}, [canvasName, isMissingCanvasRoute, activeFlowName, recordName, recordParam, params, prototypeName])
|
|
140
149
|
|
|
141
150
|
// Canvas pages get their own rendering path — no flow data needed
|
|
142
151
|
if (canvasName) {
|
|
@@ -157,6 +166,27 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
|
|
|
157
166
|
)
|
|
158
167
|
}
|
|
159
168
|
|
|
169
|
+
if (isMissingCanvasRoute) {
|
|
170
|
+
const currentUrl = `${location.pathname}${location.search}`
|
|
171
|
+
const truncatedUrl = currentUrl.length > 60
|
|
172
|
+
? currentUrl.slice(0, 60) + '…'
|
|
173
|
+
: currentUrl
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<main className={styles.container}>
|
|
177
|
+
<div className={styles.banner}>
|
|
178
|
+
<strong>Canvas not found</strong>
|
|
179
|
+
No canvas matches this route.
|
|
180
|
+
</div>
|
|
181
|
+
<p className={styles.meta}>
|
|
182
|
+
Tried to open{' '}
|
|
183
|
+
<a href={currentUrl} title={currentUrl}>{truncatedUrl}</a>
|
|
184
|
+
</p>
|
|
185
|
+
<a className={styles.homeLink} href="/">← Go to index page</a>
|
|
186
|
+
</main>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
160
190
|
const value = {
|
|
161
191
|
data,
|
|
162
192
|
error,
|
package/src/context.test.jsx
CHANGED
|
@@ -280,4 +280,17 @@ describe('StoryboardProvider', () => {
|
|
|
280
280
|
)
|
|
281
281
|
expect(screen.getByTestId('ctx')).toHaveTextContent('Global Default')
|
|
282
282
|
})
|
|
283
|
+
|
|
284
|
+
it('shows a simple 404 for unknown canvas routes with an index link', () => {
|
|
285
|
+
mockUseLocation.mockReturnValue({ pathname: '/canvas/unknown-board', search: '', hash: '' })
|
|
286
|
+
|
|
287
|
+
render(
|
|
288
|
+
<StoryboardProvider>
|
|
289
|
+
<ContextReader />
|
|
290
|
+
</StoryboardProvider>,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
expect(screen.getByText('Canvas not found')).toBeInTheDocument()
|
|
294
|
+
expect(screen.getByRole('link', { name: /go to index page/i })).toHaveAttribute('href', '/')
|
|
295
|
+
})
|
|
283
296
|
})
|