@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 CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "4.0.0-beta.39",
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.39",
8
- "@dfosco/tiny-canvas": "4.0.0-beta.39",
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
- * ViewfinderNew — SaaS-style homescreen for Storyboard.
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 './ViewfinderNew.module.css'
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 &gt; {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 &gt; Discussions &gt; 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 isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true
717
-
718
- const switchBranch = async (branch) => {
610
+ const switchBranch = (branch) => {
719
611
  setSwitching(branch)
720
- setSwitchError(null)
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 ViewfinderNew({
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
- + Create
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={() => setShowPAT(true)}>
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
- /* ViewfinderNew — SaaS-style homescreen layout */
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: 40px;
32
- height: 40px;
33
- background: #1a1a1a;
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: 14px;
52
+ font-size: 16px;
53
53
  color: #888;
54
54
  line-height: 1.3;
55
- margin-top: 2px;
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
- margin-top: auto;
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 (single widget selected):
1303
- // cmd+c → copy canvasId::widgetId (for cross-canvas paste-duplicate)
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 === 1) {
1306
- const widgetId = [...selectedWidgetIds][0]
1307
- e.preventDefault()
1308
- navigator.clipboard.writeText(`${canvasId}::${widgetId}`).catch(() => {})
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 (cross-canvas copy-paste)
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, sourceWidgetId] = widgetRefMatch
1453
- // Component widgets are code, not duplicable data — silently consume the ref
1454
- if (sourceWidgetId.startsWith('jsx-')) return
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
- let sourceWidget = null
1460
+ // Resolve source widgets in canvas order
1461
+ let sourceList
1457
1462
  if (sourceCanvas === canvasId) {
1458
- sourceWidget = (localWidgets ?? []).find(w => w.id === sourceWidgetId)
1463
+ sourceList = localWidgets ?? []
1459
1464
  } else {
1460
1465
  const canvasData = await getCanvasApi(sourceCanvas)
1461
- sourceWidget = (canvasData?.widgets ?? []).find(w => w.id === sourceWidgetId)
1466
+ sourceList = canvasData?.widgets ?? []
1462
1467
  }
1463
- if (sourceWidget) {
1464
- const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1465
- const pos = centerPositionForWidget(center, sourceWidget.type, sourceWidget.props)
1466
- undoRedo.snapshot(stateRef.current, 'add')
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: sourceWidget.type,
1469
- props: { ...sourceWidget.props },
1470
- position: pos,
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
- setLocalWidgets((prev) => [...(prev || []), result.widget])
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 }> }} props
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') setOpen(false)
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 './ViewfinderNew.jsx'
38
+ export { default as Viewfinder } from './Viewfinder.jsx'
39
39
 
40
40
  // Canvas
41
41
  export { default as CanvasPage } from './canvas/CanvasPage.jsx'