@dfosco/storyboard-react 4.0.0-beta.27 → 4.0.0-beta.29
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/canvas/CanvasPage.bridge.test.jsx +87 -2
- package/src/canvas/CanvasPage.jsx +152 -9
- package/src/canvas/CanvasPage.module.css +54 -0
- package/src/canvas/CanvasPage.multiselect.test.jsx +2 -0
- package/src/canvas/canvasApi.js +8 -0
- package/src/canvas/widgets/CodePenEmbed.jsx +291 -0
- package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +42 -7
- package/src/canvas/widgets/FigmaEmbed.module.css +21 -0
- package/src/canvas/widgets/LinkPreview.jsx +247 -18
- package/src/canvas/widgets/LinkPreview.module.css +349 -8
- package/src/canvas/widgets/LinkPreview.test.jsx +71 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +2 -1
- package/src/canvas/widgets/MarkdownBlock.module.css +34 -11
- package/src/canvas/widgets/PrototypeEmbed.jsx +101 -44
- package/src/canvas/widgets/PrototypeEmbed.module.css +1 -0
- package/src/canvas/widgets/StoryWidget.jsx +86 -42
- package/src/canvas/widgets/StoryWidget.module.css +1 -0
- package/src/canvas/widgets/WidgetChrome.jsx +20 -1
- package/src/canvas/widgets/codepenUrl.js +75 -0
- package/src/canvas/widgets/codepenUrl.test.js +76 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +16 -18
- package/src/canvas/widgets/embedTheme.js +37 -1
- package/src/canvas/widgets/githubUrl.js +82 -0
- package/src/canvas/widgets/githubUrl.test.js +74 -0
- package/src/canvas/widgets/index.js +2 -0
- package/src/canvas/widgets/refreshQueue.js +108 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +60 -90
- package/src/canvas/widgets/useSnapshotCapture.js +38 -139
- package/src/canvas/widgets/useSnapshotCapture.test.jsx +30 -91
- package/src/canvas/widgets/widgetConfig.js +1 -1
- package/src/canvas/widgets/widgetConfig.test.js +1 -1
- package/src/story/StoryPage.jsx +25 -60
- package/src/story/StoryPage.module.css +0 -55
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.29",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "4.0.0-beta.
|
|
7
|
-
"@dfosco/tiny-canvas": "4.0.0-beta.
|
|
6
|
+
"@dfosco/storyboard-core": "4.0.0-beta.29",
|
|
7
|
+
"@dfosco/tiny-canvas": "4.0.0-beta.29",
|
|
8
8
|
"@neodrag/react": "^2.3.1",
|
|
9
9
|
"glob": "^11.0.0",
|
|
10
10
|
"jsonc-parser": "^3.3.1",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { fireEvent, render, screen, act } from '@testing-library/react'
|
|
1
|
+
import { fireEvent, render, screen, act, waitFor } from '@testing-library/react'
|
|
2
2
|
import CanvasPage from './CanvasPage.jsx'
|
|
3
3
|
import { getCanvasPrimerAttrs, getCanvasThemeVars } from './canvasTheme.js'
|
|
4
|
-
import { updateCanvas } from './canvasApi.js'
|
|
4
|
+
import { addWidget, checkGitHubCliAvailable, fetchGitHubEmbed, updateCanvas } from './canvasApi.js'
|
|
5
5
|
|
|
6
6
|
vi.mock('@dfosco/tiny-canvas', () => ({
|
|
7
7
|
Canvas: ({ children, onDragEnd }) => (
|
|
@@ -77,6 +77,8 @@ vi.mock('./widgets/figmaUrl.js', () => ({
|
|
|
77
77
|
|
|
78
78
|
vi.mock('./canvasApi.js', () => ({
|
|
79
79
|
addWidget: vi.fn(),
|
|
80
|
+
checkGitHubCliAvailable: vi.fn(),
|
|
81
|
+
fetchGitHubEmbed: vi.fn(),
|
|
80
82
|
updateCanvas: vi.fn(() => Promise.resolve({ success: true })),
|
|
81
83
|
removeWidget: vi.fn(),
|
|
82
84
|
uploadImage: vi.fn(),
|
|
@@ -94,9 +96,26 @@ vi.mock('./useUndoRedo.js', () => ({
|
|
|
94
96
|
}))
|
|
95
97
|
|
|
96
98
|
describe('CanvasPage canvas bridge', () => {
|
|
99
|
+
function dispatchTextPaste(text) {
|
|
100
|
+
const event = new Event('paste', { bubbles: true, cancelable: true })
|
|
101
|
+
Object.defineProperty(event, 'clipboardData', {
|
|
102
|
+
value: {
|
|
103
|
+
getData: (type) => (type === 'text/plain' ? text : ''),
|
|
104
|
+
items: [],
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
document.dispatchEvent(event)
|
|
108
|
+
}
|
|
109
|
+
|
|
97
110
|
beforeEach(() => {
|
|
98
111
|
delete window.__storyboardCanvasBridgeState
|
|
99
112
|
vi.clearAllMocks()
|
|
113
|
+
addWidget.mockResolvedValue({
|
|
114
|
+
success: true,
|
|
115
|
+
widget: { id: 'widget-link', type: 'link-preview', position: { x: 0, y: 0 }, props: {} },
|
|
116
|
+
})
|
|
117
|
+
checkGitHubCliAvailable.mockResolvedValue({ available: true })
|
|
118
|
+
fetchGitHubEmbed.mockResolvedValue({ success: false })
|
|
100
119
|
})
|
|
101
120
|
|
|
102
121
|
it('publishes bridge state and responds to status requests', () => {
|
|
@@ -145,6 +164,72 @@ describe('CanvasPage canvas bridge', () => {
|
|
|
145
164
|
document.removeEventListener('storyboard:canvas:unmounted', unmountedHandler)
|
|
146
165
|
})
|
|
147
166
|
|
|
167
|
+
it('shows gh install banner when gh is unavailable during GitHub URL paste', async () => {
|
|
168
|
+
checkGitHubCliAvailable.mockResolvedValue({
|
|
169
|
+
available: false,
|
|
170
|
+
installUrl: 'https://github.com/cli/cli',
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
render(<CanvasPage name="design-overview" />)
|
|
174
|
+
|
|
175
|
+
await act(async () => {
|
|
176
|
+
dispatchTextPaste('https://github.com/dfosco/storyboard/issues/42')
|
|
177
|
+
await Promise.resolve()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
await waitFor(() => {
|
|
181
|
+
expect(addWidget).toHaveBeenCalled()
|
|
182
|
+
})
|
|
183
|
+
expect(fetchGitHubEmbed).not.toHaveBeenCalled()
|
|
184
|
+
expect(screen.getByRole('link', { name: 'Install GitHub CLI' })).toHaveAttribute(
|
|
185
|
+
'href',
|
|
186
|
+
'https://github.com/cli/cli',
|
|
187
|
+
)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('hydrates GitHub metadata when gh is available during paste', async () => {
|
|
191
|
+
checkGitHubCliAvailable.mockResolvedValue({ available: true })
|
|
192
|
+
fetchGitHubEmbed.mockResolvedValue({
|
|
193
|
+
success: true,
|
|
194
|
+
snapshot: {
|
|
195
|
+
kind: 'issue',
|
|
196
|
+
parentKind: 'issue',
|
|
197
|
+
context: 'GitHub · dfosco/storyboard · Issue #42',
|
|
198
|
+
title: '#42 Ship GitHub embeds',
|
|
199
|
+
body: 'Details from GitHub',
|
|
200
|
+
authors: ['dfosco'],
|
|
201
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
202
|
+
updatedAt: '2026-01-02T00:00:00Z',
|
|
203
|
+
},
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
render(<CanvasPage name="design-overview" />)
|
|
207
|
+
|
|
208
|
+
await act(async () => {
|
|
209
|
+
dispatchTextPaste('https://github.com/dfosco/storyboard/issues/42')
|
|
210
|
+
await Promise.resolve()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
await waitFor(() => {
|
|
214
|
+
expect(fetchGitHubEmbed).toHaveBeenCalledWith('https://github.com/dfosco/storyboard/issues/42')
|
|
215
|
+
})
|
|
216
|
+
expect(addWidget).toHaveBeenCalledWith(
|
|
217
|
+
'design-overview',
|
|
218
|
+
expect.objectContaining({
|
|
219
|
+
type: 'link-preview',
|
|
220
|
+
props: expect.objectContaining({
|
|
221
|
+
title: '#42 Ship GitHub embeds',
|
|
222
|
+
width: 580,
|
|
223
|
+
height: 400,
|
|
224
|
+
github: expect.objectContaining({
|
|
225
|
+
context: 'GitHub · dfosco/storyboard · Issue #42',
|
|
226
|
+
body: 'Details from GitHub',
|
|
227
|
+
}),
|
|
228
|
+
}),
|
|
229
|
+
}),
|
|
230
|
+
)
|
|
231
|
+
})
|
|
232
|
+
|
|
148
233
|
it.skip('persists dragged JSON widgets and JSX sources to canvas JSONL via update API', async () => {
|
|
149
234
|
render(<CanvasPage canvasId="design-overview" />)
|
|
150
235
|
|
|
@@ -9,10 +9,19 @@ import { schemas, getDefaults } from './widgets/widgetProps.js'
|
|
|
9
9
|
import { getFeatures, isResizable } from './widgets/widgetConfig.js'
|
|
10
10
|
import { createPasteContext, resolvePaste } from './widgets/pasteRules.js'
|
|
11
11
|
import { getPasteRules } from '@dfosco/storyboard-core'
|
|
12
|
+
import { isGitHubEmbedUrl } from './widgets/githubUrl.js'
|
|
12
13
|
import WidgetChrome from './widgets/WidgetChrome.jsx'
|
|
13
14
|
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
14
15
|
import useUndoRedo from './useUndoRedo.js'
|
|
15
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
addWidget as addWidgetApi,
|
|
18
|
+
checkGitHubCliAvailable,
|
|
19
|
+
fetchGitHubEmbed,
|
|
20
|
+
getCanvas as getCanvasApi,
|
|
21
|
+
removeWidget as removeWidgetApi,
|
|
22
|
+
updateCanvas,
|
|
23
|
+
uploadImage,
|
|
24
|
+
} from './canvasApi.js'
|
|
16
25
|
import PageSelector from './PageSelector.jsx'
|
|
17
26
|
import { stories as storyIndex } from 'virtual:storyboard-data-index'
|
|
18
27
|
import styles from './CanvasPage.module.css'
|
|
@@ -24,6 +33,7 @@ const ZOOM_MAX = 200
|
|
|
24
33
|
const VIEWPORT_TTL_MS = 15 * 60 * 1000
|
|
25
34
|
|
|
26
35
|
const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
|
|
36
|
+
const GH_INSTALL_URL = 'https://github.com/cli/cli'
|
|
27
37
|
|
|
28
38
|
/** Matches branch-deploy base path prefixes like /branch--my-feature/ */
|
|
29
39
|
const BRANCH_PREFIX_RE = /^\/branch--[^/]+/
|
|
@@ -42,7 +52,7 @@ function getToolbarColorMode(theme) {
|
|
|
42
52
|
|
|
43
53
|
function resolveCanvasThemeFromStorage() {
|
|
44
54
|
if (typeof localStorage === 'undefined') return 'light'
|
|
45
|
-
let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas:
|
|
55
|
+
let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: true }
|
|
46
56
|
try {
|
|
47
57
|
const rawSync = localStorage.getItem('sb-theme-sync')
|
|
48
58
|
if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
|
|
@@ -248,7 +258,7 @@ function computeCanvasBounds(widgets, componentEntries) {
|
|
|
248
258
|
}
|
|
249
259
|
|
|
250
260
|
/** Renders a single JSON-defined widget by type lookup. */
|
|
251
|
-
function WidgetRenderer({ widget, onUpdate, widgetRef }) {
|
|
261
|
+
function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefreshGitHub }) {
|
|
252
262
|
const Component = getWidgetComponent(widget.type)
|
|
253
263
|
if (!Component) {
|
|
254
264
|
console.warn(`[canvas] Unknown widget type: ${widget.type}`)
|
|
@@ -256,7 +266,14 @@ function WidgetRenderer({ widget, onUpdate, widgetRef }) {
|
|
|
256
266
|
}
|
|
257
267
|
const resizable = isResizable(widget.type) && !!onUpdate
|
|
258
268
|
// Only pass ref to forwardRef-wrapped components (e.g. PrototypeEmbed)
|
|
259
|
-
const elementProps = {
|
|
269
|
+
const elementProps = {
|
|
270
|
+
id: widget.id,
|
|
271
|
+
props: widget.props,
|
|
272
|
+
onUpdate,
|
|
273
|
+
resizable,
|
|
274
|
+
onRefreshGitHub,
|
|
275
|
+
canRefreshGitHub,
|
|
276
|
+
}
|
|
260
277
|
if (Component.$$typeof === Symbol.for('react.forward_ref')) {
|
|
261
278
|
elementProps.ref = widgetRef
|
|
262
279
|
}
|
|
@@ -278,10 +295,31 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
278
295
|
onUpdate,
|
|
279
296
|
onRemove,
|
|
280
297
|
onCopy,
|
|
298
|
+
onRefreshGitHub,
|
|
299
|
+
canRefreshGitHub,
|
|
281
300
|
readOnly,
|
|
282
301
|
}) {
|
|
283
302
|
const widgetRef = useRef(null)
|
|
284
|
-
const
|
|
303
|
+
const rawFeatures = getFeatures(widget.type, { isLocalDev: !readOnly })
|
|
304
|
+
|
|
305
|
+
// Dynamically adjust features based on widget state
|
|
306
|
+
const features = useMemo(() => {
|
|
307
|
+
const isGitHub = !!widget.props?.github
|
|
308
|
+
return rawFeatures.map((f) => {
|
|
309
|
+
// Toggle collapse label and hide when content is short (no github = no collapse)
|
|
310
|
+
if (f.action === 'toggle-collapse') {
|
|
311
|
+
if (!isGitHub) return null
|
|
312
|
+
return {
|
|
313
|
+
...f,
|
|
314
|
+
label: widget.props?.collapsed ? 'Expand height' : 'Collapse height',
|
|
315
|
+
icon: widget.props?.collapsed ? 'unfold' : 'fold',
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// Hide refresh-github for non-GitHub link previews
|
|
319
|
+
if (f.action === 'refresh-github' && !isGitHub) return null
|
|
320
|
+
return f
|
|
321
|
+
}).filter(Boolean)
|
|
322
|
+
}, [rawFeatures, widget.props?.github, widget.props?.collapsed])
|
|
285
323
|
|
|
286
324
|
const handleAction = useCallback((actionId) => {
|
|
287
325
|
if (actionId === 'delete') {
|
|
@@ -289,10 +327,28 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
289
327
|
} else if (actionId === 'copy') {
|
|
290
328
|
onCopy?.(widget)
|
|
291
329
|
} else if (actionId === 'copy-text') {
|
|
292
|
-
const
|
|
330
|
+
const title = widget.props?.title || ''
|
|
331
|
+
const body = widget.props?.text || widget.props?.content || widget.props?.github?.body || ''
|
|
332
|
+
const text = title && body ? `# ${title}\n\n${body}` : title || body
|
|
293
333
|
navigator.clipboard?.writeText(text).catch(() => {})
|
|
334
|
+
} else if (actionId === 'open-external') {
|
|
335
|
+
const url = widget.props?.url || widget.props?.src
|
|
336
|
+
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
|
337
|
+
} else if (actionId === 'refresh-github') {
|
|
338
|
+
const url = widget.props?.url
|
|
339
|
+
if (url && onRefreshGitHub) onRefreshGitHub(widget.id, url)
|
|
340
|
+
} else if (actionId === 'toggle-collapse') {
|
|
341
|
+
const wasCollapsed = !!widget.props?.collapsed
|
|
342
|
+
onUpdate?.(widget.id, { collapsed: !wasCollapsed })
|
|
343
|
+
// When collapsing, pan viewport to center the widget
|
|
344
|
+
if (!wasCollapsed) {
|
|
345
|
+
requestAnimationFrame(() => {
|
|
346
|
+
const el = document.getElementById(widget.id)
|
|
347
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
348
|
+
})
|
|
349
|
+
}
|
|
294
350
|
}
|
|
295
|
-
}, [widget, onRemove, onCopy])
|
|
351
|
+
}, [widget, onRemove, onCopy, onRefreshGitHub])
|
|
296
352
|
|
|
297
353
|
const handleWidgetFieldUpdate = useCallback((updates) => {
|
|
298
354
|
onUpdate?.(widget.id, updates)
|
|
@@ -317,6 +373,8 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
317
373
|
widget={widget}
|
|
318
374
|
onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
|
|
319
375
|
widgetRef={widgetRef}
|
|
376
|
+
onRefreshGitHub={onRefreshGitHub}
|
|
377
|
+
canRefreshGitHub={canRefreshGitHub}
|
|
320
378
|
/>
|
|
321
379
|
</WidgetChrome>
|
|
322
380
|
)
|
|
@@ -340,7 +398,8 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
|
|
|
340
398
|
*
|
|
341
399
|
* @param {{ canvasId: string }} props - Canvas name as indexed by the data plugin
|
|
342
400
|
*/
|
|
343
|
-
export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = null }) {
|
|
401
|
+
export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages = [], canvasMeta = null }) {
|
|
402
|
+
const canvasId = canvasIdProp || name || ''
|
|
344
403
|
const { canvas, jsxExports, jsxError, loading } = useCanvas(canvasId)
|
|
345
404
|
const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true && !new URLSearchParams(window.location.search).has('prodMode')
|
|
346
405
|
|
|
@@ -364,6 +423,7 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
|
|
|
364
423
|
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
|
|
365
424
|
const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
|
|
366
425
|
const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
|
|
426
|
+
const [showGhInstallBanner, setShowGhInstallBanner] = useState(false)
|
|
367
427
|
|
|
368
428
|
// Refs for snap settings (used by drop handler inside effect closure)
|
|
369
429
|
const snapEnabledRef = useRef(snapEnabled)
|
|
@@ -574,6 +634,58 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
|
|
|
574
634
|
}
|
|
575
635
|
}, [canvasId, localWidgets, undoRedo])
|
|
576
636
|
|
|
637
|
+
const showMissingGhBanner = useCallback(() => {
|
|
638
|
+
setShowGhInstallBanner(true)
|
|
639
|
+
}, [])
|
|
640
|
+
|
|
641
|
+
const buildGitHubPreviewUpdates = useCallback(async (url) => {
|
|
642
|
+
try {
|
|
643
|
+
const availability = await checkGitHubCliAvailable()
|
|
644
|
+
if (!availability?.available) {
|
|
645
|
+
showMissingGhBanner()
|
|
646
|
+
return null
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const result = await fetchGitHubEmbed(url)
|
|
650
|
+
if (result?.code === 'gh_unavailable') {
|
|
651
|
+
showMissingGhBanner()
|
|
652
|
+
return null
|
|
653
|
+
}
|
|
654
|
+
if (!result?.success || !result?.snapshot) return null
|
|
655
|
+
|
|
656
|
+
const snapshot = result.snapshot
|
|
657
|
+
return {
|
|
658
|
+
title: snapshot.title || '',
|
|
659
|
+
width: 580,
|
|
660
|
+
height: 400,
|
|
661
|
+
github: {
|
|
662
|
+
kind: snapshot.kind || 'issue',
|
|
663
|
+
parentKind: snapshot.parentKind || snapshot.kind || 'issue',
|
|
664
|
+
context: snapshot.context || '',
|
|
665
|
+
body: snapshot.body || '',
|
|
666
|
+
bodyHtml: snapshot.bodyHtml || '',
|
|
667
|
+
authors: Array.isArray(snapshot.authors)
|
|
668
|
+
? snapshot.authors.filter((author) => typeof author === 'string' && author.trim())
|
|
669
|
+
: [],
|
|
670
|
+
createdAt: snapshot.createdAt ?? null,
|
|
671
|
+
updatedAt: snapshot.updatedAt ?? null,
|
|
672
|
+
fetchedAt: new Date().toISOString(),
|
|
673
|
+
},
|
|
674
|
+
}
|
|
675
|
+
} catch (err) {
|
|
676
|
+
console.error('[canvas] Failed to fetch GitHub embed metadata:', err)
|
|
677
|
+
return null
|
|
678
|
+
}
|
|
679
|
+
}, [showMissingGhBanner])
|
|
680
|
+
|
|
681
|
+
const handleRefreshGitHubWidget = useCallback(async (widgetId, url) => {
|
|
682
|
+
if (!widgetId || !url) return { updated: false }
|
|
683
|
+
const updates = await buildGitHubPreviewUpdates(url)
|
|
684
|
+
if (!updates) return { updated: false }
|
|
685
|
+
handleWidgetUpdate(widgetId, updates)
|
|
686
|
+
return { updated: true }
|
|
687
|
+
}, [buildGitHubPreviewUpdates, handleWidgetUpdate])
|
|
688
|
+
|
|
577
689
|
const debouncedSourceSave = useRef(
|
|
578
690
|
debounce((canvasId, sources) => {
|
|
579
691
|
updateCanvas(canvasId, { sources }).catch((err) =>
|
|
@@ -1371,7 +1483,13 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
|
|
|
1371
1483
|
e.preventDefault()
|
|
1372
1484
|
const resolved = resolvePaste(text, pasteCtx, getPasteRules())
|
|
1373
1485
|
if (!resolved) return
|
|
1374
|
-
const { type
|
|
1486
|
+
const { type } = resolved
|
|
1487
|
+
let props = resolved.props
|
|
1488
|
+
|
|
1489
|
+
if (type === 'link-preview' && isGitHubEmbedUrl(props?.url || text)) {
|
|
1490
|
+
const githubUpdates = await buildGitHubPreviewUpdates(props?.url || text)
|
|
1491
|
+
if (githubUpdates) props = { ...props, ...githubUpdates }
|
|
1492
|
+
}
|
|
1375
1493
|
|
|
1376
1494
|
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1377
1495
|
const pos = centerPositionForWidget(center, type, props)
|
|
@@ -1392,6 +1510,7 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
|
|
|
1392
1510
|
|
|
1393
1511
|
document.addEventListener('paste', handlePaste)
|
|
1394
1512
|
return () => document.removeEventListener('paste', handlePaste)
|
|
1513
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1395
1514
|
}, [canvasId, undoRedo, localWidgets])
|
|
1396
1515
|
|
|
1397
1516
|
// --- Drag and drop handlers for images from Finder/file manager ---
|
|
@@ -1768,6 +1887,8 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
|
|
|
1768
1887
|
onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
|
|
1769
1888
|
onCopy={isLocalDev ? handleWidgetCopy : undefined}
|
|
1770
1889
|
onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
|
|
1890
|
+
onRefreshGitHub={isLocalDev ? handleRefreshGitHubWidget : undefined}
|
|
1891
|
+
canRefreshGitHub={isLocalDev}
|
|
1771
1892
|
readOnly={!isLocalDev}
|
|
1772
1893
|
/>
|
|
1773
1894
|
</div>
|
|
@@ -1816,6 +1937,28 @@ export default function CanvasPage({ canvasId, siblingPages = [], canvasMeta = n
|
|
|
1816
1937
|
</Canvas>
|
|
1817
1938
|
</div>
|
|
1818
1939
|
</div>
|
|
1940
|
+
{showGhInstallBanner && (
|
|
1941
|
+
<aside className={styles.ghInstallBanner} role="status" aria-live="polite">
|
|
1942
|
+
<span className={styles.ghInstallBannerText}>
|
|
1943
|
+
GitHub embeds require local <code>gh</code> CLI access.
|
|
1944
|
+
</span>
|
|
1945
|
+
<a
|
|
1946
|
+
href={GH_INSTALL_URL}
|
|
1947
|
+
target="_blank"
|
|
1948
|
+
rel="noopener noreferrer"
|
|
1949
|
+
className={styles.ghInstallBannerLink}
|
|
1950
|
+
>
|
|
1951
|
+
Install GitHub CLI
|
|
1952
|
+
</a>
|
|
1953
|
+
<button
|
|
1954
|
+
type="button"
|
|
1955
|
+
className={styles.ghInstallBannerDismiss}
|
|
1956
|
+
onClick={() => setShowGhInstallBanner(false)}
|
|
1957
|
+
>
|
|
1958
|
+
Dismiss
|
|
1959
|
+
</button>
|
|
1960
|
+
</aside>
|
|
1961
|
+
)}
|
|
1819
1962
|
</>
|
|
1820
1963
|
)
|
|
1821
1964
|
}
|
|
@@ -86,3 +86,57 @@
|
|
|
86
86
|
pointer-events: none;
|
|
87
87
|
user-select: none;
|
|
88
88
|
}
|
|
89
|
+
|
|
90
|
+
.ghInstallBanner {
|
|
91
|
+
position: fixed;
|
|
92
|
+
left: 50%;
|
|
93
|
+
bottom: 12px;
|
|
94
|
+
transform: translateX(-50%);
|
|
95
|
+
z-index: 15;
|
|
96
|
+
display: flex;
|
|
97
|
+
align-items: center;
|
|
98
|
+
gap: 10px;
|
|
99
|
+
padding: 8px 12px;
|
|
100
|
+
border-radius: 8px;
|
|
101
|
+
border: 1px solid var(--borderColor-default, #d1d9e0);
|
|
102
|
+
background: color-mix(in srgb, var(--bgColor-default, #ffffff) 88%, transparent);
|
|
103
|
+
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.16);
|
|
104
|
+
backdrop-filter: blur(8px);
|
|
105
|
+
font-size: 12px;
|
|
106
|
+
color: var(--fgColor-default, #1f2328);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.ghInstallBannerText {
|
|
110
|
+
white-space: nowrap;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.ghInstallBannerText code {
|
|
114
|
+
font-size: 11px;
|
|
115
|
+
padding: 1px 4px;
|
|
116
|
+
border-radius: 4px;
|
|
117
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.ghInstallBannerLink {
|
|
121
|
+
color: var(--fgColor-accent, #0969da);
|
|
122
|
+
font-weight: 600;
|
|
123
|
+
text-decoration: none;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.ghInstallBannerLink:hover {
|
|
127
|
+
text-decoration: underline;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.ghInstallBannerDismiss {
|
|
131
|
+
border: 1px solid var(--borderColor-default, #d1d9e0);
|
|
132
|
+
background: var(--bgColor-default, #ffffff);
|
|
133
|
+
color: var(--fgColor-default, #1f2328);
|
|
134
|
+
border-radius: 6px;
|
|
135
|
+
font-size: 11px;
|
|
136
|
+
padding: 4px 8px;
|
|
137
|
+
cursor: pointer;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.ghInstallBannerDismiss:hover {
|
|
141
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
142
|
+
}
|
|
@@ -103,6 +103,8 @@ vi.mock('./widgets/figmaUrl.js', () => ({
|
|
|
103
103
|
|
|
104
104
|
vi.mock('./canvasApi.js', () => ({
|
|
105
105
|
addWidget: vi.fn(),
|
|
106
|
+
checkGitHubCliAvailable: vi.fn(() => Promise.resolve({ available: true })),
|
|
107
|
+
fetchGitHubEmbed: vi.fn(() => Promise.resolve({ success: false })),
|
|
106
108
|
updateCanvas: vi.fn(() => Promise.resolve({ success: true })),
|
|
107
109
|
removeWidget: vi.fn(() => Promise.resolve({ success: true })),
|
|
108
110
|
uploadImage: vi.fn(),
|
package/src/canvas/canvasApi.js
CHANGED
|
@@ -53,3 +53,11 @@ export function toggleImagePrivacy(filename) {
|
|
|
53
53
|
export function getCanvas(canvasId) {
|
|
54
54
|
return request(`/read?name=${encodeURIComponent(canvasId)}`, 'GET')
|
|
55
55
|
}
|
|
56
|
+
|
|
57
|
+
export function checkGitHubCliAvailable() {
|
|
58
|
+
return request('/github/available', 'GET')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function fetchGitHubEmbed(url) {
|
|
62
|
+
return request('/github/embed', 'POST', { url })
|
|
63
|
+
}
|