@dfosco/storyboard-react 2.7.1 → 3.0.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.
@@ -0,0 +1,33 @@
1
+ import WidgetWrapper from './WidgetWrapper.jsx'
2
+ import { readProp, prototypeEmbedSchema } from './widgetProps.js'
3
+ import styles from './PrototypeEmbed.module.css'
4
+
5
+ export default function PrototypeEmbed({ props, onRemove }) {
6
+ const src = readProp(props, 'src', prototypeEmbedSchema)
7
+ const width = readProp(props, 'width', prototypeEmbedSchema)
8
+ const height = readProp(props, 'height', prototypeEmbedSchema)
9
+ const label = readProp(props, 'label', prototypeEmbedSchema) || src
10
+
11
+ // Build the full iframe URL using the app's base path
12
+ const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
13
+ const iframeSrc = src ? `${basePath}${src}` : ''
14
+
15
+ return (
16
+ <WidgetWrapper onRemove={onRemove}>
17
+ <div className={styles.embed} style={{ width, height }}>
18
+ {iframeSrc ? (
19
+ <iframe
20
+ src={iframeSrc}
21
+ className={styles.iframe}
22
+ title={label || 'Prototype embed'}
23
+ sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
24
+ />
25
+ ) : (
26
+ <div className={styles.empty}>
27
+ <p>No prototype URL set</p>
28
+ </div>
29
+ )}
30
+ </div>
31
+ </WidgetWrapper>
32
+ )
33
+ }
@@ -0,0 +1,26 @@
1
+ .embed {
2
+ position: relative;
3
+ overflow: hidden;
4
+ background: var(--bgColor-default, #ffffff);
5
+ }
6
+
7
+ .iframe {
8
+ width: 100%;
9
+ height: 100%;
10
+ border: none;
11
+ display: block;
12
+ }
13
+
14
+ .empty {
15
+ display: flex;
16
+ align-items: center;
17
+ justify-content: center;
18
+ height: 100%;
19
+ color: var(--fgColor-muted, #656d76);
20
+ font-size: 14px;
21
+ font-style: italic;
22
+ }
23
+
24
+ .empty p {
25
+ margin: 0;
26
+ }
@@ -0,0 +1,106 @@
1
+ import { useState, useRef, useEffect, useCallback } from 'react'
2
+ import { readProp, stickyNoteSchema } from './widgetProps.js'
3
+ import styles from './StickyNote.module.css'
4
+
5
+ const COLORS = {
6
+ yellow: { bg: '#fff8c5', border: '#d4a72c', dot: '#e8c846' },
7
+ blue: { bg: '#ddf4ff', border: '#54aeff', dot: '#74b9ff' },
8
+ green: { bg: '#dafbe1', border: '#4ac26b', dot: '#6dd58c' },
9
+ pink: { bg: '#ffebe9', border: '#ff8182', dot: '#ff9a9e' },
10
+ purple: { bg: '#fbefff', border: '#c297ff', dot: '#d4a8ff' },
11
+ orange: { bg: '#fff1e5', border: '#d18616', dot: '#e8a844' },
12
+ }
13
+
14
+ export default function StickyNote({ props, onUpdate, onRemove }) {
15
+ const text = readProp(props, 'text', stickyNoteSchema)
16
+ const color = readProp(props, 'color', stickyNoteSchema)
17
+ const palette = COLORS[color] ?? COLORS.yellow
18
+ const textareaRef = useRef(null)
19
+ const [editing, setEditing] = useState(false)
20
+
21
+ useEffect(() => {
22
+ if (editing && textareaRef.current) {
23
+ textareaRef.current.focus()
24
+ textareaRef.current.selectionStart = textareaRef.current.value.length
25
+ }
26
+ }, [editing])
27
+
28
+ const handleTextChange = useCallback((e) => {
29
+ onUpdate?.({ text: e.target.value })
30
+ }, [onUpdate])
31
+
32
+ const handleColorChange = useCallback((newColor) => {
33
+ onUpdate?.({ color: newColor })
34
+ }, [onUpdate])
35
+
36
+ return (
37
+ <div className={styles.container}>
38
+ <article
39
+ className={styles.sticky}
40
+ style={{ '--sticky-bg': palette.bg, '--sticky-border': palette.border }}
41
+ >
42
+ {onRemove && (
43
+ <button
44
+ className={styles.removeBtn}
45
+ onClick={(e) => { e.stopPropagation(); onRemove() }}
46
+ title="Remove"
47
+ aria-label="Remove sticky note"
48
+ >×</button>
49
+ )}
50
+ {editing ? (
51
+ <textarea
52
+ ref={textareaRef}
53
+ className={styles.textarea}
54
+ value={text}
55
+ onChange={handleTextChange}
56
+ onBlur={() => setEditing(false)}
57
+ onMouseDown={(e) => e.stopPropagation()}
58
+ onPointerDown={(e) => e.stopPropagation()}
59
+ onKeyDown={(e) => {
60
+ if (e.key === 'Escape') setEditing(false)
61
+ }}
62
+ placeholder="Type here…"
63
+ />
64
+ ) : (
65
+ <p
66
+ className={styles.text}
67
+ onDoubleClick={() => setEditing(true)}
68
+ role="button"
69
+ tabIndex={0}
70
+ onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}
71
+ >
72
+ {text || 'Double-click to edit…'}
73
+ </p>
74
+ )}
75
+ </article>
76
+
77
+ {/* Color picker — dot trigger below the sticky */}
78
+ <div
79
+ className={styles.pickerArea}
80
+ onMouseDown={(e) => e.stopPropagation()}
81
+ onPointerDown={(e) => e.stopPropagation()}
82
+ >
83
+ <span
84
+ className={styles.pickerDot}
85
+ style={{ background: palette.dot }}
86
+ />
87
+ <div className={styles.pickerPopup}>
88
+ {Object.entries(COLORS).map(([colorName, c]) => (
89
+ <button
90
+ key={colorName}
91
+ className={`${styles.colorDot} ${colorName === color ? styles.active : ''}`}
92
+ style={{ background: c.bg, borderColor: c.border }}
93
+ onClick={(e) => {
94
+ e.stopPropagation()
95
+ handleColorChange(colorName)
96
+ }}
97
+ title={colorName}
98
+ aria-label={`Set color to ${colorName}`}
99
+ />
100
+ ))}
101
+ </div>
102
+ </div>
103
+ </div>
104
+ )
105
+ }
106
+
@@ -0,0 +1,136 @@
1
+ .container {
2
+ position: relative;
3
+ }
4
+
5
+ .sticky {
6
+ background: var(--sticky-bg, #fff8c5);
7
+ border-radius: 2px;
8
+ min-width: 180px;
9
+ box-shadow: 2px 3px 8px rgba(0, 0, 0, 0.08);
10
+ font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
11
+ resize: both;
12
+ overflow: auto;
13
+ position: relative;
14
+ }
15
+
16
+ .removeBtn {
17
+ all: unset;
18
+ cursor: pointer;
19
+ position: absolute;
20
+ top: 4px;
21
+ right: 4px;
22
+ font-size: 16px;
23
+ line-height: 1;
24
+ width: 20px;
25
+ height: 20px;
26
+ display: flex;
27
+ align-items: center;
28
+ justify-content: center;
29
+ color: var(--sticky-border, #d4a72c);
30
+ border-radius: 2px;
31
+ opacity: 0;
32
+ transition: opacity 150ms;
33
+ z-index: 1;
34
+ }
35
+
36
+ .sticky:hover .removeBtn {
37
+ opacity: 0.6;
38
+ }
39
+
40
+ .removeBtn:hover {
41
+ opacity: 1;
42
+ color: var(--fgColor-danger, #d1242f);
43
+ }
44
+
45
+ .text {
46
+ padding: 12px 14px;
47
+ margin: 0;
48
+ font-size: 14px;
49
+ line-height: 1.5;
50
+ color: #1a1a1a;
51
+ white-space: pre-wrap;
52
+ word-break: break-word;
53
+ cursor: text;
54
+ min-height: 60px;
55
+ }
56
+
57
+ .textarea {
58
+ display: block;
59
+ width: 100%;
60
+ height: 100%;
61
+ box-sizing: border-box;
62
+ padding: 12px 14px;
63
+ margin: 0;
64
+ border: none;
65
+ outline: none;
66
+ background: transparent;
67
+ font-family: inherit;
68
+ font-size: 14px;
69
+ line-height: 1.5;
70
+ color: #1a1a1a;
71
+ resize: none;
72
+ min-height: 80px;
73
+ }
74
+
75
+ /* Color picker area — sits below the sticky */
76
+
77
+ .pickerArea {
78
+ display: flex;
79
+ justify-content: center;
80
+ padding-top: 6px;
81
+ position: relative;
82
+ }
83
+
84
+ .pickerDot {
85
+ width: 8px;
86
+ height: 8px;
87
+ border-radius: 50%;
88
+ opacity: 0.5;
89
+ transition: opacity 150ms;
90
+ cursor: pointer;
91
+ }
92
+
93
+ .pickerPopup {
94
+ position: absolute;
95
+ top: 4px;
96
+ display: flex;
97
+ gap: 5px;
98
+ padding: 6px 10px;
99
+ background: var(--bgColor-default, #ffffff);
100
+ border-radius: 20px;
101
+ box-shadow:
102
+ 0 0 0 1px rgba(0, 0, 0, 0.08),
103
+ 0 4px 12px rgba(0, 0, 0, 0.12);
104
+ opacity: 0;
105
+ pointer-events: none;
106
+ transition: opacity 150ms;
107
+ z-index: 10;
108
+ }
109
+
110
+ .pickerArea:hover .pickerDot {
111
+ opacity: 0;
112
+ }
113
+
114
+ .pickerArea:hover .pickerPopup {
115
+ opacity: 1;
116
+ pointer-events: auto;
117
+ }
118
+
119
+ .colorDot {
120
+ all: unset;
121
+ width: 20px;
122
+ height: 20px;
123
+ border-radius: 50%;
124
+ border: 2px solid transparent;
125
+ cursor: pointer;
126
+ transition: transform 100ms;
127
+ }
128
+
129
+ .colorDot:hover {
130
+ transform: scale(1.15);
131
+ }
132
+
133
+ .colorDot.active {
134
+ border-color: var(--sticky-border);
135
+ box-shadow: 0 0 0 1px var(--sticky-border);
136
+ }
@@ -0,0 +1,28 @@
1
+ import styles from './WidgetWrapper.module.css'
2
+
3
+ /**
4
+ * Common wrapper for all canvas widgets.
5
+ * Provides shadow/border styling and a remove button on hover.
6
+ */
7
+ export default function WidgetWrapper({ children, onRemove, className }) {
8
+ return (
9
+ <section className={`${styles.wrapper} ${className || ''}`}>
10
+ {onRemove && (
11
+ <button
12
+ className={styles.removeBtn}
13
+ onClick={(e) => {
14
+ e.stopPropagation()
15
+ onRemove()
16
+ }}
17
+ title="Remove widget"
18
+ aria-label="Remove widget"
19
+ >
20
+ ×
21
+ </button>
22
+ )}
23
+ <div className={styles.content}>
24
+ {children}
25
+ </div>
26
+ </section>
27
+ )
28
+ }
@@ -0,0 +1,53 @@
1
+ .wrapper {
2
+ background: var(--bgColor-default, #ffffff);
3
+ border-radius: var(--borderRadius-large, 12px);
4
+ overflow: hidden;
5
+ min-width: 200px;
6
+ box-shadow:
7
+ 0 0 0 1px rgba(0, 0, 0, 0.06),
8
+ 0 0 0 2px rgba(255, 255, 255, 0.04),
9
+ 0 2px 8px rgba(0, 0, 0, 0.08);
10
+ }
11
+
12
+ .removeBtn {
13
+ all: unset;
14
+ cursor: pointer;
15
+ position: absolute;
16
+ top: 6px;
17
+ right: 6px;
18
+ font-size: 16px;
19
+ line-height: 1;
20
+ width: 22px;
21
+ height: 22px;
22
+ display: flex;
23
+ align-items: center;
24
+ justify-content: center;
25
+ color: var(--fgColor-muted, #656d76);
26
+ border-radius: 6px;
27
+ background: var(--bgColor-default, #ffffff);
28
+ opacity: 0;
29
+ transition: opacity 150ms;
30
+ z-index: 1;
31
+ }
32
+
33
+ .wrapper:hover .removeBtn {
34
+ opacity: 1;
35
+ }
36
+
37
+ .removeBtn:hover {
38
+ color: var(--fgColor-danger, #d1242f);
39
+ background: var(--bgColor-danger-muted, #ffebe9);
40
+ }
41
+
42
+ .content {
43
+ position: relative;
44
+ }
45
+
46
+ @media (prefers-color-scheme: dark) {
47
+ .wrapper {
48
+ box-shadow:
49
+ 0 0 0 1px rgba(255, 255, 255, 0.08),
50
+ 0 0 0 2px rgba(0, 0, 0, 0.3),
51
+ 0 2px 8px rgba(0, 0, 0, 0.25);
52
+ }
53
+ }
@@ -0,0 +1,21 @@
1
+ import StickyNote from './StickyNote.jsx'
2
+ import MarkdownBlock from './MarkdownBlock.jsx'
3
+ import PrototypeEmbed from './PrototypeEmbed.jsx'
4
+
5
+ /**
6
+ * Maps widget type strings to their React components.
7
+ * Each component receives: { id, props, onUpdate, onRemove }
8
+ */
9
+ export const widgetRegistry = {
10
+ 'sticky-note': StickyNote,
11
+ 'markdown': MarkdownBlock,
12
+ 'prototype': PrototypeEmbed,
13
+ }
14
+
15
+ /**
16
+ * Resolve a widget type string to its component.
17
+ * Returns null for unknown types.
18
+ */
19
+ export function getWidgetComponent(type) {
20
+ return widgetRegistry[type] ?? null
21
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Canvas Widget Props API
3
+ *
4
+ * Every canvas widget receives its data through a structured `props` object
5
+ * stored in .canvas.json. This module defines the prop schema system that
6
+ * widgets use to declare, read, and update their editable properties.
7
+ *
8
+ * ## Prop Categories
9
+ *
10
+ * Widget props are grouped into three categories:
11
+ *
12
+ * ### `content` — User-editable content
13
+ * Text, markdown, URLs — the stuff users type or paste.
14
+ * Updated frequently (every keystroke when editing).
15
+ * Examples: sticky note text, markdown content, embed URL.
16
+ *
17
+ * ### `settings` — Widget configuration
18
+ * One-off choices that affect appearance or behavior.
19
+ * Updated infrequently (user picks from a menu).
20
+ * Examples: sticky note color, markdown width, embed layout.
21
+ *
22
+ * ### `size` — Dimensions
23
+ * Width and height of the widget.
24
+ * Updated via resize handles or explicit input.
25
+ * Examples: markdown block width, prototype embed width/height.
26
+ *
27
+ * ## Storage Format (.canvas.json)
28
+ *
29
+ * Props are stored flat in the widget's `props` object:
30
+ *
31
+ * ```json
32
+ * {
33
+ * "id": "sticky-1",
34
+ * "type": "sticky-note",
35
+ * "position": { "x": 100, "y": 200 },
36
+ * "props": {
37
+ * "text": "Hello world",
38
+ * "color": "yellow"
39
+ * }
40
+ * }
41
+ * ```
42
+ *
43
+ * ## Widget Contract
44
+ *
45
+ * Every widget component receives:
46
+ * - `id` — stable widget identifier
47
+ * - `props` — the flat props object (may be null/undefined)
48
+ * - `onUpdate` — callback to persist prop changes: onUpdate({ key: value })
49
+ * - `onRemove` — callback to delete the widget
50
+ *
51
+ * `onUpdate` accepts a partial object that is shallow-merged into `props`.
52
+ * Multiple keys can be updated in one call:
53
+ * onUpdate({ text: 'new text', color: 'blue' })
54
+ *
55
+ * ## Declaring Widget Props (Schema)
56
+ *
57
+ * Each widget type exports a `schema` describing its props.
58
+ * This is used by the toolbar, canvas settings, and future widget inspectors.
59
+ */
60
+
61
+ /**
62
+ * @typedef {'text' | 'select' | 'number' | 'url' | 'boolean'} PropType
63
+ *
64
+ * @typedef {Object} PropDef
65
+ * @property {PropType} type — input type for editing
66
+ * @property {string} label — human-readable label
67
+ * @property {string} category — 'content' | 'settings' | 'size'
68
+ * @property {*} defaultValue — fallback when prop is missing
69
+ * @property {Array} [options] — choices for 'select' type
70
+ * @property {number} [min] — minimum for 'number' type
71
+ * @property {number} [max] — maximum for 'number' type
72
+ */
73
+
74
+ /**
75
+ * Read a prop value with fallback to schema default.
76
+ * @param {object} props — widget props object (may be null)
77
+ * @param {string} key — prop name
78
+ * @param {object} schema — widget schema
79
+ * @returns {*}
80
+ */
81
+ export function readProp(props, key, schema) {
82
+ const value = props?.[key]
83
+ if (value !== undefined && value !== null) return value
84
+ return schema[key]?.defaultValue ?? null
85
+ }
86
+
87
+ /**
88
+ * Read all props with defaults applied from schema.
89
+ * @param {object} props — widget props object (may be null)
90
+ * @param {object} schema — widget schema
91
+ * @returns {object}
92
+ */
93
+ export function readAllProps(props, schema) {
94
+ const result = {}
95
+ for (const key of Object.keys(schema)) {
96
+ result[key] = readProp(props, key, schema)
97
+ }
98
+ return result
99
+ }
100
+
101
+ /**
102
+ * Get default props for a widget type from its schema.
103
+ * Used when creating new widgets.
104
+ * @param {object} schema — widget schema
105
+ * @returns {object}
106
+ */
107
+ export function getDefaults(schema) {
108
+ const result = {}
109
+ for (const [key, def] of Object.entries(schema)) {
110
+ if (def.defaultValue !== undefined) {
111
+ result[key] = def.defaultValue
112
+ }
113
+ }
114
+ return result
115
+ }
116
+
117
+ // ── Widget Schemas ──────────────────────────────────────────────────
118
+
119
+ export const stickyNoteSchema = {
120
+ text: { type: 'text', label: 'Text', category: 'content', defaultValue: '' },
121
+ color: { type: 'select', label: 'Color', category: 'settings', defaultValue: 'yellow',
122
+ options: ['yellow', 'blue', 'green', 'pink', 'purple', 'orange'] },
123
+ }
124
+
125
+ export const markdownSchema = {
126
+ content: { type: 'text', label: 'Content', category: 'content', defaultValue: '' },
127
+ width: { type: 'number', label: 'Width', category: 'size', defaultValue: 360, min: 200, max: 1200 },
128
+ }
129
+
130
+ export const prototypeEmbedSchema = {
131
+ src: { type: 'url', label: 'URL', category: 'content', defaultValue: '' },
132
+ label: { type: 'text', label: 'Label', category: 'settings', defaultValue: '' },
133
+ width: { type: 'number', label: 'Width', category: 'size', defaultValue: 800, min: 200, max: 2000 },
134
+ height: { type: 'number', label: 'Height', category: 'size', defaultValue: 600, min: 200, max: 1500 },
135
+ }
136
+
137
+ /**
138
+ * Schema registry — maps widget type strings to their schemas.
139
+ */
140
+ export const schemas = {
141
+ 'sticky-note': stickyNoteSchema,
142
+ 'markdown': markdownSchema,
143
+ 'prototype': prototypeEmbedSchema,
144
+ }
@@ -1,16 +1,14 @@
1
1
  import { useSyncExternalStore } from 'react'
2
- import { getFlag, subscribeToHash, getHashSnapshot, subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
2
+ import { getFlag, subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
3
3
 
4
4
  /**
5
5
  * React hook for reading a feature flag value.
6
- * Re-renders when the flag changes (via hash or localStorage).
6
+ * Re-renders when the flag changes (via localStorage).
7
7
  *
8
8
  * @param {string} key - Flag key (without "flag." prefix)
9
9
  * @returns {boolean} Current resolved flag value
10
10
  */
11
11
  export function useFeatureFlag(key) {
12
- // Subscribe to both hash and storage changes for reactivity
13
- useSyncExternalStore(subscribeToHash, getHashSnapshot)
14
12
  useSyncExternalStore(subscribeToStorage, getStorageSnapshot)
15
13
  return getFlag(key)
16
14
  }
@@ -0,0 +1,50 @@
1
+ import { useContext, useMemo, useCallback } from 'react'
2
+ import { StoryboardContext } from '../StoryboardContext.js'
3
+ import { getFlowsForPrototype, resolveFlowRoute } from '@dfosco/storyboard-core'
4
+ import { getFlowMeta } from '@dfosco/storyboard-core'
5
+
6
+ /**
7
+ * List all flows for the current prototype and switch between them.
8
+ *
9
+ * @returns {{
10
+ * flows: Array<{ key: string, name: string, title: string, route: string }>,
11
+ * activeFlow: string,
12
+ * switchFlow: (flowKey: string) => void,
13
+ * prototypeName: string | null
14
+ * }}
15
+ */
16
+ export function useFlows() {
17
+ const context = useContext(StoryboardContext)
18
+ if (context === null) {
19
+ throw new Error('useFlows must be used within a <StoryboardProvider>')
20
+ }
21
+
22
+ const { flowName: activeFlow, prototypeName } = context
23
+
24
+ const flows = useMemo(() => {
25
+ if (!prototypeName) return []
26
+ return getFlowsForPrototype(prototypeName).map(f => {
27
+ const meta = getFlowMeta(f.key)
28
+ return {
29
+ key: f.key,
30
+ name: f.name,
31
+ title: meta?.title || f.name,
32
+ route: resolveFlowRoute(f.key),
33
+ }
34
+ })
35
+ }, [prototypeName])
36
+
37
+ const switchFlow = useCallback((flowKey) => {
38
+ const flow = flows.find(f => f.key === flowKey)
39
+ if (flow) {
40
+ window.location.href = flow.route
41
+ }
42
+ }, [flows])
43
+
44
+ return {
45
+ flows,
46
+ activeFlow,
47
+ switchFlow,
48
+ prototypeName,
49
+ }
50
+ }