@dfosco/storyboard-react 4.0.0-beta.39 → 4.0.0-beta.40
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/{ViewfinderNew.jsx → Viewfinder.jsx} +15 -151
- package/src/{ViewfinderNew.module.css → Viewfinder.module.css} +20 -189
- package/src/canvas/CanvasPage.jsx +62 -22
- package/src/canvas/CanvasPage.module.css +3 -3
- package/src/canvas/PageSelector.jsx +100 -4
- package/src/canvas/PageSelector.module.css +65 -0
- package/src/index.js +1 -1
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.40",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@base-ui/react": "^1.4.0",
|
|
7
|
-
"@dfosco/storyboard-core": "4.0.0-beta.
|
|
8
|
-
"@dfosco/tiny-canvas": "4.0.0-beta.
|
|
7
|
+
"@dfosco/storyboard-core": "4.0.0-beta.40",
|
|
8
|
+
"@dfosco/tiny-canvas": "4.0.0-beta.40",
|
|
9
9
|
"@neodrag/react": "^2.3.1",
|
|
10
10
|
"glob": "^11.0.0",
|
|
11
11
|
"jsonc-parser": "^3.3.1",
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Viewfinder — SaaS-style homescreen for Storyboard.
|
|
3
3
|
*
|
|
4
4
|
* Replaces the old list-based Viewfinder with a sidebar + grid layout.
|
|
5
5
|
* Wired to real data from buildPrototypeIndex and listStories.
|
|
6
6
|
*/
|
|
7
7
|
import { useState, useEffect, useMemo, useCallback, useSyncExternalStore } from 'react'
|
|
8
8
|
import { buildPrototypeIndex, listStories, getStoryData, getLocal, setLocal } from '@dfosco/storyboard-core'
|
|
9
|
-
import { MarkGithubIcon, GitBranchIcon, ChevronDownIcon, ChevronRightIcon, FileDirectoryFillIcon, StarIcon, StarFillIcon, ThreeBarsIcon, XIcon } from '@primer/octicons-react'
|
|
9
|
+
import { MarkGithubIcon, GitBranchIcon, ChevronDownIcon, ChevronRightIcon, FileDirectoryFillIcon, PlusIcon, StarIcon, StarFillIcon, ThreeBarsIcon, XIcon } from '@primer/octicons-react'
|
|
10
10
|
import { Menu } from '@base-ui/react/menu'
|
|
11
11
|
import Icon from './Icon.jsx'
|
|
12
|
-
import css from './
|
|
12
|
+
import css from './Viewfinder.module.css'
|
|
13
13
|
|
|
14
14
|
/* ─── localStorage helpers ─── */
|
|
15
15
|
|
|
@@ -532,116 +532,6 @@ function CreateMenu({ onClose, basePath }) {
|
|
|
532
532
|
|
|
533
533
|
/* ─── PAT Dialog ─── */
|
|
534
534
|
|
|
535
|
-
const COMMENTS_TOKEN_KEY = 'sb-comments-token'
|
|
536
|
-
const REPO_OWNER = 'dfosco'
|
|
537
|
-
const REPO_NAME = 'storyboard'
|
|
538
|
-
|
|
539
|
-
function getRepoInfo() {
|
|
540
|
-
try {
|
|
541
|
-
const cfg = typeof __STORYBOARD_CONFIG__ !== 'undefined' ? __STORYBOARD_CONFIG__ : null
|
|
542
|
-
const repo = cfg?.repository
|
|
543
|
-
if (repo?.owner && repo?.name) return repo
|
|
544
|
-
} catch { /* ignore */ }
|
|
545
|
-
return { owner: REPO_OWNER, name: REPO_NAME }
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
function PATDialog({ open, onClose }) {
|
|
549
|
-
const [tokenValue, setTokenValue] = useState('')
|
|
550
|
-
|
|
551
|
-
if (!open) return null
|
|
552
|
-
|
|
553
|
-
const repo = getRepoInfo()
|
|
554
|
-
|
|
555
|
-
const handleSignIn = () => {
|
|
556
|
-
const trimmed = tokenValue.trim()
|
|
557
|
-
if (!trimmed) return
|
|
558
|
-
|
|
559
|
-
// Store token to localStorage
|
|
560
|
-
try { localStorage.setItem(COMMENTS_TOKEN_KEY, trimmed) } catch { /* ignore */ }
|
|
561
|
-
|
|
562
|
-
// Try the comments auth API if available
|
|
563
|
-
try {
|
|
564
|
-
import('@dfosco/storyboard-core/comments').then(({ setToken }) => {
|
|
565
|
-
setToken(trimmed)
|
|
566
|
-
}).catch(() => {})
|
|
567
|
-
} catch { /* comments module may not be initialized */ }
|
|
568
|
-
|
|
569
|
-
setTokenValue('')
|
|
570
|
-
onClose()
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
const handleKeyDown = (e) => {
|
|
574
|
-
if (e.key === 'Enter') handleSignIn()
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
return (
|
|
578
|
-
<div className={css.createMenuOverlay} onClick={onClose}>
|
|
579
|
-
<div className={css.dialog} onClick={e => e.stopPropagation()}>
|
|
580
|
-
<button className={css.dialogClose} onClick={onClose} aria-label="Close">×</button>
|
|
581
|
-
|
|
582
|
-
<div className={css.dialogTitle}>Sign in for comments</div>
|
|
583
|
-
<div className={css.dialogDesc}>
|
|
584
|
-
Leave comments for other users to see and respond, and react to! Storyboard comments use Discussions as a back-end and require a GitHub PAT to be enabled.
|
|
585
|
-
</div>
|
|
586
|
-
|
|
587
|
-
<hr className={css.dialogSeparator} />
|
|
588
|
-
|
|
589
|
-
<div className={css.tokenCard}>
|
|
590
|
-
<div className={css.tokenCardTitle}>Fine-grained Personal Access Token</div>
|
|
591
|
-
<div className={css.tokenCardRow}>
|
|
592
|
-
<span className={css.tokenCardLabel}>Owner:</span>
|
|
593
|
-
<span className={css.tokenCardValue}>{repo.owner}</span>
|
|
594
|
-
</div>
|
|
595
|
-
<div className={css.tokenCardRow}>
|
|
596
|
-
<span className={css.tokenCardLabel}>Expiration:</span>
|
|
597
|
-
<span className={css.tokenCardValue}><strong>366 days</strong> (recommended)</span>
|
|
598
|
-
</div>
|
|
599
|
-
<div className={css.tokenCardRow}>
|
|
600
|
-
<span className={css.tokenCardLabel}>Repository access:</span>
|
|
601
|
-
<span className={css.tokenCardValue}>Only select repositories > {repo.owner}/{repo.name}</span>
|
|
602
|
-
</div>
|
|
603
|
-
<div className={css.tokenCardRow}>
|
|
604
|
-
<span className={css.tokenCardLabel}>Permissions:</span>
|
|
605
|
-
<span className={css.tokenCardValue}>Repositories > Discussions > Access: Read and Write</span>
|
|
606
|
-
</div>
|
|
607
|
-
</div>
|
|
608
|
-
|
|
609
|
-
<a
|
|
610
|
-
className={css.tokenLink}
|
|
611
|
-
href="https://github.com/settings/personal-access-tokens/new"
|
|
612
|
-
target="_blank"
|
|
613
|
-
rel="noopener noreferrer"
|
|
614
|
-
>
|
|
615
|
-
Create a GitHub Fine-Grained Personal Access Token ↗
|
|
616
|
-
</a>
|
|
617
|
-
|
|
618
|
-
<hr className={css.dialogSeparator} />
|
|
619
|
-
|
|
620
|
-
<label className={css.dialogLabel}>Personal Access Token</label>
|
|
621
|
-
<input
|
|
622
|
-
className={css.dialogInput}
|
|
623
|
-
placeholder="github_pat_… or ghp_…"
|
|
624
|
-
type="password"
|
|
625
|
-
autoFocus
|
|
626
|
-
value={tokenValue}
|
|
627
|
-
onChange={e => setTokenValue(e.target.value)}
|
|
628
|
-
onKeyDown={handleKeyDown}
|
|
629
|
-
/>
|
|
630
|
-
|
|
631
|
-
<div className={css.warningBanner}>
|
|
632
|
-
<span className={css.warningIcon}>⚠️</span>
|
|
633
|
-
<span>Comments are an experimental feature and may be unstable.</span>
|
|
634
|
-
</div>
|
|
635
|
-
|
|
636
|
-
<div className={css.dialogActions}>
|
|
637
|
-
<button className={css.btnSecondary} onClick={onClose}>Cancel</button>
|
|
638
|
-
<button className={css.btnPrimary} onClick={handleSignIn}>Sign in</button>
|
|
639
|
-
</div>
|
|
640
|
-
</div>
|
|
641
|
-
</div>
|
|
642
|
-
)
|
|
643
|
-
}
|
|
644
|
-
|
|
645
535
|
/* ─── Nav config ─── */
|
|
646
536
|
|
|
647
537
|
const NAV_ITEMS = [
|
|
@@ -690,6 +580,10 @@ function useBranches(basePath) {
|
|
|
690
580
|
}
|
|
691
581
|
|
|
692
582
|
function BranchDropdown({ basePath }) {
|
|
583
|
+
// Dev: hide dropdown — use CLI to switch branches
|
|
584
|
+
const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true
|
|
585
|
+
if (isLocalDev) return null
|
|
586
|
+
|
|
693
587
|
const { branches, currentBranch, branchBasePath, gitUser } = useBranches(basePath)
|
|
694
588
|
const [showAll, setShowAll] = useState(false)
|
|
695
589
|
const [switching, setSwitching] = useState(null)
|
|
@@ -713,38 +607,10 @@ function BranchDropdown({ basePath }) {
|
|
|
713
607
|
.filter(b => !b.lastModified || new Date(b.lastModified).getTime() > twoWeeksAgo)
|
|
714
608
|
.sort((a, b) => (a.branch || '').localeCompare(b.branch || ''))
|
|
715
609
|
|
|
716
|
-
const
|
|
717
|
-
|
|
718
|
-
const switchBranch = async (branch) => {
|
|
610
|
+
const switchBranch = (branch) => {
|
|
719
611
|
setSwitching(branch)
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
if (!isLocalDev) {
|
|
723
|
-
// Prod: direct navigation
|
|
724
|
-
const target = branches?.find(b => b.branch === branch)
|
|
725
|
-
window.location.href = `${branchBasePath}${target?.folder || (branch === 'main' ? '' : `branch--${branch}/`)}`
|
|
726
|
-
return
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
// Dev: call switch-branch API to spin up server
|
|
730
|
-
const apiBase = (basePath || '/').replace(/\/$/, '')
|
|
731
|
-
try {
|
|
732
|
-
const res = await fetch(`${apiBase}/_storyboard/switch-branch`, {
|
|
733
|
-
method: 'POST',
|
|
734
|
-
headers: { 'Content-Type': 'application/json' },
|
|
735
|
-
body: JSON.stringify({ branch }),
|
|
736
|
-
})
|
|
737
|
-
const data = await res.json()
|
|
738
|
-
if (res.ok && data.url) {
|
|
739
|
-
window.location.href = data.url
|
|
740
|
-
} else {
|
|
741
|
-
setSwitchError(data.error || 'Failed to switch')
|
|
742
|
-
setSwitching(null)
|
|
743
|
-
}
|
|
744
|
-
} catch (e) {
|
|
745
|
-
setSwitchError(e.message || 'Server not reachable')
|
|
746
|
-
setSwitching(null)
|
|
747
|
-
}
|
|
612
|
+
const target = branches?.find(b => b.branch === branch)
|
|
613
|
+
window.location.href = `${branchBasePath}${target?.folder || (branch === 'main' ? '' : `branch--${branch}/`)}`
|
|
748
614
|
}
|
|
749
615
|
|
|
750
616
|
return (
|
|
@@ -817,7 +683,7 @@ function BranchDropdown({ basePath }) {
|
|
|
817
683
|
|
|
818
684
|
/* ─── Main Component ─── */
|
|
819
685
|
|
|
820
|
-
export default function
|
|
686
|
+
export default function Viewfinder({
|
|
821
687
|
pageModules = {},
|
|
822
688
|
basePath,
|
|
823
689
|
title = 'Storyboard',
|
|
@@ -920,7 +786,6 @@ export default function ViewfinderNew({
|
|
|
920
786
|
const [activeNav, setActiveNav] = useState('all')
|
|
921
787
|
const [activeTab, setActiveTab] = useState('All')
|
|
922
788
|
const [showCreate, setShowCreate] = useState(false)
|
|
923
|
-
const [showPAT, setShowPAT] = useState(false)
|
|
924
789
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
|
925
790
|
const [groupByFolders, setGroupByFolders] = useState(() => {
|
|
926
791
|
try { return localStorage.getItem(GROUP_BY_FOLDERS_KEY) !== 'false' } catch { return true }
|
|
@@ -1036,7 +901,7 @@ export default function ViewfinderNew({
|
|
|
1036
901
|
<BranchDropdown basePath={basePath} />
|
|
1037
902
|
<Menu.Root open={showCreate} onOpenChange={setShowCreate}>
|
|
1038
903
|
<Menu.Trigger className={css.createBtn}>
|
|
1039
|
-
|
|
904
|
+
<PlusIcon size={14} /> Create
|
|
1040
905
|
</Menu.Trigger>
|
|
1041
906
|
<Menu.Portal>
|
|
1042
907
|
<Menu.Positioner className={css.createDropdownPositioner} side="bottom" align="end" sideOffset={4}>
|
|
@@ -1053,6 +918,7 @@ export default function ViewfinderNew({
|
|
|
1053
918
|
<div className={css.body}>
|
|
1054
919
|
{/* ─── Sidebar ─── */}
|
|
1055
920
|
<aside className={`${css.sidebar}${sidebarOpen ? ` ${css.sidebarOpen}` : ''}`}>
|
|
921
|
+
<div className={css.sidebarContent}>
|
|
1056
922
|
<nav className={css.navSection}>
|
|
1057
923
|
{NAV_ITEMS.map(nav => (
|
|
1058
924
|
<button
|
|
@@ -1085,10 +951,11 @@ export default function ViewfinderNew({
|
|
|
1085
951
|
{s.name}
|
|
1086
952
|
</a>
|
|
1087
953
|
))}
|
|
954
|
+
</div>
|
|
1088
955
|
|
|
1089
956
|
{/* User profile / login */}
|
|
1090
957
|
<div className={css.sidebarFooter}>
|
|
1091
|
-
<button className={css.loginBtn} onClick={() =>
|
|
958
|
+
<button className={css.loginBtn} onClick={() => document.dispatchEvent(new CustomEvent('storyboard:open-auth-modal'))}>
|
|
1092
959
|
<span className={css.avatar}><MarkGithubIcon size={16} /></span>
|
|
1093
960
|
<div>
|
|
1094
961
|
<div className={css.userName}>Sign in</div>
|
|
@@ -1173,9 +1040,6 @@ export default function ViewfinderNew({
|
|
|
1173
1040
|
</div>
|
|
1174
1041
|
</main>
|
|
1175
1042
|
</div>
|
|
1176
|
-
|
|
1177
|
-
{/* Modals */}
|
|
1178
|
-
<PATDialog open={showPAT} onClose={() => setShowPAT(false)} />
|
|
1179
1043
|
</div>
|
|
1180
1044
|
)
|
|
1181
1045
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
/*
|
|
1
|
+
/* Viewfinder — SaaS-style homescreen layout */
|
|
2
2
|
|
|
3
3
|
.layout {
|
|
4
4
|
display: flex;
|
|
5
5
|
flex-direction: column;
|
|
6
|
-
height: 100vh;
|
|
6
|
+
height: calc(100vh - var(--sb-branch-bar-height, 0px));
|
|
7
7
|
font-family: 'Mona Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
8
8
|
color: #1a1a1a;
|
|
9
9
|
background: #fafafa;
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
.logo {
|
|
31
|
-
width:
|
|
32
|
-
height:
|
|
33
|
-
background: #
|
|
31
|
+
width: 48px;
|
|
32
|
+
height: 48px;
|
|
33
|
+
background: #313131;
|
|
34
34
|
border-radius: 8px;
|
|
35
35
|
transform: rotate(-1deg);
|
|
36
36
|
display: flex;
|
|
@@ -42,17 +42,17 @@
|
|
|
42
42
|
|
|
43
43
|
.appName {
|
|
44
44
|
font-size: 24px;
|
|
45
|
+
/* font-style: italic; */
|
|
45
46
|
font-weight: 900;
|
|
46
|
-
font-style: italic;
|
|
47
47
|
color: #1a1a1a;
|
|
48
48
|
line-height: 1.1;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
.appSubtitle {
|
|
52
|
-
font-size:
|
|
52
|
+
font-size: 16px;
|
|
53
53
|
color: #888;
|
|
54
54
|
line-height: 1.3;
|
|
55
|
-
margin-top:
|
|
55
|
+
margin-top: 4px;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
.topActions {
|
|
@@ -206,6 +206,11 @@
|
|
|
206
206
|
display: flex;
|
|
207
207
|
flex-direction: column;
|
|
208
208
|
padding: 0;
|
|
209
|
+
overflow: hidden;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.sidebarContent {
|
|
213
|
+
flex: 1;
|
|
209
214
|
overflow-y: auto;
|
|
210
215
|
}
|
|
211
216
|
|
|
@@ -295,6 +300,12 @@
|
|
|
295
300
|
text-decoration: none;
|
|
296
301
|
}
|
|
297
302
|
|
|
303
|
+
.starredItem:link,
|
|
304
|
+
.starredItem:visited {
|
|
305
|
+
color: #555;
|
|
306
|
+
text-decoration: none;
|
|
307
|
+
}
|
|
308
|
+
|
|
298
309
|
.starredItem:hover {
|
|
299
310
|
background: #f5f5f5;
|
|
300
311
|
}
|
|
@@ -319,7 +330,7 @@
|
|
|
319
330
|
/* Sidebar footer */
|
|
320
331
|
|
|
321
332
|
.sidebarFooter {
|
|
322
|
-
|
|
333
|
+
flex-shrink: 0;
|
|
323
334
|
border-top: 1px solid #e5e5e5;
|
|
324
335
|
padding: 12px;
|
|
325
336
|
}
|
|
@@ -746,186 +757,6 @@
|
|
|
746
757
|
margin-top: 8px;
|
|
747
758
|
}
|
|
748
759
|
|
|
749
|
-
/* PAT Token dialog */
|
|
750
|
-
|
|
751
|
-
.dialog {
|
|
752
|
-
background: #fff;
|
|
753
|
-
border-radius: 12px;
|
|
754
|
-
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.12);
|
|
755
|
-
padding: 28px;
|
|
756
|
-
width: 480px;
|
|
757
|
-
max-width: 90vw;
|
|
758
|
-
position: relative;
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
.dialogClose {
|
|
762
|
-
position: absolute;
|
|
763
|
-
top: 16px;
|
|
764
|
-
right: 16px;
|
|
765
|
-
background: none;
|
|
766
|
-
border: none;
|
|
767
|
-
font-size: 24px;
|
|
768
|
-
line-height: 1;
|
|
769
|
-
color: #999;
|
|
770
|
-
cursor: pointer;
|
|
771
|
-
padding: 4px 8px;
|
|
772
|
-
border-radius: 4px;
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
.dialogClose:hover {
|
|
776
|
-
background: #f0f0f0;
|
|
777
|
-
color: #555;
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
.dialogTitle {
|
|
781
|
-
font-size: 18px;
|
|
782
|
-
font-weight: 600;
|
|
783
|
-
margin-bottom: 8px;
|
|
784
|
-
color: #1a1a1a;
|
|
785
|
-
padding-right: 32px;
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
.dialogDesc {
|
|
789
|
-
font-size: 16px;
|
|
790
|
-
color: #666;
|
|
791
|
-
margin-bottom: 16px;
|
|
792
|
-
line-height: 1.5;
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
.dialogSeparator {
|
|
796
|
-
border: none;
|
|
797
|
-
border-top: 1px solid #e5e5e5;
|
|
798
|
-
margin: 16px 0;
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
.dialogLabel {
|
|
802
|
-
display: block;
|
|
803
|
-
font-size: 16px;
|
|
804
|
-
font-weight: 600;
|
|
805
|
-
color: #1a1a1a;
|
|
806
|
-
margin-bottom: 6px;
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
.dialogInput {
|
|
810
|
-
width: 100%;
|
|
811
|
-
padding: 10px 12px;
|
|
812
|
-
border: 1px solid #e5e5e5;
|
|
813
|
-
border-radius: 6px;
|
|
814
|
-
font-size: 16px;
|
|
815
|
-
font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
|
|
816
|
-
margin-bottom: 16px;
|
|
817
|
-
box-sizing: border-box;
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
.dialogInput:focus {
|
|
821
|
-
outline: none;
|
|
822
|
-
border-color: #999;
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
.dialogActions {
|
|
826
|
-
display: flex;
|
|
827
|
-
justify-content: flex-end;
|
|
828
|
-
gap: 8px;
|
|
829
|
-
margin-top: 8px;
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
.btnSecondary {
|
|
833
|
-
padding: 8px 16px;
|
|
834
|
-
background: #fff;
|
|
835
|
-
border: 1px solid #e5e5e5;
|
|
836
|
-
border-radius: 6px;
|
|
837
|
-
font-size: 16px;
|
|
838
|
-
color: #555;
|
|
839
|
-
cursor: pointer;
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
.btnSecondary:hover {
|
|
843
|
-
background: #f5f5f5;
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
.btnPrimary {
|
|
847
|
-
padding: 8px 16px;
|
|
848
|
-
background: #1a1a1a;
|
|
849
|
-
border: none;
|
|
850
|
-
border-radius: 6px;
|
|
851
|
-
font-size: 16px;
|
|
852
|
-
font-weight: 600;
|
|
853
|
-
color: #fff;
|
|
854
|
-
cursor: pointer;
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
.btnPrimary:hover {
|
|
858
|
-
background: #333;
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
/* Token config card */
|
|
862
|
-
|
|
863
|
-
.tokenCard {
|
|
864
|
-
background: #f6f8fa;
|
|
865
|
-
border: 1px solid #e5e5e5;
|
|
866
|
-
border-radius: 8px;
|
|
867
|
-
padding: 14px 16px;
|
|
868
|
-
margin-bottom: 12px;
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
.tokenCardTitle {
|
|
872
|
-
font-size: 16px;
|
|
873
|
-
font-weight: 600;
|
|
874
|
-
color: #1a1a1a;
|
|
875
|
-
margin-bottom: 10px;
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
.tokenCardRow {
|
|
879
|
-
display: flex;
|
|
880
|
-
align-items: baseline;
|
|
881
|
-
gap: 6px;
|
|
882
|
-
font-size: 14px;
|
|
883
|
-
line-height: 1.8;
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
.tokenCardLabel {
|
|
887
|
-
color: #666;
|
|
888
|
-
flex-shrink: 0;
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
.tokenCardValue {
|
|
892
|
-
font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
|
|
893
|
-
font-size: 14px;
|
|
894
|
-
color: #1a1a1a;
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
.tokenLink {
|
|
898
|
-
display: inline-block;
|
|
899
|
-
font-size: 16px;
|
|
900
|
-
color: #0969da;
|
|
901
|
-
text-decoration: none;
|
|
902
|
-
margin-bottom: 4px;
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
.tokenLink:hover {
|
|
906
|
-
text-decoration: underline;
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
/* Warning banner */
|
|
910
|
-
|
|
911
|
-
.warningBanner {
|
|
912
|
-
display: flex;
|
|
913
|
-
align-items: center;
|
|
914
|
-
gap: 8px;
|
|
915
|
-
padding: 10px 12px;
|
|
916
|
-
background: #fff8c5;
|
|
917
|
-
border: 1px solid #d4a72c;
|
|
918
|
-
border-radius: 6px;
|
|
919
|
-
font-size: 14px;
|
|
920
|
-
color: #6a5300;
|
|
921
|
-
margin-bottom: 8px;
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
.warningIcon {
|
|
925
|
-
flex-shrink: 0;
|
|
926
|
-
font-size: 16px;
|
|
927
|
-
}
|
|
928
|
-
|
|
929
760
|
/* Muted thumbnail colors */
|
|
930
761
|
.thumbBlue { background: #f0f4f8; }
|
|
931
762
|
.thumbAmber { background: #faf5ee; }
|
|
@@ -1299,13 +1299,16 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1299
1299
|
e.preventDefault()
|
|
1300
1300
|
setSelectedWidgetIds(new Set())
|
|
1301
1301
|
}
|
|
1302
|
-
// Copy shortcut (
|
|
1303
|
-
// cmd+c → copy canvasId::
|
|
1302
|
+
// Copy shortcut (one or more widgets selected):
|
|
1303
|
+
// cmd+c → copy canvasId::id1,id2,... (for cross-canvas paste-duplicate)
|
|
1304
1304
|
const mod = e.metaKey || e.ctrlKey
|
|
1305
|
-
if (mod && e.key === 'c' && !e.shiftKey && selectedWidgetIds.size
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1305
|
+
if (mod && e.key === 'c' && !e.shiftKey && selectedWidgetIds.size >= 1) {
|
|
1306
|
+
// Filter out non-duplicable widgets (jsx- component widgets are code)
|
|
1307
|
+
const copyableIds = [...selectedWidgetIds].filter(id => !id.startsWith('jsx-'))
|
|
1308
|
+
if (copyableIds.length > 0) {
|
|
1309
|
+
e.preventDefault()
|
|
1310
|
+
navigator.clipboard.writeText(`${canvasId}::${copyableIds.join(',')}`).catch(() => {})
|
|
1311
|
+
}
|
|
1309
1312
|
}
|
|
1310
1313
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
1311
1314
|
e.preventDefault()
|
|
@@ -1443,36 +1446,72 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1443
1446
|
const text = e.clipboardData?.getData('text/plain')?.trim()
|
|
1444
1447
|
if (!text) return
|
|
1445
1448
|
|
|
1446
|
-
// Detect canvasId::widgetId format for widget duplication
|
|
1449
|
+
// Detect canvasId::widgetId or canvasId::id1,id2,id3 format for widget duplication
|
|
1447
1450
|
// Also supports legacy canvasId/widgetId for basenames without slashes,
|
|
1448
1451
|
// but only when the second segment looks like a widget ID (type-hash).
|
|
1449
1452
|
const widgetRefMatch = text.match(/^(.+)::([^:]+)$/) || (text.indexOf('::') === -1 && text.match(/^([^/]+)\/((?:sticky-note|markdown|prototype|link-preview|figma-embed|component|image)-[a-z0-9]+)$/))
|
|
1450
1453
|
if (widgetRefMatch) {
|
|
1451
1454
|
e.preventDefault()
|
|
1452
|
-
const [, sourceCanvas,
|
|
1453
|
-
|
|
1454
|
-
if (
|
|
1455
|
+
const [, sourceCanvas, sourceWidgetRef] = widgetRefMatch
|
|
1456
|
+
const sourceWidgetIds = sourceWidgetRef.split(',').filter(id => !id.startsWith('jsx-'))
|
|
1457
|
+
if (sourceWidgetIds.length === 0) return
|
|
1458
|
+
|
|
1455
1459
|
try {
|
|
1456
|
-
|
|
1460
|
+
// Resolve source widgets in canvas order
|
|
1461
|
+
let sourceList
|
|
1457
1462
|
if (sourceCanvas === canvasId) {
|
|
1458
|
-
|
|
1463
|
+
sourceList = localWidgets ?? []
|
|
1459
1464
|
} else {
|
|
1460
1465
|
const canvasData = await getCanvasApi(sourceCanvas)
|
|
1461
|
-
|
|
1466
|
+
sourceList = canvasData?.widgets ?? []
|
|
1462
1467
|
}
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1468
|
+
|
|
1469
|
+
const sourceWidgets = sourceList.filter(w => sourceWidgetIds.includes(w.id))
|
|
1470
|
+
if (sourceWidgets.length === 0) return
|
|
1471
|
+
|
|
1472
|
+
// Compute bounding box of source widgets for relative positioning
|
|
1473
|
+
const fallback = { width: 200, height: 150 }
|
|
1474
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
|
1475
|
+
for (const w of sourceWidgets) {
|
|
1476
|
+
const wx = w.position?.x ?? 0
|
|
1477
|
+
const wy = w.position?.y ?? 0
|
|
1478
|
+
const ww = w.props?.width ?? WIDGET_FALLBACK_SIZES[w.type]?.width ?? fallback.width
|
|
1479
|
+
const wh = w.props?.height ?? WIDGET_FALLBACK_SIZES[w.type]?.height ?? fallback.height
|
|
1480
|
+
if (wx < minX) minX = wx
|
|
1481
|
+
if (wy < minY) minY = wy
|
|
1482
|
+
if (wx + ww > maxX) maxX = wx + ww
|
|
1483
|
+
if (wy + wh > maxY) maxY = wy + wh
|
|
1484
|
+
}
|
|
1485
|
+
const groupW = maxX - minX
|
|
1486
|
+
const groupH = maxY - minY
|
|
1487
|
+
|
|
1488
|
+
// Center the group in the viewport
|
|
1489
|
+
const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
|
|
1490
|
+
const baseX = Math.round(center.x - groupW / 2)
|
|
1491
|
+
const baseY = Math.round(center.y - groupH / 2)
|
|
1492
|
+
|
|
1493
|
+
// Single undo snapshot for the entire paste
|
|
1494
|
+
undoRedo.snapshot(stateRef.current, 'add')
|
|
1495
|
+
|
|
1496
|
+
// Paste all widgets, collecting new IDs for selection
|
|
1497
|
+
const newWidgets = []
|
|
1498
|
+
for (const w of sourceWidgets) {
|
|
1499
|
+
const relX = (w.position?.x ?? 0) - minX
|
|
1500
|
+
const relY = (w.position?.y ?? 0) - minY
|
|
1467
1501
|
const result = await addWidgetApi(canvasId, {
|
|
1468
|
-
type:
|
|
1469
|
-
props: { ...
|
|
1470
|
-
position:
|
|
1502
|
+
type: w.type,
|
|
1503
|
+
props: { ...w.props },
|
|
1504
|
+
position: { x: baseX + relX, y: baseY + relY },
|
|
1471
1505
|
})
|
|
1472
1506
|
if (result.success && result.widget) {
|
|
1473
|
-
|
|
1507
|
+
newWidgets.push(result.widget)
|
|
1474
1508
|
}
|
|
1475
1509
|
}
|
|
1510
|
+
|
|
1511
|
+
if (newWidgets.length > 0) {
|
|
1512
|
+
setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
|
|
1513
|
+
setSelectedWidgetIds(new Set(newWidgets.map(w => w.id)))
|
|
1514
|
+
}
|
|
1476
1515
|
} catch (err) {
|
|
1477
1516
|
console.error('[canvas] Failed to paste widget reference:', err)
|
|
1478
1517
|
}
|
|
@@ -1502,6 +1541,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1502
1541
|
if (result.success && result.widget) {
|
|
1503
1542
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
1504
1543
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1544
|
+
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
1505
1545
|
}
|
|
1506
1546
|
} catch (err) {
|
|
1507
1547
|
console.error('[canvas] Failed to add widget from paste:', err)
|
|
@@ -1915,7 +1955,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1915
1955
|
<>
|
|
1916
1956
|
<div className={styles.canvasTitle}>
|
|
1917
1957
|
<h1 className={styles.canvasTitleStatic}>{canvasMeta?.title || canvas?.title || canvasId.split('/').pop()}</h1>
|
|
1918
|
-
<PageSelector currentName={canvasId} pages={siblingPages} />
|
|
1958
|
+
<PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
|
|
1919
1959
|
{isLocalDev && (
|
|
1920
1960
|
<span className={styles.localEditingLabel}>Local editing</span>
|
|
1921
1961
|
)}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
display: flex;
|
|
4
4
|
align-items: center;
|
|
5
5
|
justify-content: center;
|
|
6
|
-
min-height: 100vh;
|
|
6
|
+
min-height: calc(100vh - var(--sb-branch-bar-height, 0px));
|
|
7
7
|
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
8
8
|
color: var(--fgColor-muted, #656d76);
|
|
9
9
|
font-size: 16px;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
.canvasScroll {
|
|
18
18
|
width: 100vw;
|
|
19
|
-
height: 100vh;
|
|
19
|
+
height: calc(100vh - var(--sb-branch-bar-height, 0px));
|
|
20
20
|
overflow: auto;
|
|
21
21
|
background-color: var(--sb--canvas-bg, var(--bgColor-muted, #f6f8fa));
|
|
22
22
|
}
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
|
|
44
44
|
.canvasTitle {
|
|
45
45
|
position: fixed;
|
|
46
|
-
top: 12px;
|
|
46
|
+
top: calc(12px + var(--sb-branch-bar-height, 0px));
|
|
47
47
|
left: 16px;
|
|
48
48
|
z-index: 10;
|
|
49
49
|
display: flex;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useRef, useState, useEffect } from 'react'
|
|
2
|
+
import { createCanvas } from './canvasApi.js'
|
|
2
3
|
import styles from './PageSelector.module.css'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -6,16 +7,23 @@ import styles from './PageSelector.module.css'
|
|
|
6
7
|
* Only renders when 2+ sibling pages exist.
|
|
7
8
|
* Uses window.location for navigation to avoid requiring a Router context.
|
|
8
9
|
*
|
|
9
|
-
* @param {{ currentName: string, pages: Array<{ name: string, route: string, title: string }
|
|
10
|
+
* @param {{ currentName: string, pages: Array<{ name: string, route: string, title: string }>, isLocalDev?: boolean }} props
|
|
10
11
|
*/
|
|
11
|
-
export default function PageSelector({ currentName, pages }) {
|
|
12
|
+
export default function PageSelector({ currentName, pages, isLocalDev = false }) {
|
|
12
13
|
const [open, setOpen] = useState(false)
|
|
14
|
+
const [adding, setAdding] = useState(false)
|
|
15
|
+
const [newName, setNewName] = useState('')
|
|
16
|
+
const [creating, setCreating] = useState(false)
|
|
13
17
|
const containerRef = useRef(null)
|
|
18
|
+
const inputRef = useRef(null)
|
|
14
19
|
|
|
15
20
|
const currentPage = pages.find((p) => p.name === currentName)
|
|
16
21
|
const currentLabel = currentPage?.title || currentName.split('/').pop()
|
|
17
22
|
const currentIndex = pages.findIndex((p) => p.name === currentName)
|
|
18
23
|
|
|
24
|
+
// Derive folder from currentName (e.g. "Examples/Design Overview" → "Examples")
|
|
25
|
+
const folder = currentName.includes('/') ? currentName.split('/')[0] : ''
|
|
26
|
+
|
|
19
27
|
const handleSelect = useCallback(
|
|
20
28
|
(page) => {
|
|
21
29
|
if (page.name !== currentName) {
|
|
@@ -27,12 +35,48 @@ export default function PageSelector({ currentName, pages }) {
|
|
|
27
35
|
[currentName],
|
|
28
36
|
)
|
|
29
37
|
|
|
38
|
+
const handleAddPage = useCallback(async () => {
|
|
39
|
+
const trimmed = newName.trim()
|
|
40
|
+
if (!trimmed || creating) return
|
|
41
|
+
setCreating(true)
|
|
42
|
+
try {
|
|
43
|
+
const result = await createCanvas({ name: trimmed, folder: folder || undefined })
|
|
44
|
+
if (result.error) {
|
|
45
|
+
console.error('Failed to create canvas page:', result.error)
|
|
46
|
+
setCreating(false)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
// Navigate to the new page once Vite picks it up
|
|
50
|
+
const kebab = trimmed
|
|
51
|
+
.replace(/[^a-zA-Z0-9\s_-]/g, '')
|
|
52
|
+
.trim()
|
|
53
|
+
.replace(/[\s_]+/g, '-')
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.replace(/-+/g, '-')
|
|
56
|
+
.replace(/^-|-$/g, '')
|
|
57
|
+
const route = folder ? `/${folder}/${kebab}` : `/${kebab}`
|
|
58
|
+
const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
|
|
59
|
+
// Small delay to let Vite detect the new file
|
|
60
|
+
setTimeout(() => { window.location.href = base + route }, 600)
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error('Failed to create canvas page:', err)
|
|
63
|
+
setCreating(false)
|
|
64
|
+
}
|
|
65
|
+
}, [newName, folder, creating])
|
|
66
|
+
|
|
67
|
+
// Focus input when entering add mode
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (adding && inputRef.current) inputRef.current.focus()
|
|
70
|
+
}, [adding])
|
|
71
|
+
|
|
30
72
|
// Close on outside click
|
|
31
73
|
useEffect(() => {
|
|
32
74
|
if (!open) return
|
|
33
75
|
function handleClick(e) {
|
|
34
76
|
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
35
77
|
setOpen(false)
|
|
78
|
+
setAdding(false)
|
|
79
|
+
setNewName('')
|
|
36
80
|
}
|
|
37
81
|
}
|
|
38
82
|
document.addEventListener('mousedown', handleClick)
|
|
@@ -43,11 +87,18 @@ export default function PageSelector({ currentName, pages }) {
|
|
|
43
87
|
useEffect(() => {
|
|
44
88
|
if (!open) return
|
|
45
89
|
function handleKey(e) {
|
|
46
|
-
if (e.key === 'Escape')
|
|
90
|
+
if (e.key === 'Escape') {
|
|
91
|
+
if (adding) {
|
|
92
|
+
setAdding(false)
|
|
93
|
+
setNewName('')
|
|
94
|
+
} else {
|
|
95
|
+
setOpen(false)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
47
98
|
}
|
|
48
99
|
document.addEventListener('keydown', handleKey)
|
|
49
100
|
return () => document.removeEventListener('keydown', handleKey)
|
|
50
|
-
}, [open])
|
|
101
|
+
}, [open, adding])
|
|
51
102
|
|
|
52
103
|
if (!pages || pages.length < 2) return null
|
|
53
104
|
|
|
@@ -95,6 +146,51 @@ export default function PageSelector({ currentName, pages }) {
|
|
|
95
146
|
{page.title}
|
|
96
147
|
</li>
|
|
97
148
|
))}
|
|
149
|
+
{isLocalDev && (
|
|
150
|
+
<>
|
|
151
|
+
<li className={styles.separator} role="separator" />
|
|
152
|
+
{adding ? (
|
|
153
|
+
<li className={styles.addForm}>
|
|
154
|
+
<input
|
|
155
|
+
ref={inputRef}
|
|
156
|
+
className={styles.addInput}
|
|
157
|
+
type="text"
|
|
158
|
+
placeholder="Page name"
|
|
159
|
+
value={newName}
|
|
160
|
+
onChange={(e) => setNewName(e.target.value)}
|
|
161
|
+
onKeyDown={(e) => {
|
|
162
|
+
if (e.key === 'Enter') {
|
|
163
|
+
e.preventDefault()
|
|
164
|
+
handleAddPage()
|
|
165
|
+
}
|
|
166
|
+
}}
|
|
167
|
+
disabled={creating}
|
|
168
|
+
/>
|
|
169
|
+
<button
|
|
170
|
+
className={styles.addSubmit}
|
|
171
|
+
onClick={handleAddPage}
|
|
172
|
+
disabled={!newName.trim() || creating}
|
|
173
|
+
>
|
|
174
|
+
{creating ? '…' : 'Add'}
|
|
175
|
+
</button>
|
|
176
|
+
</li>
|
|
177
|
+
) : (
|
|
178
|
+
<li
|
|
179
|
+
className={styles.addItem}
|
|
180
|
+
onClick={() => setAdding(true)}
|
|
181
|
+
tabIndex={0}
|
|
182
|
+
onKeyDown={(e) => {
|
|
183
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
184
|
+
e.preventDefault()
|
|
185
|
+
setAdding(true)
|
|
186
|
+
}
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
+ Add new page
|
|
190
|
+
</li>
|
|
191
|
+
)}
|
|
192
|
+
</>
|
|
193
|
+
)}
|
|
98
194
|
</ul>
|
|
99
195
|
)}
|
|
100
196
|
</nav>
|
|
@@ -91,3 +91,68 @@
|
|
|
91
91
|
.itemActive:hover {
|
|
92
92
|
background: var(--bgColor-accent-muted, #ddf4ff);
|
|
93
93
|
}
|
|
94
|
+
|
|
95
|
+
.separator {
|
|
96
|
+
height: 1px;
|
|
97
|
+
background: var(--borderColor-default, rgba(0, 0, 0, 0.15));
|
|
98
|
+
margin: 4px 8px;
|
|
99
|
+
list-style: none;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.addItem {
|
|
103
|
+
padding: 6px 10px;
|
|
104
|
+
border-radius: 4px;
|
|
105
|
+
cursor: pointer;
|
|
106
|
+
white-space: nowrap;
|
|
107
|
+
color: var(--fgColor-muted, #656d76);
|
|
108
|
+
font-size: 12px;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.addItem:hover {
|
|
112
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
113
|
+
color: var(--fgColor-default, #1f2328);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.addForm {
|
|
117
|
+
display: flex;
|
|
118
|
+
gap: 4px;
|
|
119
|
+
padding: 4px 6px;
|
|
120
|
+
list-style: none;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.addInput {
|
|
124
|
+
flex: 1;
|
|
125
|
+
min-width: 0;
|
|
126
|
+
padding: 4px 8px;
|
|
127
|
+
border: 1px solid var(--borderColor-default, rgba(0, 0, 0, 0.15));
|
|
128
|
+
border-radius: 4px;
|
|
129
|
+
font-size: 12px;
|
|
130
|
+
font-family: inherit;
|
|
131
|
+
outline: none;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.addInput:focus {
|
|
135
|
+
border-color: var(--focus-outlineColor, #0969da);
|
|
136
|
+
box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.3);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.addSubmit {
|
|
140
|
+
padding: 4px 10px;
|
|
141
|
+
border: none;
|
|
142
|
+
border-radius: 4px;
|
|
143
|
+
background: var(--bgColor-accent-emphasis, #0969da);
|
|
144
|
+
color: #fff;
|
|
145
|
+
font-size: 12px;
|
|
146
|
+
font-family: inherit;
|
|
147
|
+
cursor: pointer;
|
|
148
|
+
white-space: nowrap;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.addSubmit:hover {
|
|
152
|
+
opacity: 0.9;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.addSubmit:disabled {
|
|
156
|
+
opacity: 0.5;
|
|
157
|
+
cursor: default;
|
|
158
|
+
}
|
package/src/index.js
CHANGED
|
@@ -35,7 +35,7 @@ export { FormContext } from './context/FormContext.js'
|
|
|
35
35
|
// ModeSwitch and ToolbarShell UI moved to @dfosco/storyboard-svelte-ui
|
|
36
36
|
|
|
37
37
|
// Viewfinder dashboard
|
|
38
|
-
export { default as Viewfinder } from './
|
|
38
|
+
export { default as Viewfinder } from './Viewfinder.jsx'
|
|
39
39
|
|
|
40
40
|
// Canvas
|
|
41
41
|
export { default as CanvasPage } from './canvas/CanvasPage.jsx'
|