@dfosco/storyboard-react 2.8.0 → 3.1.0

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,9 +1,11 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "2.8.0",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "2.8.0",
6
+ "@dfosco/storyboard-core": "3.1.0",
7
+ "@dfosco/tiny-canvas": "^1.1.0",
8
+ "@neodrag/react": "^2.3.1",
7
9
  "glob": "^11.0.0",
8
10
  "jsonc-parser": "^3.3.1"
9
11
  },
@@ -24,6 +26,7 @@
24
26
  "exports": {
25
27
  ".": "./src/index.js",
26
28
  "./vite": "./src/vite/data-plugin.js",
27
- "./hash-preserver": "./src/hashPreserver.js"
29
+ "./hash-preserver": "./src/hashPreserver.js",
30
+ "./canvas/CanvasPage": "./src/canvas/CanvasPage.jsx"
28
31
  }
29
32
  }
@@ -1,5 +1,5 @@
1
1
 
2
- import { useRef, useEffect } from 'react'
2
+ import { useRef, useEffect, useMemo } from 'react'
3
3
 
4
4
  /**
5
5
  * Viewfinder — thin React wrapper around the Svelte Viewfinder component.
@@ -23,9 +23,10 @@ export default function Viewfinder({ pageModules = {}, basePath, title = 'Storyb
23
23
 
24
24
  const shouldHideDefault = hideDefaultFlow ?? hideDefaultScene
25
25
 
26
- const knownRoutes = Object.keys(pageModules)
26
+ const knownRoutes = useMemo(() => Object.keys(pageModules)
27
27
  .map(p => p.replace('/src/prototypes/', '').replace('.jsx', ''))
28
- .filter(n => !n.startsWith('_') && n !== 'index' && n !== 'viewfinder')
28
+ .filter(n => !n.startsWith('_') && n !== 'index' && n !== 'viewfinder'),
29
+ [pageModules])
29
30
 
30
31
  useEffect(() => {
31
32
  if (!containerRef.current) return
@@ -53,7 +54,7 @@ export default function Viewfinder({ pageModules = {}, basePath, title = 'Storyb
53
54
  handleRef.current = null
54
55
  }
55
56
  }
56
- }, [title, subtitle, basePath, showThumbnails, shouldHideDefault])
57
+ }, [title, subtitle, basePath, knownRoutes, showThumbnails, shouldHideDefault])
57
58
 
58
59
  return <div ref={containerRef} style={{ minHeight: '100vh' }} />
59
60
  }
@@ -0,0 +1,123 @@
1
+ import { useState, useRef, useEffect, useCallback } from 'react'
2
+ import styles from './CanvasControls.module.css'
3
+
4
+ const ZOOM_STEPS = [25, 50, 75, 100, 125, 150, 200]
5
+ export const ZOOM_MIN = ZOOM_STEPS[0]
6
+ export const ZOOM_MAX = ZOOM_STEPS[ZOOM_STEPS.length - 1]
7
+
8
+ const WIDGET_TYPES = [
9
+ { type: 'sticky-note', label: 'Sticky Note' },
10
+ { type: 'markdown', label: 'Markdown' },
11
+ { type: 'prototype', label: 'Prototype' },
12
+ ]
13
+
14
+ /**
15
+ * Focused canvas toolbar — bottom-left controls for zoom and widget creation.
16
+ */
17
+ export default function CanvasControls({ zoom, onZoomChange, onAddWidget }) {
18
+ const [menuOpen, setMenuOpen] = useState(false)
19
+ const menuRef = useRef(null)
20
+
21
+ // Close menu on outside click
22
+ useEffect(() => {
23
+ if (!menuOpen) return
24
+ function handlePointerDown(e) {
25
+ if (menuRef.current && !menuRef.current.contains(e.target)) {
26
+ setMenuOpen(false)
27
+ }
28
+ }
29
+ document.addEventListener('pointerdown', handlePointerDown)
30
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
31
+ }, [menuOpen])
32
+
33
+ const zoomIn = useCallback(() => {
34
+ onZoomChange((z) => {
35
+ const next = ZOOM_STEPS.find((s) => s > z)
36
+ return next ?? ZOOM_MAX
37
+ })
38
+ }, [onZoomChange])
39
+
40
+ const zoomOut = useCallback(() => {
41
+ onZoomChange((z) => {
42
+ const next = [...ZOOM_STEPS].reverse().find((s) => s < z)
43
+ return next ?? ZOOM_MIN
44
+ })
45
+ }, [onZoomChange])
46
+
47
+ const resetZoom = useCallback(() => {
48
+ onZoomChange(100)
49
+ }, [onZoomChange])
50
+
51
+ const handleAddWidget = useCallback((type) => {
52
+ onAddWidget(type)
53
+ setMenuOpen(false)
54
+ }, [onAddWidget])
55
+
56
+ return (
57
+ <div className={styles.toolbar} role="toolbar" aria-label="Canvas controls">
58
+ {/* Create widget */}
59
+ <div ref={menuRef} className={styles.createGroup}>
60
+ <button
61
+ className={styles.btn}
62
+ onClick={() => setMenuOpen((v) => !v)}
63
+ aria-label="Add widget"
64
+ aria-expanded={menuOpen}
65
+ title="Add widget"
66
+ >
67
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
68
+ <path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z" />
69
+ </svg>
70
+ </button>
71
+ {menuOpen && (
72
+ <div className={styles.menu} role="menu">
73
+ <div className={styles.menuLabel}>Add to canvas</div>
74
+ {WIDGET_TYPES.map((wt) => (
75
+ <button
76
+ key={wt.type}
77
+ className={styles.menuItem}
78
+ role="menuitem"
79
+ onClick={() => handleAddWidget(wt.type)}
80
+ >
81
+ {wt.label}
82
+ </button>
83
+ ))}
84
+ </div>
85
+ )}
86
+ </div>
87
+
88
+ <div className={styles.divider} />
89
+
90
+ {/* Zoom controls */}
91
+ <button
92
+ className={styles.btn}
93
+ onClick={zoomOut}
94
+ disabled={zoom <= ZOOM_MIN}
95
+ aria-label="Zoom out"
96
+ title="Zoom out"
97
+ >
98
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
99
+ <path d="M2.75 7.25h10.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5Z" />
100
+ </svg>
101
+ </button>
102
+ <button
103
+ className={styles.zoomLevel}
104
+ onClick={resetZoom}
105
+ title="Reset to 100%"
106
+ aria-label={`Zoom ${zoom}%, click to reset`}
107
+ >
108
+ {zoom}%
109
+ </button>
110
+ <button
111
+ className={styles.btn}
112
+ onClick={zoomIn}
113
+ disabled={zoom >= ZOOM_MAX}
114
+ aria-label="Zoom in"
115
+ title="Zoom in"
116
+ >
117
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
118
+ <path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z" />
119
+ </svg>
120
+ </button>
121
+ </div>
122
+ )
123
+ }
@@ -0,0 +1,133 @@
1
+ .toolbar {
2
+ position: fixed;
3
+ bottom: 24px;
4
+ left: 24px;
5
+ z-index: 9998;
6
+ display: flex;
7
+ align-items: center;
8
+ gap: 2px;
9
+ padding: 4px;
10
+ background: rgba(255, 255, 255, 0.92);
11
+ backdrop-filter: blur(12px);
12
+ -webkit-backdrop-filter: blur(12px);
13
+ border: 1px solid rgba(0, 0, 0, 0.12);
14
+ border-radius: 10px;
15
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
16
+ font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
17
+ }
18
+
19
+ @media (prefers-color-scheme: dark) {
20
+ .toolbar {
21
+ background: rgba(22, 27, 34, 0.88);
22
+ border-color: rgba(255, 255, 255, 0.1);
23
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
24
+ }
25
+ }
26
+
27
+ .btn {
28
+ all: unset;
29
+ cursor: pointer;
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: center;
33
+ width: 32px;
34
+ height: 32px;
35
+ border-radius: 8px;
36
+ color: var(--fgColor-default, #1f2328);
37
+ transition: background 120ms;
38
+ }
39
+
40
+ .btn:hover:not(:disabled) {
41
+ background: var(--bgColor-muted, #f6f8fa);
42
+ }
43
+
44
+ .btn:active:not(:disabled) {
45
+ background: var(--bgColor-neutral-muted, #eaeef2);
46
+ }
47
+
48
+ .btn:disabled {
49
+ opacity: 0.35;
50
+ cursor: default;
51
+ }
52
+
53
+ .zoomLevel {
54
+ all: unset;
55
+ cursor: pointer;
56
+ display: flex;
57
+ align-items: center;
58
+ justify-content: center;
59
+ min-width: 44px;
60
+ height: 32px;
61
+ padding: 0 4px;
62
+ border-radius: 8px;
63
+ font-size: 12px;
64
+ font-weight: 500;
65
+ font-variant-numeric: tabular-nums;
66
+ color: var(--fgColor-muted, #656d76);
67
+ transition: background 120ms;
68
+ }
69
+
70
+ .zoomLevel:hover {
71
+ background: var(--bgColor-muted, #f6f8fa);
72
+ color: var(--fgColor-default, #1f2328);
73
+ }
74
+
75
+ .divider {
76
+ width: 1px;
77
+ height: 20px;
78
+ margin: 0 2px;
79
+ background: var(--borderColor-muted, #d8dee4);
80
+ }
81
+
82
+ /* Create widget menu */
83
+ .createGroup {
84
+ position: relative;
85
+ }
86
+
87
+ .menu {
88
+ position: absolute;
89
+ bottom: calc(100% + 8px);
90
+ left: 0;
91
+ min-width: 160px;
92
+ padding: 4px;
93
+ background: rgba(255, 255, 255, 0.95);
94
+ backdrop-filter: blur(12px);
95
+ -webkit-backdrop-filter: blur(12px);
96
+ border: 1px solid rgba(0, 0, 0, 0.12);
97
+ border-radius: 10px;
98
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
99
+ z-index: 1;
100
+ }
101
+
102
+ @media (prefers-color-scheme: dark) {
103
+ .menu {
104
+ background: rgba(22, 27, 34, 0.92);
105
+ border-color: rgba(255, 255, 255, 0.1);
106
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
107
+ }
108
+ }
109
+
110
+ .menuLabel {
111
+ padding: 6px 10px 4px;
112
+ font-size: 11px;
113
+ font-weight: 600;
114
+ color: var(--fgColor-muted, #656d76);
115
+ text-transform: uppercase;
116
+ letter-spacing: 0.4px;
117
+ }
118
+
119
+ .menuItem {
120
+ all: unset;
121
+ cursor: pointer;
122
+ display: block;
123
+ width: 100%;
124
+ padding: 6px 10px;
125
+ font-size: 13px;
126
+ color: var(--fgColor-default, #1f2328);
127
+ border-radius: 6px;
128
+ box-sizing: border-box;
129
+ }
130
+
131
+ .menuItem:hover {
132
+ background: var(--bgColor-muted, #f6f8fa);
133
+ }