@dfosco/storyboard-react 4.2.1 → 4.2.3
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 +1 -1
- package/src/BranchBar/BranchBar.jsx +18 -1
- package/src/BranchBar/BranchBar.module.css +9 -1
- package/src/CommandPalette/CommandPalette.jsx +1 -1
- package/src/Viewfinder.jsx +14 -12
- package/src/Viewfinder.module.css +6 -0
- package/src/canvas/CanvasPage.jsx +5 -5
- package/src/canvas/PageSelector.jsx +36 -27
- package/src/canvas/PageSelector.module.css +9 -0
- package/src/canvas/widgets/ComponentSetWidget.jsx +14 -5
- package/src/canvas/widgets/CropOverlay.jsx +20 -60
- package/src/canvas/widgets/CropOverlay.module.css +62 -26
- package/src/canvas/widgets/ImageWidget.jsx +68 -20
- package/src/canvas/widgets/ImageWidget.module.css +13 -1
- package/src/canvas/widgets/LinkPreview.jsx +11 -0
- package/src/canvas/widgets/LinkPreview.module.css +43 -4
- package/src/canvas/widgets/WidgetChrome.module.css +7 -0
- package/src/context.jsx +1 -1
- package/src/index.js +1 -1
- package/src/story/ComponentSetPage.jsx +14 -2
- package/src/story/ComponentSetPage.module.css +20 -12
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@base-ui/react": "^1.4.0",
|
|
7
|
-
"@dfosco/storyboard-core": "4.2.
|
|
8
|
-
"@dfosco/tiny-canvas": "4.2.
|
|
7
|
+
"@dfosco/storyboard-core": "4.2.3",
|
|
8
|
+
"@dfosco/tiny-canvas": "4.2.3",
|
|
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",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AuthModal — Global PAT entry dialog for comments authentication.
|
|
3
3
|
* Mounted at app root, triggered by:
|
|
4
|
-
* -
|
|
4
|
+
* - CoreUIBar (comments tool / "C" shortcut) via 'storyboard:open-auth-modal' event
|
|
5
5
|
* - ViewfinderNew sidebar login button via same event
|
|
6
6
|
*/
|
|
7
7
|
import { useState, useEffect, useCallback } from 'react'
|
|
@@ -15,6 +15,15 @@ function checkLocalDev() {
|
|
|
15
15
|
return window.__SB_LOCAL_DEV__ === true
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** Read the dev domain color injected by the server plugin, validated via CSS.supports. */
|
|
19
|
+
function getDevDomainColor() {
|
|
20
|
+
if (typeof window === 'undefined') return null
|
|
21
|
+
const color = window.__SB_DEV_DOMAIN_COLOR__
|
|
22
|
+
if (!color) return null
|
|
23
|
+
if (typeof CSS !== 'undefined' && CSS.supports && !CSS.supports('color', color)) return null
|
|
24
|
+
return color
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
export default function BranchBar({ basePath }) {
|
|
19
28
|
const [hidden, setHidden] = useState(
|
|
20
29
|
() => typeof document !== 'undefined' && document.documentElement.classList.contains('storyboard-chrome-hidden')
|
|
@@ -33,6 +42,7 @@ export default function BranchBar({ basePath }) {
|
|
|
33
42
|
|
|
34
43
|
const isLocalDev = checkLocalDev()
|
|
35
44
|
const isOnBranch = currentBranch !== 'main'
|
|
45
|
+
const domainColor = isLocalDev ? getDevDomainColor() : null
|
|
36
46
|
|
|
37
47
|
useEffect(() => {
|
|
38
48
|
const observer = new MutationObserver(() => {
|
|
@@ -52,8 +62,15 @@ export default function BranchBar({ basePath }) {
|
|
|
52
62
|
|
|
53
63
|
return (
|
|
54
64
|
<div className={css.bar} data-branch-bar>
|
|
55
|
-
<div
|
|
65
|
+
<div
|
|
66
|
+
className={`${css.barInner}${isLocalDev ? '' : ` ${css.barProd}`}`}
|
|
67
|
+
style={domainColor ? { '--sb-branch-bar-bg': domainColor } : undefined}
|
|
68
|
+
>
|
|
56
69
|
<span className={css.barLabel}>
|
|
70
|
+
{isLocalDev && window.__SB_DEV_DOMAIN__ && <>
|
|
71
|
+
<span className={css.barDomainName}>⌘ {window.__SB_DEV_DOMAIN__}</span>
|
|
72
|
+
<span className={css.barSeparator}>·</span>
|
|
73
|
+
</>}
|
|
57
74
|
<GitBranchIcon size={12} />
|
|
58
75
|
<span className={css.barBranchName}>{currentBranch}</span>
|
|
59
76
|
{isLocalDev && <>
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
justify-content: center;
|
|
17
17
|
gap: 8px;
|
|
18
18
|
height: 32px;
|
|
19
|
-
background: hsl(212, 92%, 45%);
|
|
19
|
+
background: var(--sb-branch-bar-bg, hsl(212, 92%, 45%));
|
|
20
20
|
color: #fff;
|
|
21
21
|
padding: 4px 12px;
|
|
22
22
|
position: relative;
|
|
@@ -72,6 +72,14 @@
|
|
|
72
72
|
white-space: nowrap;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
.barDomainName {
|
|
76
|
+
font-weight: 400;
|
|
77
|
+
max-width: 200px;
|
|
78
|
+
overflow: hidden;
|
|
79
|
+
text-overflow: ellipsis;
|
|
80
|
+
white-space: nowrap;
|
|
81
|
+
}
|
|
82
|
+
|
|
75
83
|
.barSeparator {
|
|
76
84
|
opacity: 0.6;
|
|
77
85
|
margin: 0 2px;
|
|
@@ -880,7 +880,7 @@ function buildPaletteItems(basePath, onCreateAction, onNavigateToPage) {
|
|
|
880
880
|
|
|
881
881
|
/**
|
|
882
882
|
* StoryboardCommandPalette — React command palette using react-cmdk.
|
|
883
|
-
* Mounted at app root, listens for custom events from
|
|
883
|
+
* Mounted at app root, listens for custom events from CoreUIBar.
|
|
884
884
|
*/
|
|
885
885
|
export default function StoryboardCommandPalette({ basePath }) {
|
|
886
886
|
const [open, setOpen] = useState(false)
|
package/src/Viewfinder.jsx
CHANGED
|
@@ -225,7 +225,7 @@ function CardActionsMenu({ typeLabel, onEdit, onDelete }) {
|
|
|
225
225
|
<KebabHorizontalIcon size={16} />
|
|
226
226
|
</Menu.Trigger>
|
|
227
227
|
<Menu.Portal>
|
|
228
|
-
<Menu.Positioner className={css.actionsMenuPositioner} side="
|
|
228
|
+
<Menu.Positioner className={css.actionsMenuPositioner} side="inline-end" alignment="end" sideOffset={8}>
|
|
229
229
|
<Menu.Popup className={css.actionsMenu} onClick={(e) => { e.preventDefault(); e.stopPropagation() }}>
|
|
230
230
|
<Menu.Item
|
|
231
231
|
className={css.actionsMenuItem}
|
|
@@ -486,6 +486,7 @@ function ArtifactCard({ item, basePath, starred, onToggleStar, onItemDeleted })
|
|
|
486
486
|
<div className={css.cardHeader}>
|
|
487
487
|
<span className={css.cardBadge}>{getTypeLabel(item.type)}</span>
|
|
488
488
|
<div className={css.cardActions}>
|
|
489
|
+
<StarBtn active={starred} onClick={() => onToggleStar(item.id)} inline />
|
|
489
490
|
{item.flows?.length > 0 && <FlowsDropdown flows={item.flows} basePath={basePath} />}
|
|
490
491
|
{item.pages?.length > 1 && <PagesDropdown pages={item.pages} basePath={basePath} />}
|
|
491
492
|
{canEditDelete && (
|
|
@@ -504,7 +505,6 @@ function ArtifactCard({ item, basePath, starred, onToggleStar, onItemDeleted })
|
|
|
504
505
|
{item.name}
|
|
505
506
|
{isExternal && <span className={css.externalBadge}>↗</span>}
|
|
506
507
|
</div>
|
|
507
|
-
<StarBtn active={starred} onClick={() => onToggleStar(item.id)} inline />
|
|
508
508
|
</div>
|
|
509
509
|
{item.description && (
|
|
510
510
|
<div className={css.cardDescription}>{item.description}</div>
|
|
@@ -582,12 +582,13 @@ function FlowsDropdown({ flows, basePath }) {
|
|
|
582
582
|
<Menu.Item
|
|
583
583
|
key={flow.key}
|
|
584
584
|
className={css.flowsItem}
|
|
585
|
-
onClick={(e) => {
|
|
586
|
-
e.preventDefault()
|
|
587
|
-
window.location.href = withBase(basePath, flow.route)
|
|
588
|
-
}}
|
|
589
585
|
>
|
|
590
|
-
|
|
586
|
+
<a
|
|
587
|
+
href={withBase(basePath, flow.route)}
|
|
588
|
+
className={css.flowsItemLink}
|
|
589
|
+
>
|
|
590
|
+
{flow.meta?.title || flow.name}
|
|
591
|
+
</a>
|
|
591
592
|
</Menu.Item>
|
|
592
593
|
))}
|
|
593
594
|
</Menu.Popup>
|
|
@@ -619,12 +620,13 @@ function PagesDropdown({ pages, basePath }) {
|
|
|
619
620
|
<Menu.Item
|
|
620
621
|
key={page.route}
|
|
621
622
|
className={css.flowsItem}
|
|
622
|
-
onClick={(e) => {
|
|
623
|
-
e.preventDefault()
|
|
624
|
-
window.location.href = withBase(basePath, page.route)
|
|
625
|
-
}}
|
|
626
623
|
>
|
|
627
|
-
|
|
624
|
+
<a
|
|
625
|
+
href={withBase(basePath, page.route)}
|
|
626
|
+
className={css.flowsItemLink}
|
|
627
|
+
>
|
|
628
|
+
{page.name}
|
|
629
|
+
</a>
|
|
628
630
|
</Menu.Item>
|
|
629
631
|
))}
|
|
630
632
|
</Menu.Popup>
|
|
@@ -2075,7 +2075,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2075
2075
|
return () => document.removeEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
|
|
2076
2076
|
}, [canvasId])
|
|
2077
2077
|
|
|
2078
|
-
// Broadcast snap state to
|
|
2078
|
+
// Broadcast snap state to toolbar
|
|
2079
2079
|
useEffect(() => {
|
|
2080
2080
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
|
|
2081
2081
|
detail: { snapEnabled }
|
|
@@ -2083,7 +2083,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2083
2083
|
snapEnabledRef.current = snapEnabled
|
|
2084
2084
|
}, [snapEnabled])
|
|
2085
2085
|
|
|
2086
|
-
// Respond to snap-state requests from
|
|
2086
|
+
// Respond to snap-state requests from toolbar (handles mount-order race)
|
|
2087
2087
|
useEffect(() => {
|
|
2088
2088
|
function handleRequest() {
|
|
2089
2089
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
|
|
@@ -2094,7 +2094,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2094
2094
|
return () => document.removeEventListener('storyboard:canvas:snap-state-request', handleRequest)
|
|
2095
2095
|
}, [])
|
|
2096
2096
|
|
|
2097
|
-
// Listen for gridSize from
|
|
2097
|
+
// Listen for gridSize from toolbar config
|
|
2098
2098
|
useEffect(() => {
|
|
2099
2099
|
function handleGridSize(e) {
|
|
2100
2100
|
const size = e.detail?.gridSize
|
|
@@ -2638,7 +2638,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2638
2638
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
2639
2639
|
}, [handleUndo, handleRedo, handleDuplicateSelected, handleDuplicateWithConnectors, handleSelectAll])
|
|
2640
2640
|
|
|
2641
|
-
// Listen for undo/redo from CoreUIBar
|
|
2641
|
+
// Listen for undo/redo from CoreUIBar
|
|
2642
2642
|
useEffect(() => {
|
|
2643
2643
|
function handleUndoEvent() { handleUndo() }
|
|
2644
2644
|
function handleRedoEvent() { handleRedo() }
|
|
@@ -2650,7 +2650,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2650
2650
|
}
|
|
2651
2651
|
}, [handleUndo, handleRedo])
|
|
2652
2652
|
|
|
2653
|
-
// Broadcast undo/redo availability to
|
|
2653
|
+
// Broadcast undo/redo availability to toolbar
|
|
2654
2654
|
useEffect(() => {
|
|
2655
2655
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:undo-redo-state', {
|
|
2656
2656
|
detail: { canUndo: undoRedo.canUndo, canRedo: undoRedo.canRedo }
|
|
@@ -104,11 +104,15 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
|
|
|
104
104
|
const currentLabel = currentPage?.title || currentName.split('/').pop()
|
|
105
105
|
const currentIndex = realPages.findIndex(p => p.name === currentName)
|
|
106
106
|
|
|
107
|
-
const
|
|
107
|
+
const getPageHref = useCallback((page) => {
|
|
108
108
|
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
109
|
-
|
|
109
|
+
return base + page.route
|
|
110
110
|
}, [])
|
|
111
111
|
|
|
112
|
+
const navigateTo = useCallback((page) => {
|
|
113
|
+
window.location.href = getPageHref(page)
|
|
114
|
+
}, [getPageHref])
|
|
115
|
+
|
|
112
116
|
const handleSelect = useCallback(
|
|
113
117
|
(page) => {
|
|
114
118
|
if (page.name !== currentName) {
|
|
@@ -119,13 +123,21 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
|
|
|
119
123
|
[currentName, navigateTo],
|
|
120
124
|
)
|
|
121
125
|
|
|
122
|
-
// Click handler with 300ms delay (mouse only) to distinguish from dblclick
|
|
126
|
+
// Click handler with 300ms delay (mouse only) to distinguish from dblclick.
|
|
127
|
+
// Cmd/Ctrl+click is handled natively by the <a> tag.
|
|
123
128
|
const handleItemClick = useCallback((page, e) => {
|
|
124
129
|
if (didDragRef.current) {
|
|
125
130
|
didDragRef.current = false
|
|
131
|
+
e.preventDefault()
|
|
126
132
|
return
|
|
127
133
|
}
|
|
128
|
-
if (editingPage)
|
|
134
|
+
if (editingPage) {
|
|
135
|
+
e.preventDefault()
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
// Cmd/Ctrl+click or middle-click → let browser handle natively
|
|
139
|
+
if (e?.metaKey || e?.ctrlKey || e?.button === 1) return
|
|
140
|
+
e.preventDefault()
|
|
129
141
|
// Keyboard Enter/Space → navigate immediately
|
|
130
142
|
if (!e?.nativeEvent || e.nativeEvent instanceof KeyboardEvent) {
|
|
131
143
|
handleSelect(page)
|
|
@@ -465,15 +477,7 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
|
|
|
465
477
|
role="option"
|
|
466
478
|
aria-selected={page.name === currentName}
|
|
467
479
|
className={`${styles.item} ${page.name === currentName ? styles.itemActive : ''} ${dragIndex === index ? styles.itemDragging : ''}`}
|
|
468
|
-
onClick={(e) => handleItemClick(page, e)}
|
|
469
480
|
onDoubleClick={() => handleItemDblClick(page)}
|
|
470
|
-
onKeyDown={(e) => {
|
|
471
|
-
if (e.key === 'Enter' || e.key === ' ') {
|
|
472
|
-
e.preventDefault()
|
|
473
|
-
handleSelect(page)
|
|
474
|
-
}
|
|
475
|
-
}}
|
|
476
|
-
tabIndex={0}
|
|
477
481
|
draggable={isLocalDev && !isEditing}
|
|
478
482
|
onDragStart={(e) => handleDragStart(index, e)}
|
|
479
483
|
onDragOver={(e) => handleDragOver(index, e)}
|
|
@@ -499,22 +503,27 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
|
|
|
499
503
|
onBlur={handleRenameCommit}
|
|
500
504
|
/>
|
|
501
505
|
) : (
|
|
502
|
-
|
|
506
|
+
<a
|
|
507
|
+
href={getPageHref(page)}
|
|
508
|
+
className={styles.itemLink}
|
|
509
|
+
onClick={(e) => handleItemClick(page, e)}
|
|
510
|
+
onDoubleClick={(e) => e.stopPropagation()}
|
|
511
|
+
>
|
|
503
512
|
<span className={styles.itemContent}>{page.title}</span>
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
513
|
+
</a>
|
|
514
|
+
)}
|
|
515
|
+
{!isEditing && isLocalDev && (
|
|
516
|
+
<button
|
|
517
|
+
className={styles.duplicateBtn}
|
|
518
|
+
onClick={(e) => handleDuplicate(page, e)}
|
|
519
|
+
onDoubleClick={(e) => e.stopPropagation()}
|
|
520
|
+
title="Duplicate page"
|
|
521
|
+
aria-label="Duplicate page"
|
|
522
|
+
>
|
|
523
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
524
|
+
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25ZM5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" />
|
|
525
|
+
</svg>
|
|
526
|
+
</button>
|
|
518
527
|
)}
|
|
519
528
|
</li>
|
|
520
529
|
)
|
|
@@ -73,15 +73,24 @@ export default forwardRef(function ComponentSetWidget({ id: widgetId, props, onU
|
|
|
73
73
|
useEffect(() => {
|
|
74
74
|
function handleMessage(e) {
|
|
75
75
|
if (e.source !== iframeRef.current?.contentWindow) return
|
|
76
|
-
if (e.data?.type
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
if (e.data?.type === 'storyboard:component-set:select') {
|
|
77
|
+
const newSelected = e.data.exportName || ''
|
|
78
|
+
if (newSelected !== selected) {
|
|
79
|
+
onUpdate?.({ selected: newSelected })
|
|
80
|
+
}
|
|
81
|
+
} else if (e.data?.type === 'storyboard:component-set:resize') {
|
|
82
|
+
// Auto-size widget to fit the grid content (+ header height)
|
|
83
|
+
const headerH = 32
|
|
84
|
+
const newW = Math.max(200, Math.ceil(e.data.width))
|
|
85
|
+
const newH = Math.max(60, Math.ceil(e.data.height) + headerH)
|
|
86
|
+
if (newW !== width || newH !== height) {
|
|
87
|
+
onUpdate?.({ width: newW, height: newH })
|
|
88
|
+
}
|
|
80
89
|
}
|
|
81
90
|
}
|
|
82
91
|
window.addEventListener('message', handleMessage)
|
|
83
92
|
return () => window.removeEventListener('message', handleMessage)
|
|
84
|
-
}, [selected, onUpdate])
|
|
93
|
+
}, [selected, width, height, onUpdate])
|
|
85
94
|
|
|
86
95
|
const handleResize = useCallback((w, h) => {
|
|
87
96
|
onUpdate?.({ width: w, height: h })
|
|
@@ -5,17 +5,17 @@
|
|
|
5
5
|
* - A crop region with drag handles (corners + edges)
|
|
6
6
|
* - Dark overlay on excluded area (via box-shadow)
|
|
7
7
|
* - Rule-of-thirds grid
|
|
8
|
-
*
|
|
8
|
+
*
|
|
9
|
+
* The confirmation bar (CropBar) is rendered separately by ImageWidget,
|
|
10
|
+
* outside the WidgetWrapper, to avoid overflow clipping.
|
|
9
11
|
*
|
|
10
12
|
* Props:
|
|
11
13
|
* containerWidth / containerHeight — pixel dimensions of the image container
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
+
* cropRect — current crop rectangle { x, y, width, height } in display pixels
|
|
15
|
+
* onCropRectChange(rect) — called when the user drags the crop region
|
|
14
16
|
* onCancel() — exit crop mode without saving
|
|
15
|
-
* onUndo() — revert to previous image (only when canUndo is true)
|
|
16
|
-
* canUndo — whether undo is available
|
|
17
17
|
*/
|
|
18
|
-
import {
|
|
18
|
+
import { useRef, useCallback, useEffect } from 'react'
|
|
19
19
|
import styles from './CropOverlay.module.css'
|
|
20
20
|
|
|
21
21
|
const MIN_CROP = 20
|
|
@@ -52,23 +52,13 @@ function XIcon() {
|
|
|
52
52
|
export default function CropOverlay({
|
|
53
53
|
containerWidth,
|
|
54
54
|
containerHeight,
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
onSave,
|
|
55
|
+
cropRect,
|
|
56
|
+
onCropRectChange,
|
|
58
57
|
onCancel,
|
|
59
|
-
onUndo,
|
|
60
|
-
canUndo,
|
|
61
58
|
}) {
|
|
62
59
|
const cw = containerWidth || 400
|
|
63
60
|
const ch = containerHeight || 300
|
|
64
61
|
|
|
65
|
-
const [cropRect, setCropRect] = useState({
|
|
66
|
-
x: Math.round(cw * 0.05),
|
|
67
|
-
y: Math.round(ch * 0.05),
|
|
68
|
-
width: Math.round(cw * 0.9),
|
|
69
|
-
height: Math.round(ch * 0.9),
|
|
70
|
-
})
|
|
71
|
-
|
|
72
62
|
const dragging = useRef(null)
|
|
73
63
|
|
|
74
64
|
// Escape key cancels crop
|
|
@@ -118,7 +108,7 @@ export default function CropOverlay({
|
|
|
118
108
|
}
|
|
119
109
|
}
|
|
120
110
|
|
|
121
|
-
|
|
111
|
+
onCropRectChange({ x, y, width, height })
|
|
122
112
|
}
|
|
123
113
|
|
|
124
114
|
const onUp = () => {
|
|
@@ -129,21 +119,7 @@ export default function CropOverlay({
|
|
|
129
119
|
|
|
130
120
|
window.addEventListener('pointermove', onMove)
|
|
131
121
|
window.addEventListener('pointerup', onUp)
|
|
132
|
-
}, [cropRect, cw, ch])
|
|
133
|
-
|
|
134
|
-
const handleSave = useCallback(() => {
|
|
135
|
-
const scaleX = (naturalWidth || cw) / cw
|
|
136
|
-
const scaleY = (naturalHeight || ch) / ch
|
|
137
|
-
onSave?.({
|
|
138
|
-
x: Math.round(cropRect.x * scaleX),
|
|
139
|
-
y: Math.round(cropRect.y * scaleY),
|
|
140
|
-
width: Math.round(cropRect.width * scaleX),
|
|
141
|
-
height: Math.round(cropRect.height * scaleY),
|
|
142
|
-
})
|
|
143
|
-
}, [cropRect, cw, ch, naturalWidth, naturalHeight, onSave])
|
|
144
|
-
|
|
145
|
-
const cropW = Math.round(((naturalWidth || cw) / cw) * cropRect.width)
|
|
146
|
-
const cropH = Math.round(((naturalHeight || ch) / ch) * cropRect.height)
|
|
122
|
+
}, [cropRect, cw, ch, onCropRectChange])
|
|
147
123
|
|
|
148
124
|
return (
|
|
149
125
|
<div
|
|
@@ -170,48 +146,32 @@ export default function CropOverlay({
|
|
|
170
146
|
))}
|
|
171
147
|
</div>
|
|
172
148
|
|
|
173
|
-
<FloatingCropBar
|
|
174
|
-
cropRect={cropRect}
|
|
175
|
-
containerWidth={cw}
|
|
176
|
-
cropW={cropW}
|
|
177
|
-
cropH={cropH}
|
|
178
|
-
onSave={handleSave}
|
|
179
|
-
onUndo={onUndo}
|
|
180
|
-
onCancel={onCancel}
|
|
181
|
-
canUndo={canUndo}
|
|
182
|
-
/>
|
|
183
149
|
</div>
|
|
184
150
|
)
|
|
185
151
|
}
|
|
186
152
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const belowY = cropRect.y + cropRect.height + gap
|
|
193
|
-
|
|
194
|
-
const anchorBelow = aboveY < 0
|
|
195
|
-
const barTop = anchorBelow ? belowY : aboveY
|
|
196
|
-
const barLeft = clamp(centerX, 80, containerWidth - 80)
|
|
197
|
-
|
|
153
|
+
/**
|
|
154
|
+
* CropBar — crop confirmation toolbar rendered outside the image widget.
|
|
155
|
+
* Positioned by the parent (ImageWidget) in place of the WidgetChrome toolbar.
|
|
156
|
+
*/
|
|
157
|
+
export function CropBar({ cropW, cropH, onSave, onUndo, onCancel, canUndo }) {
|
|
198
158
|
return (
|
|
199
159
|
<div
|
|
200
|
-
className={
|
|
201
|
-
style={{ top: barTop, left: barLeft }}
|
|
160
|
+
className={styles.cropBar}
|
|
202
161
|
onPointerDown={(e) => e.stopPropagation()}
|
|
162
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
203
163
|
>
|
|
204
164
|
<span className={styles.dimensions}>{cropW} × {cropH}</span>
|
|
205
165
|
<span className={styles.separator} />
|
|
206
|
-
<button className={`${styles.
|
|
166
|
+
<button className={`${styles.cropBarBtn} ${styles.cropBarBtnSave}`} onClick={onSave}>
|
|
207
167
|
<CheckIcon /> Save
|
|
208
168
|
</button>
|
|
209
169
|
{canUndo && (
|
|
210
|
-
<button className={styles.
|
|
170
|
+
<button className={styles.cropBarBtn} onClick={onUndo}>
|
|
211
171
|
<UndoIcon /> Undo
|
|
212
172
|
</button>
|
|
213
173
|
)}
|
|
214
|
-
<button className={`${styles.
|
|
174
|
+
<button className={`${styles.cropBarBtn} ${styles.cropBarBtnCancel}`} onClick={onCancel}>
|
|
215
175
|
<XIcon />
|
|
216
176
|
</button>
|
|
217
177
|
</div>
|
|
@@ -47,27 +47,29 @@
|
|
|
47
47
|
.handleW { left: -6px; top: 50%; transform: translateY(-50%); cursor: w-resize; }
|
|
48
48
|
.handleE { right: -6px; top: 50%; transform: translateY(-50%); cursor: e-resize; }
|
|
49
49
|
|
|
50
|
-
/* ──
|
|
50
|
+
/* ── Crop confirmation bar (rendered outside widget by ImageWidget) ── */
|
|
51
51
|
|
|
52
|
-
.
|
|
53
|
-
position: absolute;
|
|
52
|
+
.cropBar {
|
|
54
53
|
display: flex;
|
|
55
54
|
align-items: center;
|
|
55
|
+
justify-content: center;
|
|
56
56
|
gap: 6px;
|
|
57
57
|
padding: 4px 8px;
|
|
58
|
-
background:
|
|
59
|
-
|
|
60
|
-
-
|
|
61
|
-
|
|
62
|
-
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
|
63
|
-
z-index: 20;
|
|
64
|
-
transform: translateX(-50%);
|
|
65
|
-
transition: top 80ms ease-out, left 80ms ease-out;
|
|
66
|
-
pointer-events: auto;
|
|
58
|
+
background: var(--bgColor-default, #ffffff);
|
|
59
|
+
border: 1.6px solid var(--borderColor-muted, #d0d7de);
|
|
60
|
+
border-radius: 20px;
|
|
61
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
67
62
|
white-space: nowrap;
|
|
63
|
+
pointer-events: auto;
|
|
68
64
|
}
|
|
69
65
|
|
|
70
|
-
.
|
|
66
|
+
:global([data-sb-canvas-theme^='dark']) .cropBar {
|
|
67
|
+
background: var(--bgColor-muted, #161b22);
|
|
68
|
+
border-color: var(--borderColor-muted, #373e47);
|
|
69
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.cropBarBtn {
|
|
71
73
|
all: unset;
|
|
72
74
|
cursor: pointer;
|
|
73
75
|
display: flex;
|
|
@@ -77,42 +79,76 @@
|
|
|
77
79
|
border-radius: 6px;
|
|
78
80
|
font-size: 12px;
|
|
79
81
|
font-weight: 500;
|
|
80
|
-
color: #
|
|
82
|
+
color: var(--fgColor-default, #1f2328);
|
|
81
83
|
transition: background 100ms;
|
|
82
84
|
}
|
|
83
85
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
:global([data-sb-canvas-theme^='dark']) .cropBarBtn {
|
|
87
|
+
color: var(--fgColor-default, #e6edf3);
|
|
86
88
|
}
|
|
87
89
|
|
|
88
|
-
.
|
|
90
|
+
.cropBarBtn:hover {
|
|
91
|
+
background: var(--bgColor-neutral-muted, #eaeef2);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
:global([data-sb-canvas-theme^='dark']) .cropBarBtn:hover {
|
|
95
|
+
background: var(--bgColor-neutral-muted, #272c33);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.cropBarBtnSave {
|
|
89
99
|
background: var(--bgColor-success-emphasis, #1a7f37);
|
|
90
|
-
color: #ffffff;
|
|
100
|
+
color: var(--fgColor-onEmphasis, #ffffff);
|
|
91
101
|
}
|
|
92
102
|
|
|
93
|
-
.
|
|
103
|
+
.cropBarBtnSave:hover {
|
|
94
104
|
background: var(--bgColor-success-emphasis, #2da44e);
|
|
105
|
+
filter: brightness(1.1);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
:global([data-sb-canvas-theme^='dark']) .cropBarBtnSave {
|
|
109
|
+
background: var(--bgColor-success-emphasis, #238636);
|
|
110
|
+
color: var(--fgColor-onEmphasis, #ffffff);
|
|
95
111
|
}
|
|
96
112
|
|
|
97
|
-
.
|
|
98
|
-
|
|
113
|
+
:global([data-sb-canvas-theme^='dark']) .cropBarBtnSave:hover {
|
|
114
|
+
background: var(--bgColor-success-emphasis, #2ea043);
|
|
115
|
+
filter: brightness(1.1);
|
|
99
116
|
}
|
|
100
117
|
|
|
101
|
-
.
|
|
102
|
-
color: #
|
|
103
|
-
|
|
118
|
+
.cropBarBtnCancel {
|
|
119
|
+
color: var(--fgColor-muted, #656d76);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
:global([data-sb-canvas-theme^='dark']) .cropBarBtnCancel {
|
|
123
|
+
color: var(--fgColor-muted, #8b949e);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.cropBarBtnCancel:hover {
|
|
127
|
+
color: var(--fgColor-default, #1f2328);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
:global([data-sb-canvas-theme^='dark']) .cropBarBtnCancel:hover {
|
|
131
|
+
color: var(--fgColor-default, #e6edf3);
|
|
104
132
|
}
|
|
105
133
|
|
|
106
134
|
.dimensions {
|
|
107
135
|
font-size: 11px;
|
|
108
136
|
font-weight: 500;
|
|
109
|
-
color:
|
|
137
|
+
color: var(--fgColor-muted, #656d76);
|
|
110
138
|
padding: 0 4px;
|
|
111
139
|
font-variant-numeric: tabular-nums;
|
|
112
140
|
}
|
|
113
141
|
|
|
142
|
+
:global([data-sb-canvas-theme^='dark']) .dimensions {
|
|
143
|
+
color: var(--fgColor-muted, #8b949e);
|
|
144
|
+
}
|
|
145
|
+
|
|
114
146
|
.separator {
|
|
115
147
|
width: 1px;
|
|
116
148
|
height: 16px;
|
|
117
|
-
background:
|
|
149
|
+
background: var(--borderColor-muted, #d0d7de);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
:global([data-sb-canvas-theme^='dark']) .separator {
|
|
153
|
+
background: var(--borderColor-muted, #373e47);
|
|
118
154
|
}
|
|
@@ -2,7 +2,7 @@ import { useRef, useCallback, useState, useMemo, forwardRef, useImperativeHandle
|
|
|
2
2
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
3
3
|
import ResizeHandle from './ResizeHandle.jsx'
|
|
4
4
|
import ExpandedPane from './ExpandedPane.jsx'
|
|
5
|
-
import CropOverlay from './CropOverlay.jsx'
|
|
5
|
+
import CropOverlay, { CropBar } from './CropOverlay.jsx'
|
|
6
6
|
import { readProp } from './widgetProps.js'
|
|
7
7
|
import { schemas } from './widgetConfig.js'
|
|
8
8
|
import { toggleImagePrivacy, cropAndUpload } from '../canvasApi.js'
|
|
@@ -29,6 +29,7 @@ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resiz
|
|
|
29
29
|
const [expandMode, setExpandMode] = useState(null)
|
|
30
30
|
const expanded = expandMode !== null
|
|
31
31
|
const [cropping, setCropping] = useState(false)
|
|
32
|
+
const [cropRect, setCropRect] = useState(null)
|
|
32
33
|
const [previousSrc, setPreviousSrc] = useState(null)
|
|
33
34
|
const [containerSize, setContainerSize] = useState(null)
|
|
34
35
|
|
|
@@ -54,11 +55,22 @@ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resiz
|
|
|
54
55
|
onUpdate?.({ width: newWidth, height: newHeight })
|
|
55
56
|
}, [naturalRatio, width, height, onUpdate])
|
|
56
57
|
|
|
57
|
-
const
|
|
58
|
-
|
|
58
|
+
const cw = containerSize?.width || width || 400
|
|
59
|
+
const ch = containerSize?.height || height || 300
|
|
60
|
+
|
|
61
|
+
const handleCropSave = useCallback(async () => {
|
|
62
|
+
if (!src || !cropRect) return
|
|
63
|
+
const scaleX = (naturalSize?.width || cw) / cw
|
|
64
|
+
const scaleY = (naturalSize?.height || ch) / ch
|
|
65
|
+
const naturalCropRect = {
|
|
66
|
+
x: Math.round(cropRect.x * scaleX),
|
|
67
|
+
y: Math.round(cropRect.y * scaleY),
|
|
68
|
+
width: Math.round(cropRect.width * scaleX),
|
|
69
|
+
height: Math.round(cropRect.height * scaleY),
|
|
70
|
+
}
|
|
59
71
|
const canvasId = window.__storyboardCanvasBridgeState?.canvasId || ''
|
|
60
72
|
try {
|
|
61
|
-
const result = await cropAndUpload(src,
|
|
73
|
+
const result = await cropAndUpload(src, naturalCropRect, canvasId)
|
|
62
74
|
if (result.success) {
|
|
63
75
|
setPreviousSrc(src)
|
|
64
76
|
onUpdate?.({ src: result.filename })
|
|
@@ -67,10 +79,12 @@ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resiz
|
|
|
67
79
|
console.error('[canvas] Failed to crop image:', err)
|
|
68
80
|
}
|
|
69
81
|
setCropping(false)
|
|
70
|
-
|
|
82
|
+
setCropRect(null)
|
|
83
|
+
}, [src, cropRect, naturalSize, cw, ch, onUpdate])
|
|
71
84
|
|
|
72
85
|
const handleCropCancel = useCallback(() => {
|
|
73
86
|
setCropping(false)
|
|
87
|
+
setCropRect(null)
|
|
74
88
|
}, [])
|
|
75
89
|
|
|
76
90
|
const handleCropUndo = useCallback(() => {
|
|
@@ -79,6 +93,7 @@ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resiz
|
|
|
79
93
|
setPreviousSrc(null)
|
|
80
94
|
}
|
|
81
95
|
setCropping(false)
|
|
96
|
+
setCropRect(null)
|
|
82
97
|
}, [previousSrc, onUpdate])
|
|
83
98
|
|
|
84
99
|
useImperativeHandle(ref, () => ({
|
|
@@ -88,7 +103,15 @@ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resiz
|
|
|
88
103
|
if (actionId === 'crop-image') {
|
|
89
104
|
// Measure container at activation time (not during render)
|
|
90
105
|
const el = containerRef.current
|
|
91
|
-
|
|
106
|
+
const w = el?.offsetWidth || width || 400
|
|
107
|
+
const h = el?.offsetHeight || height || 300
|
|
108
|
+
if (el) setContainerSize({ width: w, height: h })
|
|
109
|
+
setCropRect({
|
|
110
|
+
x: Math.round(w * 0.05),
|
|
111
|
+
y: Math.round(h * 0.05),
|
|
112
|
+
width: Math.round(w * 0.9),
|
|
113
|
+
height: Math.round(h * 0.9),
|
|
114
|
+
})
|
|
92
115
|
setCropping(true)
|
|
93
116
|
return true
|
|
94
117
|
}
|
|
@@ -104,12 +127,22 @@ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resiz
|
|
|
104
127
|
} else if (actionId === 'download-image') {
|
|
105
128
|
if (!src) return
|
|
106
129
|
const url = getImageUrl(src)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
130
|
+
fetch(url)
|
|
131
|
+
.then((r) => {
|
|
132
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
|
133
|
+
return r.blob()
|
|
134
|
+
})
|
|
135
|
+
.then((blob) => {
|
|
136
|
+
const blobUrl = URL.createObjectURL(blob)
|
|
137
|
+
const a = document.createElement('a')
|
|
138
|
+
a.href = blobUrl
|
|
139
|
+
a.download = src.replace(/^~/, '')
|
|
140
|
+
document.body.appendChild(a)
|
|
141
|
+
a.click()
|
|
142
|
+
document.body.removeChild(a)
|
|
143
|
+
URL.revokeObjectURL(blobUrl)
|
|
144
|
+
})
|
|
145
|
+
.catch((err) => console.error('[canvas] Failed to download image:', err))
|
|
113
146
|
} else if (actionId === 'copy-as-png') {
|
|
114
147
|
if (!src) return
|
|
115
148
|
const url = getImageUrl(src)
|
|
@@ -132,6 +165,12 @@ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resiz
|
|
|
132
165
|
const sizeStyle = {}
|
|
133
166
|
if (typeof width === 'number') sizeStyle.width = `${width}px`
|
|
134
167
|
|
|
168
|
+
// Compute crop dimensions in natural pixels for the CropBar display
|
|
169
|
+
const scaleX = cropRect ? ((naturalSize?.width || cw) / cw) : 1
|
|
170
|
+
const scaleY = cropRect ? ((naturalSize?.height || ch) / ch) : 1
|
|
171
|
+
const cropW = cropRect ? Math.round(cropRect.width * scaleX) : 0
|
|
172
|
+
const cropH = cropRect ? Math.round(cropRect.height * scaleY) : 0
|
|
173
|
+
|
|
135
174
|
return (
|
|
136
175
|
<>
|
|
137
176
|
<WidgetWrapper className={styles.imageWrapper}>
|
|
@@ -150,16 +189,13 @@ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resiz
|
|
|
150
189
|
Private
|
|
151
190
|
</span>
|
|
152
191
|
)}
|
|
153
|
-
{cropping && (
|
|
192
|
+
{cropping && cropRect && (
|
|
154
193
|
<CropOverlay
|
|
155
|
-
containerWidth={
|
|
156
|
-
containerHeight={
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
onSave={handleCropSave}
|
|
194
|
+
containerWidth={cw}
|
|
195
|
+
containerHeight={ch}
|
|
196
|
+
cropRect={cropRect}
|
|
197
|
+
onCropRectChange={setCropRect}
|
|
160
198
|
onCancel={handleCropCancel}
|
|
161
|
-
onUndo={handleCropUndo}
|
|
162
|
-
canUndo={!!previousSrc}
|
|
163
199
|
/>
|
|
164
200
|
)}
|
|
165
201
|
</div>
|
|
@@ -173,6 +209,18 @@ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resiz
|
|
|
173
209
|
)}
|
|
174
210
|
</div>
|
|
175
211
|
</WidgetWrapper>
|
|
212
|
+
{cropping && cropRect && (
|
|
213
|
+
<div className={styles.cropBarSlot}>
|
|
214
|
+
<CropBar
|
|
215
|
+
cropW={cropW}
|
|
216
|
+
cropH={cropH}
|
|
217
|
+
onSave={handleCropSave}
|
|
218
|
+
onCancel={handleCropCancel}
|
|
219
|
+
onUndo={handleCropUndo}
|
|
220
|
+
canUndo={!!previousSrc}
|
|
221
|
+
/>
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
176
224
|
{expanded && (
|
|
177
225
|
<ImageExpandPane
|
|
178
226
|
widgetId={id}
|
|
@@ -28,11 +28,23 @@
|
|
|
28
28
|
pointer-events: none;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/* Hide the widget toolbar when crop is active (
|
|
31
|
+
/* Hide the widget toolbar when crop is active (replaced by crop bar) */
|
|
32
32
|
.container[data-crop-active] {
|
|
33
33
|
overflow: visible;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/* Crop bar slot — positioned below the widget like the WidgetChrome toolbar */
|
|
37
|
+
.cropBarSlot {
|
|
38
|
+
display: flex;
|
|
39
|
+
align-items: center;
|
|
40
|
+
justify-content: center;
|
|
41
|
+
position: absolute;
|
|
42
|
+
left: 0;
|
|
43
|
+
right: 0;
|
|
44
|
+
top: calc(100% + 10px);
|
|
45
|
+
z-index: 20;
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
.privateBadge {
|
|
37
49
|
position: absolute;
|
|
38
50
|
top: 20px;
|
|
@@ -31,6 +31,9 @@ function postProcessHtml(html) {
|
|
|
31
31
|
// Unwrap <details><summary>...</summary><video ...></details> → just the <video>
|
|
32
32
|
out = out.replace(/<details[^>]*>\s*<summary[^>]*>[\s\S]*?<\/summary>\s*(<video[\s\S]*?<\/video>)\s*<\/details>/gi, '$1')
|
|
33
33
|
|
|
34
|
+
// Force remaining <details> elements open so content is visible
|
|
35
|
+
out = out.replace(/<details(?![^>]*\bopen\b)/gi, '<details open')
|
|
36
|
+
|
|
34
37
|
// Convert bare video URLs (wrapped in <p>) into <video> elements
|
|
35
38
|
out = out.replace(VIDEO_URL_LINE_RE, (_, url) =>
|
|
36
39
|
`<video src="${url}" controls preload="none"></video>`
|
|
@@ -52,6 +55,14 @@ function postProcessHtml(html) {
|
|
|
52
55
|
return `<input ${before}${after}>`
|
|
53
56
|
})
|
|
54
57
|
|
|
58
|
+
// Mark @mention links with a data attribute for pill styling.
|
|
59
|
+
// GitHub API HTML uses class="user-mention" but remark output won't have it.
|
|
60
|
+
// Match <a ...>@username</a> linking to github.com profiles.
|
|
61
|
+
out = out.replace(/<a\s([^>]*)>(@[a-zA-Z0-9_-]+)<\/a>/g, (match, attrs, text) => {
|
|
62
|
+
if (match.includes('data-mention')) return match
|
|
63
|
+
return `<a ${attrs} data-mention>${text}</a>`
|
|
64
|
+
})
|
|
65
|
+
|
|
55
66
|
return out
|
|
56
67
|
}
|
|
57
68
|
|
|
@@ -241,6 +241,22 @@
|
|
|
241
241
|
text-decoration: underline;
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
/* @mention pills — blue background + text like GitHub */
|
|
245
|
+
.issueBody a[data-mention] {
|
|
246
|
+
background: var(--bgColor-accent-muted, #ddf4ff);
|
|
247
|
+
color: var(--fgColor-accent, #0969da);
|
|
248
|
+
padding: 1px 6px;
|
|
249
|
+
border-radius: 4px;
|
|
250
|
+
font-weight: 600;
|
|
251
|
+
text-decoration: none;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.issueBody a[data-mention]:hover {
|
|
255
|
+
background: var(--bgColor-accent-emphasis, #0969da);
|
|
256
|
+
color: var(--fgColor-onEmphasis, #ffffff);
|
|
257
|
+
text-decoration: none;
|
|
258
|
+
}
|
|
259
|
+
|
|
244
260
|
.issueBody img {
|
|
245
261
|
max-width: 100%;
|
|
246
262
|
height: auto;
|
|
@@ -400,15 +416,32 @@
|
|
|
400
416
|
margin: 16px 0;
|
|
401
417
|
}
|
|
402
418
|
|
|
403
|
-
/* Details/summary —
|
|
419
|
+
/* Details/summary — interactive collapsible sections */
|
|
404
420
|
.issueBody details {
|
|
405
|
-
border:
|
|
406
|
-
|
|
421
|
+
border: 1px solid var(--borderColor-muted, #d8dee4);
|
|
422
|
+
border-radius: 6px;
|
|
423
|
+
margin: 12px 0;
|
|
407
424
|
padding: 0;
|
|
408
425
|
}
|
|
409
426
|
|
|
410
427
|
.issueBody summary {
|
|
411
|
-
display:
|
|
428
|
+
display: list-item;
|
|
429
|
+
cursor: pointer;
|
|
430
|
+
pointer-events: auto;
|
|
431
|
+
font-weight: 600;
|
|
432
|
+
padding: 8px 12px;
|
|
433
|
+
list-style: disclosure-closed inside;
|
|
434
|
+
user-select: none;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
.issueBody details[open] > summary {
|
|
438
|
+
list-style-type: disclosure-open;
|
|
439
|
+
border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
|
|
440
|
+
margin-bottom: 0;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
.issueBody details > :not(summary) {
|
|
444
|
+
padding: 0 12px;
|
|
412
445
|
}
|
|
413
446
|
|
|
414
447
|
.error {
|
|
@@ -491,6 +524,8 @@
|
|
|
491
524
|
.expandedIssueBody * { pointer-events: auto; }
|
|
492
525
|
.expandedIssueBody a { color: var(--fgColor-accent, #0969da); text-decoration: none; }
|
|
493
526
|
.expandedIssueBody a:hover { text-decoration: underline; }
|
|
527
|
+
.expandedIssueBody a[data-mention] { background: var(--bgColor-accent-muted, #ddf4ff); color: var(--fgColor-accent, #0969da); padding: 1px 6px; border-radius: 4px; font-weight: 600; text-decoration: none; }
|
|
528
|
+
.expandedIssueBody a[data-mention]:hover { background: var(--bgColor-accent-emphasis, #0969da); color: var(--fgColor-onEmphasis, #ffffff); text-decoration: none; }
|
|
494
529
|
.expandedIssueBody img { max-width: 100%; height: auto; border-radius: 6px; margin: 8px 0; display: block; }
|
|
495
530
|
.expandedIssueBody video { max-width: 100%; height: auto; border-radius: 6px; margin: 8px 0; display: block; }
|
|
496
531
|
.expandedIssueBody h1 { font-size: 20px; font-weight: 700; margin: 16px 0 8px; border-bottom: 1px solid var(--borderColor-muted, #d8dee4); padding-bottom: 4px; }
|
|
@@ -504,6 +539,10 @@
|
|
|
504
539
|
.expandedIssueBody ol { margin: 0 0 12px; padding-left: 24px; list-style: decimal; }
|
|
505
540
|
.expandedIssueBody li { margin: 0 0 4px; display: list-item; }
|
|
506
541
|
.expandedIssueBody blockquote { border-left: 4px solid var(--borderColor-default, #d0d7de); margin: 12px 0; padding: 4px 16px; color: var(--fgColor-muted, #656d76); }
|
|
542
|
+
.expandedIssueBody details { border: 1px solid var(--borderColor-muted, #d8dee4); border-radius: 6px; margin: 12px 0; padding: 0; }
|
|
543
|
+
.expandedIssueBody summary { display: list-item; cursor: pointer; font-weight: 600; padding: 8px 12px; list-style: disclosure-closed inside; user-select: none; }
|
|
544
|
+
.expandedIssueBody details[open] > summary { list-style-type: disclosure-open; border-bottom: 1px solid var(--borderColor-muted, #d8dee4); margin-bottom: 0; }
|
|
545
|
+
.expandedIssueBody details > :not(summary) { padding: 0 12px; }
|
|
507
546
|
|
|
508
547
|
/* ── Expanded plain link view ─────────────────────────────────────── */
|
|
509
548
|
|
|
@@ -92,6 +92,13 @@
|
|
|
92
92
|
border-radius: 4px;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/* Hide toolbar and connector anchors when a child widget is in crop mode */
|
|
96
|
+
.chromeContainer:has([data-crop-active]) .toolbar,
|
|
97
|
+
.chromeContainer:has([data-crop-active]) .anchorPort {
|
|
98
|
+
visibility: hidden;
|
|
99
|
+
pointer-events: none;
|
|
100
|
+
}
|
|
101
|
+
|
|
95
102
|
.widgetSlotSelected {
|
|
96
103
|
outline: 4px solid var(--bgColor-accent-emphasis, #2f81f7);
|
|
97
104
|
outline-offset: 2px;
|
package/src/context.jsx
CHANGED
|
@@ -263,7 +263,7 @@ function StoryboardProviderInner({ flowName, sceneName, recordName, recordParam,
|
|
|
263
263
|
cleanup = mountDesignModes()
|
|
264
264
|
})
|
|
265
265
|
.catch(() => {
|
|
266
|
-
//
|
|
266
|
+
// UI not available — degrade gracefully
|
|
267
267
|
})
|
|
268
268
|
|
|
269
269
|
return () => cleanup?.()
|
package/src/index.js
CHANGED
|
@@ -34,7 +34,7 @@ export { installHashPreserver } from './hashPreserver.js'
|
|
|
34
34
|
export { FormContext } from './context/FormContext.js'
|
|
35
35
|
|
|
36
36
|
// Design mode hook (keep — React apps may still read mode state)
|
|
37
|
-
|
|
37
|
+
|
|
38
38
|
|
|
39
39
|
// Workspace dashboard
|
|
40
40
|
export { default as Workspace } from './Workspace.jsx'
|
|
@@ -84,7 +84,8 @@ export default function ComponentSetPage({ name }) {
|
|
|
84
84
|
|
|
85
85
|
const gridRef = useRef(null)
|
|
86
86
|
|
|
87
|
-
// Measure all cell content elements and snap cells to the largest
|
|
87
|
+
// Measure all cell content elements and snap cells to the largest.
|
|
88
|
+
// Posts the total grid size to the parent widget so it can auto-size.
|
|
88
89
|
useLayoutEffect(() => {
|
|
89
90
|
const grid = gridRef.current
|
|
90
91
|
if (!grid || !exports) return
|
|
@@ -101,6 +102,17 @@ export default function ComponentSetPage({ name }) {
|
|
|
101
102
|
}
|
|
102
103
|
grid.style.setProperty('--cell-snap-w', `${maxW}px`)
|
|
103
104
|
grid.style.setProperty('--cell-snap-h', `${maxH}px`)
|
|
105
|
+
|
|
106
|
+
// Post total grid size to parent widget
|
|
107
|
+
if (isEmbed && window.parent !== window) {
|
|
108
|
+
requestAnimationFrame(() => {
|
|
109
|
+
window.parent.postMessage({
|
|
110
|
+
type: 'storyboard:component-set:resize',
|
|
111
|
+
width: grid.scrollWidth,
|
|
112
|
+
height: grid.scrollHeight,
|
|
113
|
+
}, '*')
|
|
114
|
+
})
|
|
115
|
+
}
|
|
104
116
|
}
|
|
105
117
|
|
|
106
118
|
// Measure after fonts load and initial paint
|
|
@@ -110,7 +122,7 @@ export default function ComponentSetPage({ name }) {
|
|
|
110
122
|
const ro = new ResizeObserver(measure)
|
|
111
123
|
for (const el of cells) ro.observe(el)
|
|
112
124
|
return () => ro.disconnect()
|
|
113
|
-
}, [exports, layout])
|
|
125
|
+
}, [exports, layout, isEmbed])
|
|
114
126
|
|
|
115
127
|
const handleSelect = useCallback((exportName) => {
|
|
116
128
|
const params = new URLSearchParams(location.search)
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/* ComponentSetPage — grid layout for all exports of a story */
|
|
2
2
|
|
|
3
3
|
.grid {
|
|
4
|
-
background-color: var(--bgColor-
|
|
4
|
+
background-color: var(--bgColor-default, #ffffff);
|
|
5
5
|
display: flex;
|
|
6
6
|
flex-wrap: nowrap;
|
|
7
|
-
gap:
|
|
8
|
-
padding:
|
|
9
|
-
min-height: 100vh;
|
|
7
|
+
gap: 0;
|
|
8
|
+
padding: 0;
|
|
10
9
|
width: max-content;
|
|
11
10
|
min-width: 100%;
|
|
12
11
|
box-sizing: border-box;
|
|
@@ -24,20 +23,30 @@
|
|
|
24
23
|
flex: 0 0 auto;
|
|
25
24
|
display: flex;
|
|
26
25
|
flex-direction: column;
|
|
27
|
-
border:
|
|
28
|
-
border-radius:
|
|
26
|
+
border: 1px solid var(--borderColor-muted, #d8dee4);
|
|
27
|
+
border-radius: 0;
|
|
29
28
|
overflow: hidden;
|
|
30
29
|
transition: border-color 120ms ease, box-shadow 120ms ease;
|
|
31
30
|
position: relative;
|
|
32
|
-
background: var(--bgColor-
|
|
31
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* Collapse double borders between adjacent cells */
|
|
35
|
+
.grid[data-layout="horizontal"] .cell + .cell {
|
|
36
|
+
border-left: none;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
|
-
|
|
39
|
+
.grid[data-layout="vertical"] .cell + .cell {
|
|
40
|
+
border-top: none;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* In horizontal layout, cells snap to widest and tallest component */
|
|
36
44
|
.grid[data-layout="horizontal"] .cell {
|
|
37
45
|
min-width: var(--cell-snap-w, 200px);
|
|
46
|
+
min-height: var(--cell-snap-h, 60px);
|
|
38
47
|
}
|
|
39
48
|
|
|
40
|
-
/* In vertical layout,
|
|
49
|
+
/* In vertical layout, cells snap to widest and tallest component */
|
|
41
50
|
.grid[data-layout="vertical"] .cell {
|
|
42
51
|
min-height: var(--cell-snap-h, 120px);
|
|
43
52
|
min-width: 100%;
|
|
@@ -58,14 +67,12 @@
|
|
|
58
67
|
font-size: 11px;
|
|
59
68
|
font-weight: 600;
|
|
60
69
|
color: var(--fgColor-muted, #656d76);
|
|
61
|
-
background: var(--bgColor-
|
|
70
|
+
background: var(--bgColor-default, #ffffff);
|
|
62
71
|
border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
|
|
63
72
|
cursor: pointer;
|
|
64
73
|
user-select: none;
|
|
65
74
|
transition: background 100ms ease, color 100ms ease;
|
|
66
75
|
flex-shrink: 0;
|
|
67
|
-
/* Round top corners to match cell */
|
|
68
|
-
border-radius: 6px 6px 0 0;
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
.cellLabel:hover {
|
|
@@ -99,6 +106,7 @@
|
|
|
99
106
|
flex: 1;
|
|
100
107
|
overflow: visible;
|
|
101
108
|
position: relative;
|
|
109
|
+
padding: 12px;
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
.error {
|