@dfosco/storyboard 0.9.6 → 0.10.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.
Files changed (155) hide show
  1. package/commandpalette.config.json +6 -0
  2. package/dist/storyboard-ui.css +1 -1
  3. package/dist/storyboard-ui.js +16450 -12737
  4. package/dist/storyboard-ui.js.map +1 -1
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +66 -2
  7. package/src/core/artifact/schemas/component.schema.json +55 -0
  8. package/src/core/artifact/schemas/prototype.schema.json +55 -0
  9. package/src/core/canvas/materializer.js +120 -105
  10. package/src/core/canvas/materializer.test.js +172 -1
  11. package/src/core/canvas/server.js +156 -11
  12. package/src/core/cli/canvasConnector.js +51 -2
  13. package/src/core/cli/configureRepository.js +164 -0
  14. package/src/core/cli/dev.js +14 -23
  15. package/src/core/cli/file.js +333 -0
  16. package/src/core/cli/gh.js +101 -0
  17. package/src/core/cli/index.js +4 -0
  18. package/src/core/cli/repoConfig.js +109 -0
  19. package/src/core/cli/repoConfig.test.js +119 -0
  20. package/src/core/cli/setup.js +15 -0
  21. package/src/core/comments/ui/mount.js +27 -0
  22. package/src/core/file/__tests__/server.test.js +604 -0
  23. package/src/core/file/__tests__/tree.test.js +190 -0
  24. package/src/core/file/server.js +519 -0
  25. package/src/core/file/tree.js +151 -0
  26. package/src/core/file-watcher/config.json +28 -0
  27. package/src/core/file-watcher/watcher.js +891 -0
  28. package/src/core/{rename-watcher → file-watcher}/watcher.test.js +142 -1
  29. package/src/core/index.js +4 -1
  30. package/src/core/knobs/coerce.js +93 -0
  31. package/src/core/knobs/coerce.test.js +51 -0
  32. package/src/core/knobs/discovery.js +109 -0
  33. package/src/core/knobs/discovery.test.js +69 -0
  34. package/src/core/knobs/index.js +18 -0
  35. package/src/core/knobs/keys.js +26 -0
  36. package/src/core/knobs/keys.test.js +28 -0
  37. package/src/core/knobs/writer.js +117 -0
  38. package/src/core/knobs/writer.test.js +113 -0
  39. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-trigger.jsx +10 -2
  40. package/src/core/rename-watcher/watcher.js +3 -824
  41. package/src/core/session/session.js +31 -3
  42. package/src/core/session/session.test.js +31 -75
  43. package/src/core/stores/canvasConfig.js +31 -1
  44. package/src/core/stores/canvasConfig.test.js +50 -1
  45. package/src/core/stores/configSchema.js +57 -0
  46. package/src/core/stores/configSchema.test.js +6 -0
  47. package/src/core/stores/themeBootstrap.js +18 -1
  48. package/src/core/stores/themeBootstrap.test.js +17 -6
  49. package/src/core/stores/uiConfig.js +2 -1
  50. package/src/core/tools/handlers/knobs.js +14 -0
  51. package/src/core/tools/registry.js +1 -0
  52. package/src/core/ui/CanvasCreateMenu.jsx +9 -1
  53. package/src/core/ui/CanvasSnap.jsx +14 -7
  54. package/src/core/ui/CanvasZoomToFit.jsx +11 -8
  55. package/src/core/ui/CoreUIBar.jsx +70 -3
  56. package/src/core/ui/KnobsPanel.jsx +320 -0
  57. package/src/core/ui/KnobsPanel.module.css +118 -0
  58. package/src/core/ui/KnobsPanel.test.jsx +135 -0
  59. package/src/core/ui/knobsPanelState.js +25 -0
  60. package/src/core/vite/server-plugin.js +4 -0
  61. package/src/internals/BaseUiForm/BaseUiForm.jsx +85 -3
  62. package/src/internals/BaseUiForm/BaseUiForm.module.css +232 -0
  63. package/src/internals/BranchBar/BranchBar.jsx +3 -1
  64. package/src/internals/CommandPalette/CommandPalette.jsx +55 -25
  65. package/src/internals/Knobs/KnobsForm.jsx +633 -0
  66. package/src/internals/Knobs/KnobsForm.module.css +162 -0
  67. package/src/internals/Knobs/KnobsForm.test.jsx +107 -0
  68. package/src/internals/Knobs/index.js +1 -0
  69. package/src/internals/canvas/CanvasPage.bridge.test.jsx +1 -0
  70. package/src/internals/canvas/CanvasPage.dragdrop.test.jsx +191 -0
  71. package/src/internals/canvas/CanvasPage.jsx +577 -187
  72. package/src/internals/canvas/CanvasPage.multiselect.test.jsx +1 -0
  73. package/src/internals/canvas/CanvasToolbar.jsx +6 -0
  74. package/src/internals/canvas/ConnectorLayer.jsx +54 -12
  75. package/src/internals/canvas/ConnectorLayer.module.css +67 -0
  76. package/src/internals/canvas/PageSelector.jsx +187 -202
  77. package/src/internals/canvas/PageSelector.module.css +12 -6
  78. package/src/internals/canvas/canvasApi.js +21 -2
  79. package/src/internals/canvas/widgets/CodePenEmbed.jsx +9 -2
  80. package/src/internals/canvas/widgets/FigmaEmbed.jsx +8 -1
  81. package/src/internals/canvas/widgets/FileWidget/CodeEditor.jsx +304 -0
  82. package/src/internals/canvas/widgets/FileWidget/CodeEditor.module.css +57 -0
  83. package/src/internals/canvas/widgets/FileWidget/DeleteConfirmController.jsx +62 -0
  84. package/src/internals/canvas/widgets/FileWidget/DeleteConfirmDialog.jsx +46 -0
  85. package/src/internals/canvas/widgets/FileWidget/DeleteConfirmDialog.module.css +48 -0
  86. package/src/internals/canvas/widgets/FileWidget/FilePicker.jsx +309 -0
  87. package/src/internals/canvas/widgets/FileWidget/FilePicker.module.css +170 -0
  88. package/src/internals/canvas/widgets/FileWidget/FilePickerController.jsx +75 -0
  89. package/src/internals/canvas/widgets/FileWidget/FileSecondaryPane.jsx +167 -0
  90. package/src/internals/canvas/widgets/FileWidget/FileWidget.jsx +658 -0
  91. package/src/internals/canvas/widgets/FileWidget/FileWidget.module.css +359 -0
  92. package/src/internals/canvas/widgets/FileWidget/MdxRenderer.jsx +153 -0
  93. package/src/internals/canvas/widgets/FileWidget/MdxRenderer.module.css +81 -0
  94. package/src/internals/canvas/widgets/FileWidget/__tests__/CodeEditor.test.jsx +235 -0
  95. package/src/internals/canvas/widgets/FileWidget/__tests__/DeleteConfirmDialog.test.jsx +51 -0
  96. package/src/internals/canvas/widgets/FileWidget/__tests__/FilePicker.test.jsx +329 -0
  97. package/src/internals/canvas/widgets/FileWidget/__tests__/FileWidget.test.jsx +525 -0
  98. package/src/internals/canvas/widgets/FileWidget/__tests__/MdxRenderer.test.jsx +81 -0
  99. package/src/internals/canvas/widgets/FileWidget/__tests__/fileContentStore.test.js +404 -0
  100. package/src/internals/canvas/widgets/FileWidget/__tests__/fileFlavor.test.js +287 -0
  101. package/src/internals/canvas/widgets/FileWidget/fileContentStore.js +328 -0
  102. package/src/internals/canvas/widgets/FileWidget/fileFlavor.js +250 -0
  103. package/src/internals/canvas/widgets/KnobsWidget.jsx +229 -0
  104. package/src/internals/canvas/widgets/KnobsWidget.module.css +144 -0
  105. package/src/internals/canvas/widgets/KnobsWidget.noreload.test.jsx +215 -0
  106. package/src/internals/canvas/widgets/KnobsWidget.test.jsx +237 -0
  107. package/src/internals/canvas/widgets/MarkdownBlock.jsx +32 -152
  108. package/src/internals/canvas/widgets/MarkdownBlock.module.css +17 -328
  109. package/src/internals/canvas/widgets/PrototypeEmbed.jsx +20 -2
  110. package/src/internals/canvas/widgets/StickyNote.jsx +43 -34
  111. package/src/internals/canvas/widgets/StickyNote.module.css +123 -4
  112. package/src/internals/canvas/widgets/StickyNote.test.jsx +95 -0
  113. package/src/internals/canvas/widgets/StorySetWidget.jsx +25 -3
  114. package/src/internals/canvas/widgets/StoryWidget.jsx +18 -0
  115. package/src/internals/canvas/widgets/TerminalWidget.jsx +46 -0
  116. package/src/internals/canvas/widgets/expandUtils.js +57 -1
  117. package/src/internals/canvas/widgets/iframeRefContract.test.jsx +102 -0
  118. package/src/internals/canvas/widgets/iframeSrcCompare.js +38 -0
  119. package/src/internals/canvas/widgets/iframeSrcCompare.test.js +65 -0
  120. package/src/internals/canvas/widgets/index.js +4 -2
  121. package/src/internals/canvas/widgets/knobsTargetResolver.js +215 -0
  122. package/src/internals/canvas/widgets/knobsTargetResolver.test.js +142 -0
  123. package/src/internals/canvas/widgets/markdown/MarkdownEditor.jsx +175 -0
  124. package/src/internals/canvas/widgets/markdown/MarkdownEditor.module.css +11 -0
  125. package/src/internals/canvas/widgets/markdown/MarkdownView.test.jsx +32 -0
  126. package/src/internals/canvas/widgets/markdown/markdownClass.js +16 -0
  127. package/src/internals/canvas/widgets/markdown/markdownContent.module.css +230 -0
  128. package/src/internals/canvas/widgets/markdown/markdownRender.js +53 -0
  129. package/src/internals/canvas/widgets/storyPath.js +31 -0
  130. package/src/internals/canvas/widgets/storyPath.test.js +59 -0
  131. package/src/internals/canvas/widgets/widgetConfig.js +61 -3
  132. package/src/internals/canvas/widgets/widgetConfig.knobs.test.js +42 -0
  133. package/src/internals/canvas/widgets/widgetConfig.test.js +33 -2
  134. package/src/internals/canvas/widgets/widgetIcons.jsx +6 -1
  135. package/src/internals/hooks/useKnob.js +97 -0
  136. package/src/internals/hooks/useKnob.test.js +133 -0
  137. package/src/internals/index.js +2 -0
  138. package/src/internals/vite/data-plugin.js +45 -7
  139. package/src/internals/vite/data-plugin.test.js +3 -3
  140. package/storyboard.config.json +6 -0
  141. package/toolbar.config.json +15 -2
  142. package/widgets.config.json +235 -133
  143. package/src/core/ui/CanvasSnap.css +0 -27
  144. package/src/core/ui/CanvasZoomToFit.css +0 -18
  145. package/src/internals/canvas/widgets/TilesWidget.jsx +0 -300
  146. package/src/internals/canvas/widgets/TilesWidget.module.css +0 -133
  147. package/src/internals/canvas/widgets/tilePool.js +0 -23
  148. package/src/internals/canvas/widgets/tiles/diagonal-bl.png +0 -0
  149. package/src/internals/canvas/widgets/tiles/diagonal-br.png +0 -0
  150. package/src/internals/canvas/widgets/tiles/diagonal-tl.png +0 -0
  151. package/src/internals/canvas/widgets/tiles/leaf.png +0 -0
  152. package/src/internals/canvas/widgets/tiles/quarter-tl.png +0 -0
  153. package/src/internals/canvas/widgets/tiles/quarter-tr.png +0 -0
  154. package/src/internals/canvas/widgets/tiles/solid-a.png +0 -0
  155. package/src/internals/canvas/widgets/tiles/solid-b.png +0 -0
@@ -0,0 +1,237 @@
1
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { initKnobs } from '../../../core/index.js'
4
+ import { _resetWidgetRegistry } from '../../../core/stores/widgetRegistry.js'
5
+ import { setWidgetRef } from '../canvasApi.js'
6
+ import KnobsWidget from './KnobsWidget.jsx'
7
+
8
+ const KNOBS_ID = 'knobs-1'
9
+
10
+ const componentKnobs = {
11
+ Alpha: [{ id: 'title', type: 'text', label: 'Alpha title', default: 'Alpha default' }],
12
+ Beta: [{ id: 'mode', type: 'select', label: 'Beta mode', default: 'light', options: ['light', 'dark'] }],
13
+ }
14
+
15
+ function connector(id, start, end) {
16
+ return {
17
+ id,
18
+ type: 'connector',
19
+ start: { widgetId: start, anchor: 'right' },
20
+ end: { widgetId: end, anchor: 'left' },
21
+ }
22
+ }
23
+
24
+ function storyWidget(id, storyId, alias) {
25
+ return {
26
+ id,
27
+ type: 'story',
28
+ position: { x: 0, y: 0 },
29
+ props: { storyId, alias, width: 400, height: 300 },
30
+ }
31
+ }
32
+
33
+ function setBridge(widgets, connectors) {
34
+ window.__storyboardCanvasBridgeState = {
35
+ canvasId: 'test-canvas',
36
+ widgets,
37
+ connectors,
38
+ }
39
+ }
40
+
41
+ function dispatchBridgeUpdate() {
42
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:bridge-updated'))
43
+ }
44
+
45
+ function paramsFor(targetWindow = window) {
46
+ return new URLSearchParams(targetWindow.location.hash.replace(/^#/, ''))
47
+ }
48
+
49
+ function registerIframeWindow(widgetId) {
50
+ const iframe = document.createElement('iframe')
51
+ document.body.appendChild(iframe)
52
+ setWidgetRef(widgetId, { getIframeWindow: () => iframe.contentWindow })
53
+ return iframe.contentWindow
54
+ }
55
+
56
+ function renderWidget({ props = {}, onUpdate = vi.fn(), resizable = false } = {}) {
57
+ return {
58
+ onUpdate,
59
+ ...render(
60
+ <KnobsWidget
61
+ id={KNOBS_ID}
62
+ props={{ width: 360, height: 420, ...props }}
63
+ onUpdate={onUpdate}
64
+ resizable={resizable}
65
+ />
66
+ ),
67
+ }
68
+ }
69
+
70
+ describe('KnobsWidget', () => {
71
+ beforeEach(() => {
72
+ initKnobs({ componentKnobs })
73
+ _resetWidgetRegistry()
74
+ window.history.replaceState(null, '', '/')
75
+ setBridge([], [])
76
+ })
77
+
78
+ afterEach(() => {
79
+ setWidgetRef('story-alpha', null)
80
+ setWidgetRef('story-beta', null)
81
+ delete window.__storyboardCanvasBridgeState
82
+ cleanup()
83
+ initKnobs()
84
+ _resetWidgetRegistry()
85
+ })
86
+
87
+ it('renders an empty state when there are no connected targets', () => {
88
+ renderWidget()
89
+
90
+ expect(screen.getByText('Connect this widget to a prototype, component, or canvas widget to expose its knobs.')).toBeInTheDocument()
91
+ })
92
+
93
+ it('renders KnobsForm with a single connected target schema', () => {
94
+ registerIframeWindow('story-alpha')
95
+ setBridge(
96
+ [storyWidget('story-alpha', 'Alpha', 'Alpha target')],
97
+ [connector('connector-alpha', KNOBS_ID, 'story-alpha')],
98
+ )
99
+
100
+ renderWidget()
101
+
102
+ expect(screen.queryByRole('tab')).toBeNull()
103
+ expect(screen.getByLabelText('Alpha title')).toHaveValue('Alpha default')
104
+ })
105
+
106
+ it('renders tabs for multiple connected targets and swaps the active form on click', () => {
107
+ registerIframeWindow('story-alpha')
108
+ registerIframeWindow('story-beta')
109
+ setBridge(
110
+ [
111
+ storyWidget('story-alpha', 'Alpha', 'Alpha target'),
112
+ storyWidget('story-beta', 'Beta', 'Beta target'),
113
+ ],
114
+ [
115
+ connector('connector-alpha', KNOBS_ID, 'story-alpha'),
116
+ connector('connector-beta', KNOBS_ID, 'story-beta'),
117
+ ],
118
+ )
119
+
120
+ renderWidget()
121
+
122
+ expect(screen.getByRole('tab', { name: 'Alpha target' })).toHaveAttribute('aria-selected', 'true')
123
+ expect(screen.getByLabelText('Alpha title')).toBeInTheDocument()
124
+
125
+ fireEvent.click(screen.getByRole('tab', { name: 'Beta target' }))
126
+
127
+ expect(screen.getByRole('tab', { name: 'Beta target' })).toHaveAttribute('aria-selected', 'true')
128
+ expect(screen.getByText('Beta mode')).toBeInTheDocument()
129
+ expect(screen.queryByLabelText('Alpha title')).toBeNull()
130
+ })
131
+
132
+ it('uses activeTarget props and persists tab selection through onUpdate', () => {
133
+ registerIframeWindow('story-alpha')
134
+ registerIframeWindow('story-beta')
135
+ setBridge(
136
+ [
137
+ storyWidget('story-alpha', 'Alpha', 'Alpha target'),
138
+ storyWidget('story-beta', 'Beta', 'Beta target'),
139
+ ],
140
+ [
141
+ connector('connector-alpha', KNOBS_ID, 'story-alpha'),
142
+ connector('connector-beta', KNOBS_ID, 'story-beta'),
143
+ ],
144
+ )
145
+ const onUpdate = vi.fn()
146
+
147
+ renderWidget({ props: { activeTarget: 'story-beta' }, onUpdate })
148
+
149
+ expect(screen.getByRole('tab', { name: 'Beta target' })).toHaveAttribute('aria-selected', 'true')
150
+
151
+ fireEvent.click(screen.getByRole('tab', { name: 'Alpha target' }))
152
+
153
+ expect(onUpdate).toHaveBeenCalledWith({ activeTarget: 'story-alpha' })
154
+ expect(screen.getByRole('tab', { name: 'Alpha target' })).toHaveAttribute('aria-selected', 'true')
155
+ })
156
+
157
+ it('falls back to the first remaining target when the active target disconnects', () => {
158
+ registerIframeWindow('story-alpha')
159
+ registerIframeWindow('story-beta')
160
+ setBridge(
161
+ [
162
+ storyWidget('story-alpha', 'Alpha', 'Alpha target'),
163
+ storyWidget('story-beta', 'Beta', 'Beta target'),
164
+ ],
165
+ [
166
+ connector('connector-alpha', KNOBS_ID, 'story-alpha'),
167
+ connector('connector-beta', KNOBS_ID, 'story-beta'),
168
+ ],
169
+ )
170
+ const onUpdate = vi.fn()
171
+
172
+ renderWidget({ props: { activeTarget: 'story-beta' }, onUpdate })
173
+ expect(screen.getByText('Beta mode')).toBeInTheDocument()
174
+
175
+ act(() => {
176
+ setBridge(
177
+ [storyWidget('story-alpha', 'Alpha', 'Alpha target')],
178
+ [connector('connector-alpha', KNOBS_ID, 'story-alpha')],
179
+ )
180
+ dispatchBridgeUpdate()
181
+ })
182
+
183
+ expect(screen.getByLabelText('Alpha title')).toBeInTheDocument()
184
+ expect(screen.queryByText('Beta mode')).toBeNull()
185
+ expect(onUpdate).toHaveBeenCalledWith({ activeTarget: 'story-alpha' })
186
+ })
187
+
188
+ it('waits for iframe-backed targets instead of falling back to canvas hash writes', () => {
189
+ setBridge(
190
+ [storyWidget('story-alpha', 'Alpha', 'Alpha target')],
191
+ [connector('connector-alpha', KNOBS_ID, 'story-alpha')],
192
+ )
193
+
194
+ renderWidget()
195
+
196
+ expect(screen.getByText('Waiting for Alpha target to finish loading before writing knobs.')).toBeInTheDocument()
197
+ expect(screen.queryByLabelText('Alpha title')).toBeNull()
198
+ })
199
+
200
+ it('routes iframe writes to the target contentWindow hash, not the canvas hash', () => {
201
+ const targetWindow = registerIframeWindow('story-alpha')
202
+ setBridge(
203
+ [storyWidget('story-alpha', 'Alpha', 'Alpha target')],
204
+ [connector('connector-alpha', KNOBS_ID, 'story-alpha')],
205
+ )
206
+
207
+ renderWidget()
208
+
209
+ fireEvent.change(screen.getByLabelText('Alpha title'), { target: { value: 'Iframe value' } })
210
+
211
+ expect(paramsFor(targetWindow).get('knobTitle')).toBe('Iframe value')
212
+ expect(paramsFor(window).get('knobTitle')).toBeNull()
213
+ })
214
+
215
+ it('preserves resize behavior by applying dimensions and persisting updates', () => {
216
+ const onUpdate = vi.fn()
217
+ const { container } = renderWidget({
218
+ props: { width: 300, height: 240 },
219
+ onUpdate,
220
+ resizable: true,
221
+ })
222
+ const card = container.querySelector('[data-knobs-widget]')
223
+ const handle = screen.getByRole('separator', { name: 'Resize' })
224
+
225
+ expect(card.style.width).toBe('300px')
226
+ expect(card.style.height).toBe('240px')
227
+
228
+ Object.defineProperty(card, 'offsetWidth', { value: 300, configurable: true })
229
+ Object.defineProperty(card, 'offsetHeight', { value: 240, configurable: true })
230
+
231
+ fireEvent.mouseDown(handle, { clientX: 300, clientY: 240 })
232
+ fireEvent.mouseMove(document, { clientX: 340, clientY: 280 })
233
+ fireEvent.mouseUp(document)
234
+
235
+ expect(onUpdate).toHaveBeenCalledWith({ width: 340, height: 280 })
236
+ })
237
+ })
@@ -1,7 +1,4 @@
1
1
  import { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'
2
- import { remark } from 'remark'
3
- import remarkGfm from 'remark-gfm'
4
- import remarkHtml from 'remark-html'
5
2
  import WidgetWrapper from './WidgetWrapper.jsx'
6
3
  import ResizeHandle from './ResizeHandle.jsx'
7
4
  import { readProp } from './widgetProps.js'
@@ -9,64 +6,11 @@ import { schemas, getFeaturesForSurface } from './widgetConfig.js'
9
6
  import ExpandedPane from './ExpandedPane.jsx'
10
7
  import { findAllConnectedSplitTargets, getSplitPaneLabel, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
11
8
  import { useExpandOverride } from './useExpandOverride.js'
9
+ import { MarkdownView, MarkdownTextEditor } from './markdown/MarkdownEditor.jsx'
12
10
  import styles from './MarkdownBlock.module.css'
13
11
 
14
12
  const markdownSchema = schemas['markdown']
15
13
 
16
- /**
17
- * Renders markdown to HTML using remark with GitHub Flavored Markdown support.
18
- */
19
- function renderMarkdown(text) {
20
- if (!text) return ''
21
- const result = remark()
22
- .use(remarkGfm)
23
- .use(remarkHtml, { sanitize: false })
24
- .processSync(text)
25
- // Open all links in new tabs
26
- return String(result).replace(/<a\s/g, '<a target="_blank" rel="noopener noreferrer" ')
27
- }
28
-
29
- /**
30
- * Post-process rendered HTML to syntax-highlight fenced code blocks.
31
- * remark-html outputs <pre><code class="language-xxx">...</code></pre>.
32
- * We replace the code content with highlight.js output.
33
- */
34
- let hljsPromise = null
35
- function getHljs() {
36
- if (!hljsPromise) {
37
- hljsPromise = import('../../../core/inspector/highlighter.js').then((mod) => mod)
38
- }
39
- return hljsPromise
40
- }
41
-
42
- async function highlightCodeBlocks(html) {
43
- if (!html.includes('<code class="language-')) return html
44
- const { createInspectorHighlighter } = await getHljs()
45
- const hl = await createInspectorHighlighter()
46
- return html.replace(
47
- /<pre><code class="language-(\w+)">([\s\S]*?)<\/code><\/pre>/g,
48
- (match, lang, code) => {
49
- try {
50
- // Decode all HTML entities that remark-html may produce
51
- const decoded = code
52
- .replace(/&#x3C;/gi, '<')
53
- .replace(/&#x3E;/gi, '>')
54
- .replace(/&#x26;/gi, '&')
55
- .replace(/&#x22;/gi, '"')
56
- .replace(/&#x27;/gi, "'")
57
- .replace(/&lt;/g, '<')
58
- .replace(/&gt;/g, '>')
59
- .replace(/&quot;/g, '"')
60
- .replace(/&amp;/g, '&')
61
- // codeToHtml returns a full <pre style="bg;fg"><code>...</code></pre>
62
- return hl.codeToHtml(decoded, { lang })
63
- } catch {
64
- return match
65
- }
66
- }
67
- )
68
- }
69
-
70
14
  export default forwardRef(function MarkdownBlock({ id, props, onUpdate, resizable }, ref) {
71
15
  const content = readProp(props, 'content', markdownSchema)
72
16
  const width = readProp(props, 'width', markdownSchema)
@@ -77,7 +21,6 @@ export default forwardRef(function MarkdownBlock({ id, props, onUpdate, resizabl
77
21
  const [expandMode, setExpandMode] = useExpandOverride('markdown', id)
78
22
  const expanded = expandMode !== null
79
23
  const editingActive = canEdit && editing
80
- const textareaRef = useRef(null)
81
24
  const blockRef = useRef(null)
82
25
  const [editHeight, setEditHeight] = useState(null)
83
26
 
@@ -93,31 +36,8 @@ export default forwardRef(function MarkdownBlock({ id, props, onUpdate, resizabl
93
36
  onUpdate?.({ width: w, height: h })
94
37
  }, [onUpdate])
95
38
 
96
- const rawHtml = useMemo(() => renderMarkdown(content), [content])
97
- const [renderedHtml, setRenderedHtml] = useState(rawHtml)
98
-
99
- // Re-highlight when theme changes
100
- const [themeKey, setThemeKey] = useState(0)
101
- useEffect(() => {
102
- function onThemeChanged() { setThemeKey((k) => k + 1) }
103
- document.addEventListener('storyboard:theme:changed', onThemeChanged)
104
- return () => document.removeEventListener('storyboard:theme:changed', onThemeChanged)
105
- }, [])
106
-
107
- // Async-highlight code blocks after initial render or theme change
108
- useEffect(() => {
109
- // eslint-disable-next-line react-hooks/set-state-in-effect
110
- setRenderedHtml(rawHtml)
111
- if (!rawHtml.includes('<code class="language-')) return
112
- let cancelled = false
113
- highlightCodeBlocks(rawHtml).then((highlighted) => {
114
- if (!cancelled) setRenderedHtml(highlighted)
115
- })
116
- return () => { cancelled = true }
117
- }, [rawHtml, themeKey])
118
-
119
- const handleContentChange = useCallback((e) => {
120
- onUpdate?.({ content: e.target.value })
39
+ const handleContentChange = useCallback((value) => {
40
+ onUpdate?.({ content: value })
121
41
  }, [onUpdate])
122
42
 
123
43
  const handleReadOnlyCopy = useCallback((e) => {
@@ -130,7 +50,8 @@ export default forwardRef(function MarkdownBlock({ id, props, onUpdate, resizabl
130
50
  }, [canEdit, content])
131
51
 
132
52
  const startEditing = useCallback(() => {
133
- // Capture the preview height BEFORE React swaps to the textarea
53
+ // Capture the preview height BEFORE React swaps to the textarea so the
54
+ // widget doesn't collapse to its min-height during the transition.
134
55
  if (blockRef.current) {
135
56
  setEditHeight(blockRef.current.offsetHeight)
136
57
  blockRef.current.dataset.scrollTop = blockRef.current.scrollTop
@@ -140,15 +61,10 @@ export default forwardRef(function MarkdownBlock({ id, props, onUpdate, resizabl
140
61
 
141
62
  useEffect(() => {
142
63
  if (editingActive) {
143
- if (textareaRef.current) {
144
- // Place cursor at end and prevent scroll jump to top
145
- const len = textareaRef.current.value.length
146
- textareaRef.current.setSelectionRange(len, len)
147
- textareaRef.current.focus({ preventScroll: true })
148
- // Restore the block's scroll position (captured before React swapped the DOM)
149
- if (blockRef.current) {
150
- blockRef.current.scrollTop = blockRef.current.dataset.scrollTop || 0
151
- }
64
+ // Restore the block's scroll position (captured before React swapped the
65
+ // DOM) on the next frame, after the textarea has been laid out.
66
+ if (blockRef.current) {
67
+ blockRef.current.scrollTop = blockRef.current.dataset.scrollTop || 0
152
68
  }
153
69
  } else {
154
70
  // eslint-disable-next-line react-hooks/set-state-in-effect
@@ -169,39 +85,27 @@ export default forwardRef(function MarkdownBlock({ id, props, onUpdate, resizabl
169
85
  }}
170
86
  >
171
87
  {editingActive ? (
172
- <textarea
173
- ref={textareaRef}
88
+ <MarkdownTextEditor
174
89
  className={styles.editor}
175
- data-canvas-allow-text-selection
176
90
  style={{ flex: 1 }}
177
- value={content}
91
+ content={content}
178
92
  onChange={handleContentChange}
179
- onBlur={() => setEditing(false)}
180
- onMouseDown={(e) => e.stopPropagation()}
181
- onPointerDown={(e) => e.stopPropagation()}
182
- onKeyDown={(e) => {
183
- if (e.key === 'Escape') setEditing(false)
184
- }}
185
- placeholder="Write markdown…"
93
+ onExit={() => setEditing(false)}
186
94
  />
187
95
  ) : (
188
- <div
96
+ <MarkdownView
189
97
  className={styles.preview}
190
98
  style={!canEdit ? { cursor: 'default' } : undefined}
191
- data-canvas-allow-text-selection={!canEdit ? '' : undefined}
192
- onClick={!canEdit ? (e) => e.stopPropagation() : undefined}
99
+ content={content}
100
+ canEdit={canEdit}
101
+ onStartEditing={startEditing}
193
102
  onCopy={!canEdit ? handleReadOnlyCopy : undefined}
194
- onDoubleClick={canEdit ? startEditing : undefined}
195
- role={canEdit ? 'button' : undefined}
196
- tabIndex={canEdit ? 0 : undefined}
197
- onKeyDown={canEdit ? (e) => {
198
- if (e.key === 'Enter') startEditing()
199
- } : undefined}
200
- dangerouslySetInnerHTML={{
201
- __html: renderedHtml || (canEdit
202
- ? '<p class="placeholder">Double-click to edit…</p>'
203
- : '<p class="placeholder">No content</p>'),
204
- }}
103
+ allowTextSelection={!canEdit}
104
+ size="small"
105
+ inert
106
+ emptyHtml={canEdit
107
+ ? '<p class="placeholder">Double-click to edit…</p>'
108
+ : '<p class="placeholder">No content</p>'}
205
109
  />
206
110
  )}
207
111
  {resizable && (
@@ -304,54 +208,30 @@ function MarkdownExpandPane({ widgetId, content, splitMode, onClose, onUpdate })
304
208
  * Editing state is controlled externally via props (toggle button lives in the title bar).
305
209
  */
306
210
  export function ExpandedMarkdownEditor({ content, onUpdate, editing, onToggleEdit }) {
307
- const textareaRef = useRef(null)
308
211
  const canEdit = typeof onUpdate === 'function'
309
212
 
310
- const rawHtml = useMemo(() => renderMarkdown(content), [content])
311
- const [renderedHtml, setRenderedHtml] = useState(rawHtml)
312
-
313
- useEffect(() => {
314
- // eslint-disable-next-line react-hooks/set-state-in-effect
315
- setRenderedHtml(rawHtml)
316
- if (!rawHtml.includes('<code class="language-')) return
317
- let cancelled = false
318
- highlightCodeBlocks(rawHtml).then((highlighted) => {
319
- if (!cancelled) setRenderedHtml(highlighted)
320
- })
321
- return () => { cancelled = true }
322
- }, [rawHtml])
323
-
324
- useEffect(() => {
325
- if (editing && textareaRef.current) {
326
- const len = textareaRef.current.value.length
327
- textareaRef.current.setSelectionRange(len, len)
328
- textareaRef.current.focus({ preventScroll: true })
329
- }
330
- }, [editing])
331
-
332
213
  if (editing && canEdit) {
333
214
  return (
334
215
  <div className={styles.expandedEditorWrap}>
335
- <textarea
336
- ref={textareaRef}
216
+ <MarkdownTextEditor
337
217
  className={styles.expandedEditor}
338
- value={content}
339
- onChange={(e) => onUpdate({ content: e.target.value })}
340
- onKeyDown={(e) => { if (e.key === 'Escape') onToggleEdit?.() }}
341
- placeholder="Write markdown…"
218
+ content={content}
219
+ onChange={(value) => onUpdate({ content: value })}
220
+ onExit={() => onToggleEdit?.()}
342
221
  />
343
222
  </div>
344
223
  )
345
224
  }
346
225
 
347
226
  return (
348
- <div
227
+ <MarkdownView
349
228
  className={styles.expandedPreview}
350
229
  style={{ flex: 1, overflow: 'auto' }}
351
- onDoubleClick={canEdit ? onToggleEdit : undefined}
352
- dangerouslySetInnerHTML={{
353
- __html: renderedHtml || '<p>No content</p>',
354
- }}
230
+ content={content}
231
+ canEdit={canEdit}
232
+ onStartEditing={onToggleEdit}
233
+ size="large"
234
+ emptyHtml="<p>No content</p>"
355
235
  />
356
236
  )
357
237
  }