@dfosco/storyboard-react 4.2.0-beta.26 → 4.2.0-beta.27
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/AuthModal/AuthModal.jsx +0 -5
- package/src/CommandPalette/CommandPalette.jsx +1 -1
- package/src/Viewfinder.jsx +1 -16
- package/src/canvas/CanvasPage.dragdrop.test.jsx +1 -1
- package/src/canvas/CanvasPage.jsx +1 -3
- package/src/canvas/ConnectorLayer.jsx +0 -12
- package/src/canvas/canvasReloadGuard.test.js +1 -1
- package/src/canvas/widgets/ExpandedPane.jsx +4 -5
- package/src/canvas/widgets/ExpandedPane.test.jsx +3 -3
- package/src/canvas/widgets/LinkPreview.jsx +2 -2
- package/src/canvas/widgets/MarkdownBlock.jsx +1 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +0 -1
- package/src/canvas/widgets/StoryWidget.jsx +2 -1
- package/src/canvas/widgets/TerminalWidget.jsx +4 -47
- package/src/canvas/widgets/TilesWidget.jsx +0 -2
- package/src/context.jsx +1 -1
- package/src/hooks/useSceneData.js +1 -0
- package/src/hooks/useThemeState.test.js +1 -1
- package/src/vite/data-plugin.js +79 -90
- package/src/vite/data-plugin.test.js +1 -1
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "4.2.0-beta.
|
|
3
|
+
"version": "4.2.0-beta.27",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@base-ui/react": "^1.4.0",
|
|
7
|
-
"@dfosco/storyboard-core": "4.2.0-beta.
|
|
8
|
-
"@dfosco/tiny-canvas": "4.2.0-beta.
|
|
7
|
+
"@dfosco/storyboard-core": "4.2.0-beta.27",
|
|
8
|
+
"@dfosco/tiny-canvas": "4.2.0-beta.27",
|
|
9
9
|
"@neodrag/react": "^2.3.1",
|
|
10
10
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
11
11
|
"@radix-ui/react-visually-hidden": "^1.2.4",
|
|
@@ -30,11 +30,6 @@ export default function AuthModal() {
|
|
|
30
30
|
return () => document.removeEventListener('storyboard:open-auth-modal', handleOpen)
|
|
31
31
|
}, [])
|
|
32
32
|
|
|
33
|
-
const _handleClose = useCallback(() => {
|
|
34
|
-
setOpen(false)
|
|
35
|
-
setTokenValue('')
|
|
36
|
-
}, [])
|
|
37
|
-
|
|
38
33
|
const handleSignIn = useCallback(() => {
|
|
39
34
|
const trimmed = tokenValue.trim()
|
|
40
35
|
if (!trimmed) return
|
|
@@ -1198,7 +1198,7 @@ export default function StoryboardCommandPalette({ basePath }) {
|
|
|
1198
1198
|
!search && <Command.Separator key={list.id} />
|
|
1199
1199
|
) : (
|
|
1200
1200
|
<Command.Group key={list.id} heading={list.heading}>
|
|
1201
|
-
{list.items.map(({ id, children, keywords, onClick, itemType, toolIcon, toolMeta, closeOnSelect, hideFromSearch, url
|
|
1201
|
+
{list.items.map(({ id, children, keywords, onClick, itemType, toolIcon, toolMeta, closeOnSelect, hideFromSearch, url }) => {
|
|
1202
1202
|
if (search && hideFromSearch) return null
|
|
1203
1203
|
if (hiddenFromSearchIds.size > 0) {
|
|
1204
1204
|
for (const toolId of hiddenFromSearchIds) {
|
package/src/Viewfinder.jsx
CHANGED
|
@@ -56,7 +56,7 @@ const COMMENTS_TOKEN_KEY = 'sb-comments-token'
|
|
|
56
56
|
* Resolve the current GitHub user for display in the sidebar.
|
|
57
57
|
* Priority: 1) PAT-cached user (from comments auth), 2) gh CLI login via git-user endpoint.
|
|
58
58
|
*/
|
|
59
|
-
function useGitHubUser(
|
|
59
|
+
function useGitHubUser() {
|
|
60
60
|
const [user, setUser] = useState(() => {
|
|
61
61
|
try {
|
|
62
62
|
const raw = localStorage.getItem(COMMENTS_USER_KEY)
|
|
@@ -154,16 +154,6 @@ function withBase(basePath, route) {
|
|
|
154
154
|
return `${normalizedBase}${normalizedRoute}`.replace(/\/+/g, '/')
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
/* ─── Thumbnail color from name hash ─── */
|
|
158
|
-
|
|
159
|
-
const THUMB_CLASSES = ['thumbBlue', 'thumbAmber', 'thumbGreen', 'thumbPurple', 'thumbRose', 'thumbSlate']
|
|
160
|
-
|
|
161
|
-
function _thumbClass(name) {
|
|
162
|
-
let h = 0
|
|
163
|
-
for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0
|
|
164
|
-
return css[THUMB_CLASSES[Math.abs(h) % THUMB_CLASSES.length]]
|
|
165
|
-
}
|
|
166
|
-
|
|
167
157
|
/* ─── Type helpers ─── */
|
|
168
158
|
|
|
169
159
|
function getTypeLabel(type) {
|
|
@@ -1078,10 +1068,7 @@ export default function Workspace({
|
|
|
1078
1068
|
basePath,
|
|
1079
1069
|
title = 'Storyboard',
|
|
1080
1070
|
subtitle,
|
|
1081
|
-
hideDefaultFlow,
|
|
1082
|
-
hideDefaultScene = false,
|
|
1083
1071
|
}) {
|
|
1084
|
-
const _shouldHideDefault = hideDefaultFlow ?? hideDefaultScene
|
|
1085
1072
|
const themeAttrs = useToolbarTheme()
|
|
1086
1073
|
const ghUser = useGitHubUser(basePath)
|
|
1087
1074
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
@@ -1291,8 +1278,6 @@ export default function Workspace({
|
|
|
1291
1278
|
// Starred items for sidebar
|
|
1292
1279
|
const starredItems = useMemo(() => visibleItems.filter(i => starred.has(i.id)), [visibleItems, starred])
|
|
1293
1280
|
|
|
1294
|
-
const _pageTitle = NAV_ITEMS.find(n => n.id === activeNav)?.label || 'All artifacts'
|
|
1295
|
-
|
|
1296
1281
|
return (
|
|
1297
1282
|
<div className={css.layout} {...themeAttrs}>
|
|
1298
1283
|
{/* ─── Full-width Header ─── */}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* onto the canvas, including coordinate conversion and file filtering.
|
|
6
6
|
*/
|
|
7
7
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
8
|
-
import {
|
|
8
|
+
import { render, act } from '@testing-library/react'
|
|
9
9
|
import CanvasPage from './CanvasPage.jsx'
|
|
10
10
|
import { addWidget, uploadImage } from './canvasApi.js'
|
|
11
11
|
|
|
@@ -57,9 +57,6 @@ const GH_INSTALL_URL = 'https://github.com/cli/cli'
|
|
|
57
57
|
registerSmoothCorners()
|
|
58
58
|
registerHotPoolDevLogs()
|
|
59
59
|
|
|
60
|
-
/** Matches branch-deploy base path prefixes like /branch--my-feature/ */
|
|
61
|
-
const _BRANCH_PREFIX_RE = /^\/branch--[^/]+/
|
|
62
|
-
|
|
63
60
|
// Build a reverse map from story route paths → { storyId, route }
|
|
64
61
|
const storyRouteIndex = new Map()
|
|
65
62
|
for (const [storyId, data] of Object.entries(storyIndex || {})) {
|
|
@@ -415,6 +412,7 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
415
412
|
return adjusted
|
|
416
413
|
}, [rawFeatures, widget.props?.github, widget.props?.collapsed, widget.type, widget.id, connectorCount, allWidgets])
|
|
417
414
|
|
|
415
|
+
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
|
418
416
|
const handleAction = useCallback((actionId, opts) => {
|
|
419
417
|
if (actionId === 'delete') {
|
|
420
418
|
onRemove?.(widget.id)
|
|
@@ -19,18 +19,6 @@ function getEndpointStyle(widgetType, side) {
|
|
|
19
19
|
return connectorConfig[key]
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
const DOT_OUTSET = 8
|
|
23
|
-
|
|
24
|
-
function _getDotOffset(anchor) {
|
|
25
|
-
switch (anchor) {
|
|
26
|
-
case 'top': return { dx: 0, dy: -DOT_OUTSET }
|
|
27
|
-
case 'bottom': return { dx: 0, dy: DOT_OUTSET }
|
|
28
|
-
case 'left': return { dx: -DOT_OUTSET, dy: 0 }
|
|
29
|
-
case 'right': return { dx: DOT_OUTSET, dy: 0 }
|
|
30
|
-
default: return { dx: 0, dy: 0 }
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
22
|
/**
|
|
35
23
|
* Render an endpoint shape (circle, arrow-start, arrow-end, or none) at the given point.
|
|
36
24
|
* - "circle" (default): filled dot
|
|
@@ -56,7 +56,7 @@ const MIN_PANE_HEIGHT_PX = 80
|
|
|
56
56
|
* @param {() => void} props.onClose — close callback
|
|
57
57
|
* @param {((panes: PaneConfig[]) => void)} [props.onPanesChange] — notify parent of pane changes
|
|
58
58
|
*/
|
|
59
|
-
export default function ExpandedPane({ initialPanes, initialLayout, variant = 'modal', onClose
|
|
59
|
+
export default function ExpandedPane({ initialPanes, initialLayout, variant = 'modal', onClose }) {
|
|
60
60
|
// Normalize to 2D layout: outer = columns, inner = rows
|
|
61
61
|
const [layout, setLayout] = useState(() => {
|
|
62
62
|
if (initialLayout) return initialLayout
|
|
@@ -85,7 +85,7 @@ export default function ExpandedPane({ initialPanes, initialLayout, variant = 'm
|
|
|
85
85
|
const [rowRatios, setRowRatios] = useState(() =>
|
|
86
86
|
layout.map((col) => col.map(() => 1)),
|
|
87
87
|
)
|
|
88
|
-
const [
|
|
88
|
+
const [, setActivePaneId] = useState(null)
|
|
89
89
|
|
|
90
90
|
// Ref map: paneId → container DOM element (callback refs)
|
|
91
91
|
const containerRefs = useRef(new Map())
|
|
@@ -96,7 +96,6 @@ export default function ExpandedPane({ initialPanes, initialLayout, variant = 'm
|
|
|
96
96
|
|
|
97
97
|
const totalPanes = allPanes.length
|
|
98
98
|
const isSplit = totalPanes >= 2
|
|
99
|
-
const useFullLayout = isSplit || variant === 'full'
|
|
100
99
|
|
|
101
100
|
// ── External pane attach/detach via useLayoutEffect ──
|
|
102
101
|
useLayoutEffect(() => {
|
|
@@ -109,7 +108,7 @@ export default function ExpandedPane({ initialPanes, initialLayout, variant = 'm
|
|
|
109
108
|
detachRefs.current.set(pane.id, detach)
|
|
110
109
|
}
|
|
111
110
|
return () => {
|
|
112
|
-
for (const [
|
|
111
|
+
for (const [, detach] of detachRefs.current) {
|
|
113
112
|
detach?.()
|
|
114
113
|
}
|
|
115
114
|
detachRefs.current.clear()
|
|
@@ -456,7 +455,7 @@ function ColumnWithDivider({ colIndex, isLast, onDividerPointerDown, children })
|
|
|
456
455
|
/**
|
|
457
456
|
* Renders a pane within a row-split column, optionally followed by a horizontal row divider.
|
|
458
457
|
*/
|
|
459
|
-
function PaneWithRowDivider({
|
|
458
|
+
function PaneWithRowDivider({ flex, isLast, colIndex, onRowDividerPointerDown, children }) {
|
|
460
459
|
return (
|
|
461
460
|
<>
|
|
462
461
|
<div className={styles.pane} style={{ flex }}>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, it, expect, vi
|
|
2
|
-
import { render, screen, fireEvent
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
3
|
import ExpandedPane from './ExpandedPane.jsx'
|
|
4
4
|
|
|
5
5
|
// Mock createPortal to render inline for testing
|
|
@@ -135,7 +135,7 @@ describe('ExpandedPane', () => {
|
|
|
135
135
|
|
|
136
136
|
describe('external pane attach/detach', () => {
|
|
137
137
|
it('calls attach with container element on mount', async () => {
|
|
138
|
-
const { pane, attach
|
|
138
|
+
const { pane, attach } = makeExternalPane('term-1')
|
|
139
139
|
render(<ExpandedPane initialPanes={[pane]} variant="full" onClose={vi.fn()} />)
|
|
140
140
|
// useLayoutEffect runs synchronously in test
|
|
141
141
|
expect(attach).toHaveBeenCalledOnce()
|
|
@@ -7,10 +7,9 @@ import WidgetWrapper from './WidgetWrapper.jsx'
|
|
|
7
7
|
import ResizeHandle from './ResizeHandle.jsx'
|
|
8
8
|
import { readProp, linkPreviewSchema } from './widgetProps.js'
|
|
9
9
|
import ExpandedPane from './ExpandedPane.jsx'
|
|
10
|
-
import { findAllConnectedSplitTargets,
|
|
10
|
+
import { findAllConnectedSplitTargets, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
|
|
11
11
|
import styles from './LinkPreview.module.css'
|
|
12
12
|
|
|
13
|
-
const VIDEO_EXT_RE = /\.(mp4|mov|webm|ogg)(\?[^)]*)?$/i
|
|
14
13
|
const VIDEO_URL_LINE_RE = /^<p>\s*(https?:\/\/[^\s<]+\.(mp4|mov|webm|ogg)(?:\?[^\s<]*)?)\s*<\/p>$/gim
|
|
15
14
|
|
|
16
15
|
/**
|
|
@@ -119,6 +118,7 @@ function GitHubIssueCard({ id, url, title, github, width, collapsed, expanded, e
|
|
|
119
118
|
const kindLabel = getCommentKindLabel(github)
|
|
120
119
|
|
|
121
120
|
// Prefer pre-rendered bodyHtml (has signed image URLs), fall back to remark for discussions
|
|
121
|
+
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
|
122
122
|
const bodyHtml = useMemo(() => {
|
|
123
123
|
if (github?.bodyHtml) return postProcessHtml(github.bodyHtml)
|
|
124
124
|
return renderMarkdown(github?.body || '')
|
|
@@ -310,6 +310,7 @@ export function ExpandedMarkdownEditor({ content, onUpdate, editing, onToggleEdi
|
|
|
310
310
|
const [renderedHtml, setRenderedHtml] = useState(rawHtml)
|
|
311
311
|
|
|
312
312
|
useEffect(() => {
|
|
313
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
313
314
|
setRenderedHtml(rawHtml)
|
|
314
315
|
if (!rawHtml.includes('<code class="language-')) return
|
|
315
316
|
let cancelled = false
|
|
@@ -61,7 +61,6 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
61
61
|
}, [src, basePath, baseSegment])
|
|
62
62
|
|
|
63
63
|
const scale = zoom / 100
|
|
64
|
-
const isExternal = /^https?:\/\//.test(src || '')
|
|
65
64
|
|
|
66
65
|
const [editing, setEditing] = useState(false)
|
|
67
66
|
const [interactive, setInteractive] = useState(false)
|
|
@@ -105,6 +105,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
105
105
|
if (!showCode || sourceCode !== null) return
|
|
106
106
|
const story = getStoryData(storyId)
|
|
107
107
|
if (!story?._storyModule) {
|
|
108
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
108
109
|
setSourceCode('// Source not available')
|
|
109
110
|
return
|
|
110
111
|
}
|
|
@@ -313,7 +314,7 @@ function StoryExpandPane({ widgetId, storyId, exportName, splitMode, onClose })
|
|
|
313
314
|
kind: 'react',
|
|
314
315
|
render: () => url
|
|
315
316
|
? <iframe src={url} style={{ border: 'none', width: '100%', height: '100%', display: 'block' }} title={storyId} onLoad={(e) => e.target.blur()} />
|
|
316
|
-
: <div style={{ padding: 32, color: 'var(--fgColor-muted)' }}>Story
|
|
317
|
+
: <div style={{ padding: 32, color: 'var(--fgColor-muted)' }}>Story "{storyId}" not found</div>,
|
|
317
318
|
}
|
|
318
319
|
}
|
|
319
320
|
return buildPaneForWidget(widget)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useRef, useEffect, useCallback, useState, useMemo, forwardRef, useImperativeHandle } from 'react'
|
|
2
2
|
import { readProp } from './widgetProps.js'
|
|
3
3
|
import { schemas } from './widgetProps.js'
|
|
4
|
-
import { getTerminalConfig, getTerminalDimensions
|
|
4
|
+
import { getTerminalConfig, getTerminalDimensions } from '@dfosco/storyboard-core'
|
|
5
5
|
import { useOverride } from '../../hooks/useOverride.js'
|
|
6
6
|
import { getSplitPaneLabel, findAllConnectedSplitTargets, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
|
|
7
7
|
import ExpandedPane from './ExpandedPane.jsx'
|
|
@@ -79,46 +79,6 @@ function fitTerminalToElement(widgetId, containerEl) {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
const EMBED_TYPES = new Set(['prototype', 'story'])
|
|
83
|
-
|
|
84
|
-
function findConnectedEmbed(widgetId) {
|
|
85
|
-
const bridge = window.__storyboardCanvasBridgeState
|
|
86
|
-
if (!bridge?.connectors || !bridge?.widgets) return null
|
|
87
|
-
const connectedIds = new Set()
|
|
88
|
-
for (const c of bridge.connectors) {
|
|
89
|
-
if (c.start?.widgetId === widgetId) connectedIds.add(c.end?.widgetId)
|
|
90
|
-
if (c.end?.widgetId === widgetId) connectedIds.add(c.start?.widgetId)
|
|
91
|
-
}
|
|
92
|
-
for (const w of bridge.widgets) {
|
|
93
|
-
if (connectedIds.has(w.id) && EMBED_TYPES.has(w.type)) return w
|
|
94
|
-
}
|
|
95
|
-
return null
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function buildEmbedUrl(widget) {
|
|
99
|
-
if (!widget) return null
|
|
100
|
-
const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
|
|
101
|
-
const baseClean = base.endsWith('/') ? base.slice(0, -1) : base
|
|
102
|
-
if (widget.type === 'prototype') {
|
|
103
|
-
const src = widget.props?.src
|
|
104
|
-
if (!src) return null
|
|
105
|
-
if (/^https?:\/\//.test(src)) return src
|
|
106
|
-
return `${baseClean}${src.startsWith('/') ? '' : '/'}${src}?_sb_embed&_sb_hide_branch_bar`
|
|
107
|
-
}
|
|
108
|
-
if (widget.type === 'story') {
|
|
109
|
-
const storyId = widget.props?.storyId
|
|
110
|
-
const exportName = widget.props?.exportName
|
|
111
|
-
if (!storyId) return null
|
|
112
|
-
const storyData = getStoryData(storyId)
|
|
113
|
-
if (storyData?._route) {
|
|
114
|
-
const route = exportName ? `${storyData._route}?export=${exportName}` : storyData._route
|
|
115
|
-
return `${baseClean}${route}`
|
|
116
|
-
}
|
|
117
|
-
return null
|
|
118
|
-
}
|
|
119
|
-
return null
|
|
120
|
-
}
|
|
121
|
-
|
|
122
82
|
const DEFAULT_THEME = {
|
|
123
83
|
background: '#0d1117',
|
|
124
84
|
foreground: '#e6edf3',
|
|
@@ -142,7 +102,7 @@ const DEFAULT_THEME = {
|
|
|
142
102
|
brightWhite: '#f0f6fc',
|
|
143
103
|
}
|
|
144
104
|
|
|
145
|
-
export default forwardRef(function TerminalWidget({ id, props, onUpdate,
|
|
105
|
+
export default forwardRef(function TerminalWidget({ id, props, onUpdate, multiSelected }, ref) {
|
|
146
106
|
const cfg = getTerminalConfig()
|
|
147
107
|
const fontSize = cfg.fontSize ?? 13
|
|
148
108
|
const agentId = props?.agentId || null
|
|
@@ -155,11 +115,6 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
|
|
|
155
115
|
const prettyName = props?.prettyName || null
|
|
156
116
|
const startupCommand = props?.startupCommand || null
|
|
157
117
|
|
|
158
|
-
// Snap dimensions to cell grid so the terminal fills its container exactly
|
|
159
|
-
const { cols, rows } = useMemo(
|
|
160
|
-
() => calcDimensions(rawWidth, rawHeight, fontSize),
|
|
161
|
-
[rawWidth, rawHeight, fontSize],
|
|
162
|
-
)
|
|
163
118
|
const width = rawWidth
|
|
164
119
|
const height = rawHeight
|
|
165
120
|
// Snapped dimensions computed from ghostty's actual cell metrics (set after open)
|
|
@@ -220,6 +175,7 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
|
|
|
220
175
|
|
|
221
176
|
// Exit interactive when terminal becomes part of a multi-selection
|
|
222
177
|
useEffect(() => {
|
|
178
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
223
179
|
if (multiSelected && interactive) setInteractive(false)
|
|
224
180
|
}, [multiSelected])
|
|
225
181
|
|
|
@@ -385,6 +341,7 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
|
|
|
385
341
|
// Reveal mask — hide terminal for 750ms after ready to mask startup flash
|
|
386
342
|
useEffect(() => {
|
|
387
343
|
if (!ready) {
|
|
344
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
388
345
|
setRevealed(false)
|
|
389
346
|
return
|
|
390
347
|
}
|
|
@@ -47,8 +47,6 @@ const TilesWidget = forwardRef(function TilesWidget({ id, props, onUpdate, resiz
|
|
|
47
47
|
const columns = (isProd ? localState?.columns : null) ?? (readProp(props, 'columns', tilesSchema) || 3)
|
|
48
48
|
const rows = (isProd ? localState?.rows : null) ?? (readProp(props, 'rows', tilesSchema) || 3)
|
|
49
49
|
const tileSize = readProp(props, 'tileSize', tilesSchema) || 80
|
|
50
|
-
const width = readProp(props, 'width', tilesSchema)
|
|
51
|
-
const height = readProp(props, 'height', tilesSchema)
|
|
52
50
|
const savedTiles = (isProd ? localState?.tiles : null) ?? readProp(props, 'tiles', tilesSchema)
|
|
53
51
|
|
|
54
52
|
// Local state for interactions
|
package/src/context.jsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect, useMemo, Suspense, lazy } from 'react'
|
|
2
2
|
import { useParams, useLocation } from 'react-router-dom'
|
|
3
3
|
// Named import seeds the core data index via init() AND provides canvas/story route data
|
|
4
|
-
import { canvases,
|
|
4
|
+
import { canvases, stories } from 'virtual:storyboard-data-index'
|
|
5
5
|
import { loadFlow, flowExists, findRecord, deepMerge, setFlowClass, installBodyClassSync, resolveFlowName, resolveRecordName, isModesEnabled } from '@dfosco/storyboard-core'
|
|
6
6
|
import { StoryboardContext } from './StoryboardContext.js'
|
|
7
7
|
import usePrototypeReloadGuard from './hooks/usePrototypeReloadGuard.js'
|
|
@@ -36,6 +36,7 @@ export function useFlowData(path, opts) {
|
|
|
36
36
|
const storageString = useSyncExternalStore(subscribeToStorage, getStorageSnapshot)
|
|
37
37
|
|
|
38
38
|
// Collect overrides relevant to this path
|
|
39
|
+
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
|
39
40
|
const result = useMemo(() => {
|
|
40
41
|
if (loading || error || data == null) return undefined
|
|
41
42
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { renderHook, act } from '@testing-library/react'
|
|
2
|
-
import { describe, it, expect, beforeEach
|
|
2
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
3
3
|
import { setTheme, setThemeSyncTarget } from '@dfosco/storyboard-core'
|
|
4
4
|
import { useThemeState, useThemeSyncTargets } from './useThemeState.js'
|
|
5
5
|
|
package/src/vite/data-plugin.js
CHANGED
|
@@ -49,10 +49,6 @@ function parseDataFile(filePath) {
|
|
|
49
49
|
const canvasCheck = normalized.match(/(?:^|\/)src\/canvas\//)
|
|
50
50
|
if (canvasCheck) {
|
|
51
51
|
const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
|
|
52
|
-
const routeBase = (dirPath + '/')
|
|
53
|
-
.replace(/^.*?src\/canvas\//, '')
|
|
54
|
-
.replace(/[^/]*\.folder\/?/g, '')
|
|
55
|
-
.replace(/\/$/, '')
|
|
56
52
|
// Path-based ID: include folder context for uniqueness.
|
|
57
53
|
// .folder dirs contribute their name (sans .folder suffix) to the ID.
|
|
58
54
|
const idBase = (dirPath + '/')
|
|
@@ -192,39 +188,6 @@ function parseDataFile(filePath) {
|
|
|
192
188
|
return { name, suffix, ext: match[3], inferredRoute }
|
|
193
189
|
}
|
|
194
190
|
|
|
195
|
-
/**
|
|
196
|
-
* Look up the git author who first created a file.
|
|
197
|
-
* Used to auto-fill the author field in .prototype.json when missing.
|
|
198
|
-
*/
|
|
199
|
-
function getGitAuthor(root, filePath) {
|
|
200
|
-
try {
|
|
201
|
-
const result = execSync(
|
|
202
|
-
`git log --follow --diff-filter=A --format="%aN" -- "${filePath}"`,
|
|
203
|
-
{ cwd: root, encoding: 'utf-8', timeout: 5000 },
|
|
204
|
-
).trim()
|
|
205
|
-
const lines = result.split('\n').filter(Boolean)
|
|
206
|
-
return lines.length > 0 ? lines[lines.length - 1] : null
|
|
207
|
-
} catch {
|
|
208
|
-
return null
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Look up the most recent commit date for any file in a directory.
|
|
214
|
-
* Returns an ISO 8601 timestamp, or null if unavailable.
|
|
215
|
-
*/
|
|
216
|
-
function getLastModified(root, dirPath) {
|
|
217
|
-
try {
|
|
218
|
-
const result = execSync(
|
|
219
|
-
`git log -1 --format="%aI" -- "${dirPath}"`,
|
|
220
|
-
{ cwd: root, encoding: 'utf-8', timeout: 5000 },
|
|
221
|
-
).trim()
|
|
222
|
-
return result || null
|
|
223
|
-
} catch {
|
|
224
|
-
return null
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
191
|
/**
|
|
229
192
|
* Batch-fetch git metadata (author + lastModified) for multiple files in a
|
|
230
193
|
* single subprocess, avoiding per-file git overhead during startup.
|
|
@@ -581,84 +544,104 @@ function deepMergeBuild(target, source) {
|
|
|
581
544
|
* Build the unified config object by reading and merging all config sources.
|
|
582
545
|
*
|
|
583
546
|
* Priority (lowest → highest):
|
|
584
|
-
*
|
|
547
|
+
* configSchema defaults → core domain configs → storyboard.config.json → user domain configs
|
|
548
|
+
*
|
|
549
|
+
* Domain-specific config files (toolbar.config.json, commandpalette.config.json, etc.)
|
|
550
|
+
* always win over storyboard.config.json — specificity beats generality.
|
|
551
|
+
* Deep merge is used at every layer: objects are recursively merged (keys append),
|
|
552
|
+
* arrays and scalars are replaced.
|
|
585
553
|
*
|
|
586
554
|
* Returns { unified, warnings } where warnings is an array of overlap messages.
|
|
587
555
|
*/
|
|
588
556
|
function buildUnifiedConfig(root) {
|
|
589
557
|
const warnings = []
|
|
590
558
|
|
|
591
|
-
// 1. Read core defaults
|
|
559
|
+
// 1. Read core defaults (lowest priority domain configs)
|
|
592
560
|
const coreToolbar = readCoreConfigFile(root, 'toolbar.config.json') || {}
|
|
593
561
|
const coreCommandPalette = readCoreConfigFile(root, 'commandpalette.config.json') || {}
|
|
594
562
|
const corePaste = readCoreConfigFile(root, 'paste.config.json') || {}
|
|
595
563
|
const coreWidgets = readCoreConfigFile(root, 'widgets.config.json') || {}
|
|
596
564
|
|
|
597
|
-
// 2. Read
|
|
598
|
-
const userFiles = [
|
|
599
|
-
{ domain: 'widgets', filename: 'widgets.config.json', priority: 1 },
|
|
600
|
-
{ domain: 'paste', filename: 'paste.config.json', priority: 2 },
|
|
601
|
-
{ domain: 'toolbar', filename: 'toolbar.config.json', priority: 3 },
|
|
602
|
-
{ domain: 'commandPalette', filename: 'commandpalette.config.json', priority: 4 },
|
|
603
|
-
]
|
|
604
|
-
|
|
605
|
-
const userConfigs = {}
|
|
606
|
-
for (const { domain, filename } of userFiles) {
|
|
607
|
-
const filePath = path.resolve(root, filename)
|
|
608
|
-
const parsed = readJsonFile(filePath)
|
|
609
|
-
if (parsed) userConfigs[domain] = { data: parsed, filename }
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
// 3. Read storyboard.config.json (highest priority)
|
|
565
|
+
// 2. Read storyboard.config.json (middle priority)
|
|
613
566
|
// Use the schema-defaulted config for most things, but also read
|
|
614
567
|
// the raw file to know which keys were explicitly set by the user.
|
|
615
568
|
const { config: sbConfig } = readConfig(root)
|
|
616
569
|
const rawSbConfig = readJsonFile(path.resolve(root, 'storyboard.config.json')) || {}
|
|
617
570
|
|
|
618
|
-
//
|
|
619
|
-
|
|
620
|
-
|
|
571
|
+
// 3. Apply storyboard.config.json overrides on top of core domain configs.
|
|
572
|
+
// Only merge when the user explicitly defined the key in storyboard.config.json
|
|
573
|
+
// (not from configSchema defaults, which would overwrite core config with empty arrays).
|
|
574
|
+
const afterSbToolbar = rawSbConfig.toolbar
|
|
575
|
+
? deepMergeBuild(coreToolbar, sbConfig.toolbar)
|
|
621
576
|
: coreToolbar
|
|
622
|
-
const
|
|
623
|
-
? deepMergeBuild(coreCommandPalette,
|
|
577
|
+
const afterSbCommandPalette = rawSbConfig.commandPalette
|
|
578
|
+
? deepMergeBuild(coreCommandPalette, sbConfig.commandPalette)
|
|
624
579
|
: coreCommandPalette
|
|
625
|
-
const
|
|
626
|
-
? deepMergeBuild(corePaste,
|
|
580
|
+
const afterSbPaste = rawSbConfig.paste
|
|
581
|
+
? deepMergeBuild(corePaste, sbConfig.paste || {})
|
|
627
582
|
: corePaste
|
|
628
|
-
const
|
|
629
|
-
? deepMergeBuild(coreWidgets,
|
|
583
|
+
const afterSbWidgets = rawSbConfig.widgets
|
|
584
|
+
? deepMergeBuild(coreWidgets, sbConfig.widgets || {})
|
|
630
585
|
: coreWidgets
|
|
631
586
|
|
|
632
|
-
//
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
:
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
for (const key of overlaps) {
|
|
646
|
-
warnings.push(`Config overlap: "${key}" is defined in both toolbar.config.json and storyboard.config.json.toolbar — storyboard.config.json wins.`)
|
|
647
|
-
}
|
|
587
|
+
// 4. Read user domain config files (highest priority)
|
|
588
|
+
const userFiles = [
|
|
589
|
+
{ domain: 'widgets', filename: 'widgets.config.json' },
|
|
590
|
+
{ domain: 'paste', filename: 'paste.config.json' },
|
|
591
|
+
{ domain: 'toolbar', filename: 'toolbar.config.json' },
|
|
592
|
+
{ domain: 'commandPalette', filename: 'commandpalette.config.json' },
|
|
593
|
+
]
|
|
594
|
+
|
|
595
|
+
const userConfigs = {}
|
|
596
|
+
for (const { domain, filename } of userFiles) {
|
|
597
|
+
const filePath = path.resolve(root, filename)
|
|
598
|
+
const parsed = readJsonFile(filePath)
|
|
599
|
+
if (parsed) userConfigs[domain] = { data: parsed, filename }
|
|
648
600
|
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
601
|
+
|
|
602
|
+
// 5. Apply user domain configs on top of everything (highest priority)
|
|
603
|
+
const finalToolbar = userConfigs.toolbar
|
|
604
|
+
? deepMergeBuild(afterSbToolbar, userConfigs.toolbar.data)
|
|
605
|
+
: afterSbToolbar
|
|
606
|
+
const finalCommandPalette = userConfigs.commandPalette
|
|
607
|
+
? deepMergeBuild(afterSbCommandPalette, userConfigs.commandPalette.data)
|
|
608
|
+
: afterSbCommandPalette
|
|
609
|
+
const finalPaste = userConfigs.paste
|
|
610
|
+
? deepMergeBuild(afterSbPaste, userConfigs.paste.data)
|
|
611
|
+
: afterSbPaste
|
|
612
|
+
const finalWidgets = userConfigs.widgets
|
|
613
|
+
? deepMergeBuild(afterSbWidgets, userConfigs.widgets.data)
|
|
614
|
+
: afterSbWidgets
|
|
615
|
+
|
|
616
|
+
// 6. Detect overlaps between storyboard.config.json and user domain configs
|
|
617
|
+
const domainOverlapChecks = [
|
|
618
|
+
{ sbKey: 'toolbar', domain: 'toolbar', label: 'toolbar.config.json' },
|
|
619
|
+
{ sbKey: 'commandPalette', domain: 'commandPalette', label: 'commandpalette.config.json' },
|
|
620
|
+
{ sbKey: 'paste', domain: 'paste', label: 'paste.config.json' },
|
|
621
|
+
{ sbKey: 'widgets', domain: 'widgets', label: 'widgets.config.json' },
|
|
622
|
+
]
|
|
623
|
+
for (const { sbKey, domain, label } of domainOverlapChecks) {
|
|
624
|
+
if (rawSbConfig[sbKey] && userConfigs[domain]) {
|
|
625
|
+
const overlaps = findOverlappingKeys(rawSbConfig[sbKey], userConfigs[domain].data)
|
|
626
|
+
for (const key of overlaps) {
|
|
627
|
+
warnings.push(`Config overlap: "${key}" is defined in both storyboard.config.json.${sbKey} and ${label} — ${label} wins.`)
|
|
628
|
+
}
|
|
653
629
|
}
|
|
654
630
|
}
|
|
655
631
|
|
|
656
632
|
// 7. Build the unified config object
|
|
633
|
+
console.log('[storyboard] [devlog] buildUnifiedConfig:', {
|
|
634
|
+
coreCPSections: coreCommandPalette?.sections?.length,
|
|
635
|
+
afterSbCPSections: afterSbCommandPalette?.sections?.length,
|
|
636
|
+
finalCPSections: finalCommandPalette?.sections?.length,
|
|
637
|
+
rawSbHasCP: !!rawSbConfig.commandPalette,
|
|
638
|
+
userHasCP: !!userConfigs.commandPalette,
|
|
639
|
+
})
|
|
657
640
|
const unified = {
|
|
658
641
|
toolbar: finalToolbar,
|
|
659
642
|
commandPalette: finalCommandPalette,
|
|
660
|
-
paste,
|
|
661
|
-
widgets,
|
|
643
|
+
paste: finalPaste,
|
|
644
|
+
widgets: finalWidgets,
|
|
662
645
|
featureFlags: sbConfig?.featureFlags || {},
|
|
663
646
|
modes: sbConfig?.modes || {},
|
|
664
647
|
ui: sbConfig?.ui || {},
|
|
@@ -1328,13 +1311,19 @@ export default function storyboardDataPlugin() {
|
|
|
1328
1311
|
const { configPath } = readConfig(root)
|
|
1329
1312
|
watcher.add(configPath)
|
|
1330
1313
|
|
|
1331
|
-
// Watch root
|
|
1332
|
-
const
|
|
1333
|
-
|
|
1314
|
+
// Watch all root domain config files for changes
|
|
1315
|
+
const domainConfigFiles = [
|
|
1316
|
+
'toolbar.config.json',
|
|
1317
|
+
'commandpalette.config.json',
|
|
1318
|
+
'paste.config.json',
|
|
1319
|
+
'widgets.config.json',
|
|
1320
|
+
].map(f => path.resolve(root, f))
|
|
1321
|
+
const watchedConfigPaths = new Set([configPath, ...domainConfigFiles])
|
|
1322
|
+
for (const p of domainConfigFiles) watcher.add(p)
|
|
1334
1323
|
|
|
1335
1324
|
const invalidateConfig = (filePath) => {
|
|
1336
1325
|
const resolved = path.resolve(filePath)
|
|
1337
|
-
if (resolved
|
|
1326
|
+
if (watchedConfigPaths.has(resolved)) {
|
|
1338
1327
|
buildResult = null
|
|
1339
1328
|
const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
|
|
1340
1329
|
if (mod) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdtempSync, writeFileSync, mkdirSync, rmSync
|
|
1
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import storyboardDataPlugin, { resolveTemplateVars, computeTemplateVars, parseDataFile } from './data-plugin.js'
|