@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.
- package/commandpalette.config.json +6 -0
- package/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +16450 -12737
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +66 -2
- package/src/core/artifact/schemas/component.schema.json +55 -0
- package/src/core/artifact/schemas/prototype.schema.json +55 -0
- package/src/core/canvas/materializer.js +120 -105
- package/src/core/canvas/materializer.test.js +172 -1
- package/src/core/canvas/server.js +156 -11
- package/src/core/cli/canvasConnector.js +51 -2
- package/src/core/cli/configureRepository.js +164 -0
- package/src/core/cli/dev.js +14 -23
- package/src/core/cli/file.js +333 -0
- package/src/core/cli/gh.js +101 -0
- package/src/core/cli/index.js +4 -0
- package/src/core/cli/repoConfig.js +109 -0
- package/src/core/cli/repoConfig.test.js +119 -0
- package/src/core/cli/setup.js +15 -0
- package/src/core/comments/ui/mount.js +27 -0
- package/src/core/file/__tests__/server.test.js +604 -0
- package/src/core/file/__tests__/tree.test.js +190 -0
- package/src/core/file/server.js +519 -0
- package/src/core/file/tree.js +151 -0
- package/src/core/file-watcher/config.json +28 -0
- package/src/core/file-watcher/watcher.js +891 -0
- package/src/core/{rename-watcher → file-watcher}/watcher.test.js +142 -1
- package/src/core/index.js +4 -1
- package/src/core/knobs/coerce.js +93 -0
- package/src/core/knobs/coerce.test.js +51 -0
- package/src/core/knobs/discovery.js +109 -0
- package/src/core/knobs/discovery.test.js +69 -0
- package/src/core/knobs/index.js +18 -0
- package/src/core/knobs/keys.js +26 -0
- package/src/core/knobs/keys.test.js +28 -0
- package/src/core/knobs/writer.js +117 -0
- package/src/core/knobs/writer.test.js +113 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-trigger.jsx +10 -2
- package/src/core/rename-watcher/watcher.js +3 -824
- package/src/core/session/session.js +31 -3
- package/src/core/session/session.test.js +31 -75
- package/src/core/stores/canvasConfig.js +31 -1
- package/src/core/stores/canvasConfig.test.js +50 -1
- package/src/core/stores/configSchema.js +57 -0
- package/src/core/stores/configSchema.test.js +6 -0
- package/src/core/stores/themeBootstrap.js +18 -1
- package/src/core/stores/themeBootstrap.test.js +17 -6
- package/src/core/stores/uiConfig.js +2 -1
- package/src/core/tools/handlers/knobs.js +14 -0
- package/src/core/tools/registry.js +1 -0
- package/src/core/ui/CanvasCreateMenu.jsx +9 -1
- package/src/core/ui/CanvasSnap.jsx +14 -7
- package/src/core/ui/CanvasZoomToFit.jsx +11 -8
- package/src/core/ui/CoreUIBar.jsx +70 -3
- package/src/core/ui/KnobsPanel.jsx +320 -0
- package/src/core/ui/KnobsPanel.module.css +118 -0
- package/src/core/ui/KnobsPanel.test.jsx +135 -0
- package/src/core/ui/knobsPanelState.js +25 -0
- package/src/core/vite/server-plugin.js +4 -0
- package/src/internals/BaseUiForm/BaseUiForm.jsx +85 -3
- package/src/internals/BaseUiForm/BaseUiForm.module.css +232 -0
- package/src/internals/BranchBar/BranchBar.jsx +3 -1
- package/src/internals/CommandPalette/CommandPalette.jsx +55 -25
- package/src/internals/Knobs/KnobsForm.jsx +633 -0
- package/src/internals/Knobs/KnobsForm.module.css +162 -0
- package/src/internals/Knobs/KnobsForm.test.jsx +107 -0
- package/src/internals/Knobs/index.js +1 -0
- package/src/internals/canvas/CanvasPage.bridge.test.jsx +1 -0
- package/src/internals/canvas/CanvasPage.dragdrop.test.jsx +191 -0
- package/src/internals/canvas/CanvasPage.jsx +577 -187
- package/src/internals/canvas/CanvasPage.multiselect.test.jsx +1 -0
- package/src/internals/canvas/CanvasToolbar.jsx +6 -0
- package/src/internals/canvas/ConnectorLayer.jsx +54 -12
- package/src/internals/canvas/ConnectorLayer.module.css +67 -0
- package/src/internals/canvas/PageSelector.jsx +187 -202
- package/src/internals/canvas/PageSelector.module.css +12 -6
- package/src/internals/canvas/canvasApi.js +21 -2
- package/src/internals/canvas/widgets/CodePenEmbed.jsx +9 -2
- package/src/internals/canvas/widgets/FigmaEmbed.jsx +8 -1
- package/src/internals/canvas/widgets/FileWidget/CodeEditor.jsx +304 -0
- package/src/internals/canvas/widgets/FileWidget/CodeEditor.module.css +57 -0
- package/src/internals/canvas/widgets/FileWidget/DeleteConfirmController.jsx +62 -0
- package/src/internals/canvas/widgets/FileWidget/DeleteConfirmDialog.jsx +46 -0
- package/src/internals/canvas/widgets/FileWidget/DeleteConfirmDialog.module.css +48 -0
- package/src/internals/canvas/widgets/FileWidget/FilePicker.jsx +309 -0
- package/src/internals/canvas/widgets/FileWidget/FilePicker.module.css +170 -0
- package/src/internals/canvas/widgets/FileWidget/FilePickerController.jsx +75 -0
- package/src/internals/canvas/widgets/FileWidget/FileSecondaryPane.jsx +167 -0
- package/src/internals/canvas/widgets/FileWidget/FileWidget.jsx +658 -0
- package/src/internals/canvas/widgets/FileWidget/FileWidget.module.css +359 -0
- package/src/internals/canvas/widgets/FileWidget/MdxRenderer.jsx +153 -0
- package/src/internals/canvas/widgets/FileWidget/MdxRenderer.module.css +81 -0
- package/src/internals/canvas/widgets/FileWidget/__tests__/CodeEditor.test.jsx +235 -0
- package/src/internals/canvas/widgets/FileWidget/__tests__/DeleteConfirmDialog.test.jsx +51 -0
- package/src/internals/canvas/widgets/FileWidget/__tests__/FilePicker.test.jsx +329 -0
- package/src/internals/canvas/widgets/FileWidget/__tests__/FileWidget.test.jsx +525 -0
- package/src/internals/canvas/widgets/FileWidget/__tests__/MdxRenderer.test.jsx +81 -0
- package/src/internals/canvas/widgets/FileWidget/__tests__/fileContentStore.test.js +404 -0
- package/src/internals/canvas/widgets/FileWidget/__tests__/fileFlavor.test.js +287 -0
- package/src/internals/canvas/widgets/FileWidget/fileContentStore.js +328 -0
- package/src/internals/canvas/widgets/FileWidget/fileFlavor.js +250 -0
- package/src/internals/canvas/widgets/KnobsWidget.jsx +229 -0
- package/src/internals/canvas/widgets/KnobsWidget.module.css +144 -0
- package/src/internals/canvas/widgets/KnobsWidget.noreload.test.jsx +215 -0
- package/src/internals/canvas/widgets/KnobsWidget.test.jsx +237 -0
- package/src/internals/canvas/widgets/MarkdownBlock.jsx +32 -152
- package/src/internals/canvas/widgets/MarkdownBlock.module.css +17 -328
- package/src/internals/canvas/widgets/PrototypeEmbed.jsx +20 -2
- package/src/internals/canvas/widgets/StickyNote.jsx +43 -34
- package/src/internals/canvas/widgets/StickyNote.module.css +123 -4
- package/src/internals/canvas/widgets/StickyNote.test.jsx +95 -0
- package/src/internals/canvas/widgets/StorySetWidget.jsx +25 -3
- package/src/internals/canvas/widgets/StoryWidget.jsx +18 -0
- package/src/internals/canvas/widgets/TerminalWidget.jsx +46 -0
- package/src/internals/canvas/widgets/expandUtils.js +57 -1
- package/src/internals/canvas/widgets/iframeRefContract.test.jsx +102 -0
- package/src/internals/canvas/widgets/iframeSrcCompare.js +38 -0
- package/src/internals/canvas/widgets/iframeSrcCompare.test.js +65 -0
- package/src/internals/canvas/widgets/index.js +4 -2
- package/src/internals/canvas/widgets/knobsTargetResolver.js +215 -0
- package/src/internals/canvas/widgets/knobsTargetResolver.test.js +142 -0
- package/src/internals/canvas/widgets/markdown/MarkdownEditor.jsx +175 -0
- package/src/internals/canvas/widgets/markdown/MarkdownEditor.module.css +11 -0
- package/src/internals/canvas/widgets/markdown/MarkdownView.test.jsx +32 -0
- package/src/internals/canvas/widgets/markdown/markdownClass.js +16 -0
- package/src/internals/canvas/widgets/markdown/markdownContent.module.css +230 -0
- package/src/internals/canvas/widgets/markdown/markdownRender.js +53 -0
- package/src/internals/canvas/widgets/storyPath.js +31 -0
- package/src/internals/canvas/widgets/storyPath.test.js +59 -0
- package/src/internals/canvas/widgets/widgetConfig.js +61 -3
- package/src/internals/canvas/widgets/widgetConfig.knobs.test.js +42 -0
- package/src/internals/canvas/widgets/widgetConfig.test.js +33 -2
- package/src/internals/canvas/widgets/widgetIcons.jsx +6 -1
- package/src/internals/hooks/useKnob.js +97 -0
- package/src/internals/hooks/useKnob.test.js +133 -0
- package/src/internals/index.js +2 -0
- package/src/internals/vite/data-plugin.js +45 -7
- package/src/internals/vite/data-plugin.test.js +3 -3
- package/storyboard.config.json +6 -0
- package/toolbar.config.json +15 -2
- package/widgets.config.json +235 -133
- package/src/core/ui/CanvasSnap.css +0 -27
- package/src/core/ui/CanvasZoomToFit.css +0 -18
- package/src/internals/canvas/widgets/TilesWidget.jsx +0 -300
- package/src/internals/canvas/widgets/TilesWidget.module.css +0 -133
- package/src/internals/canvas/widgets/tilePool.js +0 -23
- package/src/internals/canvas/widgets/tiles/diagonal-bl.png +0 -0
- package/src/internals/canvas/widgets/tiles/diagonal-br.png +0 -0
- package/src/internals/canvas/widgets/tiles/diagonal-tl.png +0 -0
- package/src/internals/canvas/widgets/tiles/leaf.png +0 -0
- package/src/internals/canvas/widgets/tiles/quarter-tl.png +0 -0
- package/src/internals/canvas/widgets/tiles/quarter-tr.png +0 -0
- package/src/internals/canvas/widgets/tiles/solid-a.png +0 -0
- 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(/</gi, '<')
|
|
53
|
-
.replace(/>/gi, '>')
|
|
54
|
-
.replace(/&/gi, '&')
|
|
55
|
-
.replace(/"/gi, '"')
|
|
56
|
-
.replace(/'/gi, "'")
|
|
57
|
-
.replace(/</g, '<')
|
|
58
|
-
.replace(/>/g, '>')
|
|
59
|
-
.replace(/"/g, '"')
|
|
60
|
-
.replace(/&/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
|
|
97
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
<
|
|
173
|
-
ref={textareaRef}
|
|
88
|
+
<MarkdownTextEditor
|
|
174
89
|
className={styles.editor}
|
|
175
|
-
data-canvas-allow-text-selection
|
|
176
90
|
style={{ flex: 1 }}
|
|
177
|
-
|
|
91
|
+
content={content}
|
|
178
92
|
onChange={handleContentChange}
|
|
179
|
-
|
|
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
|
-
<
|
|
96
|
+
<MarkdownView
|
|
189
97
|
className={styles.preview}
|
|
190
98
|
style={!canEdit ? { cursor: 'default' } : undefined}
|
|
191
|
-
|
|
192
|
-
|
|
99
|
+
content={content}
|
|
100
|
+
canEdit={canEdit}
|
|
101
|
+
onStartEditing={startEditing}
|
|
193
102
|
onCopy={!canEdit ? handleReadOnlyCopy : undefined}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
<
|
|
336
|
-
ref={textareaRef}
|
|
216
|
+
<MarkdownTextEditor
|
|
337
217
|
className={styles.expandedEditor}
|
|
338
|
-
|
|
339
|
-
onChange={(
|
|
340
|
-
|
|
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
|
-
<
|
|
227
|
+
<MarkdownView
|
|
349
228
|
className={styles.expandedPreview}
|
|
350
229
|
style={{ flex: 1, overflow: 'auto' }}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
}
|