@bendyline/squisq-editor-react 1.5.2 → 1.6.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/dist/index.d.ts +757 -20
- package/dist/index.js +16910 -6844
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/BlockCardView.tsx +121 -0
- package/src/BlockPreviewPanel.tsx +69 -0
- package/src/BlockPropertiesPopover.tsx +191 -0
- package/src/EditorContext.tsx +143 -0
- package/src/EditorShell.tsx +200 -120
- package/src/FolderView.tsx +131 -0
- package/src/Icon.tsx +26 -0
- package/src/ImageEditor.tsx +69 -22
- package/src/OutlinePanel.tsx +38 -3
- package/src/PlainHtmlPreview.tsx +30 -3
- package/src/PreviewControls.tsx +180 -8
- package/src/RawEditor.tsx +216 -29
- package/src/RecorderEntry.tsx +9 -16
- package/src/TemplateAnnotation.ts +44 -0
- package/src/TemplatePicker.tsx +329 -54
- package/src/ThemeCustomizerPanel.tsx +30 -336
- package/src/ThemePicker.tsx +112 -3
- package/src/TimelineBlockPreview.tsx +37 -0
- package/src/TimelineTrack.tsx +671 -0
- package/src/Toolbar.tsx +528 -174
- package/src/Tooltip.tsx +22 -4
- package/src/TransitionPicker.tsx +351 -0
- package/src/VersionHistoryPanel.tsx +61 -31
- package/src/ViewMenuPanel.tsx +17 -14
- package/src/WysiwygEditor.tsx +161 -65
- package/src/__tests__/blockProperties.test.ts +92 -0
- package/src/__tests__/blockRange.test.ts +105 -0
- package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
- package/src/__tests__/createShapeLayer.test.ts +46 -0
- package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
- package/src/__tests__/embeddedMedia.test.ts +48 -0
- package/src/__tests__/headingTransition.test.ts +138 -0
- package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
- package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
- package/src/__tests__/recorderMediaInsert.test.ts +86 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
- package/src/__tests__/templatePickerMetadata.test.ts +32 -0
- package/src/__tests__/timelineSource.test.ts +134 -0
- package/src/__tests__/tiptapBridge.test.ts +92 -0
- package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
- package/src/__tests__/tooltip.test.tsx +72 -0
- package/src/__tests__/transitionCatalog.test.ts +64 -0
- package/src/__tests__/useBlockNavigator.test.tsx +67 -0
- package/src/__tests__/useMediaRecorder.test.ts +24 -0
- package/src/__tests__/useTimelineClock.test.ts +21 -0
- package/src/blockProperties.ts +88 -0
- package/src/blockRange.ts +132 -0
- package/src/buildPreviewDoc.ts +98 -9
- package/src/customTemplates/AddBin.tsx +126 -0
- package/src/customTemplates/CustomLayoutManager.tsx +233 -0
- package/src/customTemplates/CustomTemplateContext.tsx +182 -0
- package/src/customTemplates/LayerToolbar.tsx +580 -0
- package/src/customTemplates/ShapeGlyph.tsx +47 -0
- package/src/customTemplates/TemplateDesigner.tsx +430 -0
- package/src/customTemplates/__tests__/library.test.ts +88 -0
- package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
- package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
- package/src/customTemplates/designer.css +673 -0
- package/src/customTemplates/index.ts +31 -0
- package/src/customTemplates/library.ts +97 -0
- package/src/customTemplates/normalizePositions.ts +75 -0
- package/src/customTemplates/shapeDefs.ts +131 -0
- package/src/customTemplates/thumbnail.tsx +63 -0
- package/src/customTemplates/tokenDefs.ts +60 -0
- package/src/customTemplates/useDocCustomTemplates.ts +52 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
- package/src/customThemes/CustomThemeContext.tsx +179 -0
- package/src/customThemes/CustomThemeDialog.tsx +286 -0
- package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
- package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
- package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
- package/src/customThemes/customThemeLibrary.ts +97 -0
- package/src/customThemes/index.ts +31 -0
- package/src/customThemes/themeControls.tsx +229 -0
- package/src/customThemes/themeDraft.ts +272 -0
- package/src/customThemes/useDocCustomThemes.ts +49 -0
- package/src/diagram/DiagramCanvas.tsx +240 -0
- package/src/diagram/DiagramExtension.ts +209 -0
- package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
- package/src/diagram/DiagramWidget.tsx +270 -0
- package/src/diagram/diagramCommands.ts +604 -0
- package/src/diagram/diagramConstants.ts +17 -0
- package/src/diagram/useDiagramData.ts +126 -0
- package/src/embeddedMedia.ts +78 -0
- package/src/frontmatter.ts +29 -0
- package/src/headingTransition.ts +231 -0
- package/src/imageEditor/CanvasSurface.tsx +383 -88
- package/src/imageEditor/PropertiesPanel.tsx +47 -1
- package/src/imageEditor/Toolbar.tsx +229 -16
- package/src/imageEditor/createShapeLayer.ts +280 -0
- package/src/imageEditor/icons.tsx +34 -114
- package/src/imageEditor/image-editor.css +54 -5
- package/src/imageEditor/state.ts +23 -3
- package/src/index.ts +77 -0
- package/src/recorder/RecorderModal.tsx +120 -53
- package/src/recorder/RecorderPanel.tsx +2 -26
- package/src/recorder/hooks/useMediaRecorder.ts +17 -2
- package/src/recorder/insertMediaBlock.ts +30 -0
- package/src/resolveBlockVisual.ts +33 -0
- package/src/scene/Scene.tsx +540 -0
- package/src/scene/SceneBlockExtension.ts +198 -0
- package/src/scene/SceneBlockToolbar.tsx +201 -0
- package/src/scene/SceneBlockWidget.tsx +434 -0
- package/src/scene/ScenePropsBar.tsx +85 -0
- package/src/scene/SceneSelection.tsx +107 -0
- package/src/scene/SceneViewport.tsx +102 -0
- package/src/scene/ShapePalette.tsx +181 -0
- package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
- package/src/scene/__tests__/bezierEdit.test.ts +85 -0
- package/src/scene/__tests__/blockLayers.test.ts +57 -0
- package/src/scene/__tests__/shapeLayers.test.ts +106 -0
- package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
- package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
- package/src/scene/adapters/DiagramAdapter.ts +168 -0
- package/src/scene/adapters/DrawingAdapter.ts +415 -0
- package/src/scene/adapters/LayoutAdapter.ts +310 -0
- package/src/scene/adapters/blockLayers.ts +159 -0
- package/src/scene/commands/SceneCommand.ts +70 -0
- package/src/scene/commands/drawingCommands.ts +318 -0
- package/src/scene/commands/layoutCommands.ts +301 -0
- package/src/scene/hooks/useSceneHitTest.ts +105 -0
- package/src/scene/hooks/useScenePanZoom.ts +147 -0
- package/src/scene/hooks/useSceneSelection.ts +62 -0
- package/src/scene/index.ts +95 -0
- package/src/scene/layers/DiagramEdges.tsx +127 -0
- package/src/scene/layers/edgeGeometry.ts +77 -0
- package/src/scene/layers/nodeCard.tsx +145 -0
- package/src/scene/layers/renderLayer.tsx +70 -0
- package/src/scene/layers/shapeLayers.ts +201 -0
- package/src/scene/paths/bezierEdit.ts +208 -0
- package/src/scene/scene.css +649 -0
- package/src/scene/text/SceneTextOverlay.tsx +161 -0
- package/src/scene/text/sceneTextChannel.ts +40 -0
- package/src/scene/text/sceneTextConfig.ts +27 -0
- package/src/scene/text/sceneTiptap.ts +36 -0
- package/src/scene/text/useSceneTextEditing.ts +39 -0
- package/src/scene/tools/ConnectTool.ts +111 -0
- package/src/scene/tools/DrawingConnectTool.ts +161 -0
- package/src/scene/tools/PathTool.ts +158 -0
- package/src/scene/tools/PlaceTool.ts +47 -0
- package/src/scene/tools/SceneTool.ts +75 -0
- package/src/scene/tools/SelectTool.ts +284 -0
- package/src/scene/tools/ShapeTool.ts +144 -0
- package/src/scene/tools/TextTool.ts +72 -0
- package/src/scene/tools/TokenTool.ts +95 -0
- package/src/scene/tools/createDrawShapeTool.ts +82 -0
- package/src/styles/diagram.css +183 -0
- package/src/styles/editor.css +1615 -203
- package/src/styles/folder-view.css +210 -0
- package/src/styles/image-edit-affordance.css +2 -2
- package/src/styles/index.css +4 -0
- package/src/timelineSource.ts +244 -0
- package/src/tiptapBridge.ts +115 -34
- package/src/tooltipPlacement.ts +13 -0
- package/src/transitionCatalog.ts +159 -0
- package/src/types/monaco-shims.d.ts +10 -0
- package/src/useBlockNavigator.ts +153 -0
- package/src/useMonacoLoader.ts +105 -0
- package/src/useTimelineClock.ts +76 -0
- package/src/utils/dropUtils.ts +1 -1
package/src/ImageEditor.tsx
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* `exportImageEditDoc` from `@bendyline/squisq/imageEdit`.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { useCallback, useState } from 'react';
|
|
16
|
+
import { useCallback, useRef, useState } from 'react';
|
|
17
17
|
import type { ContentContainer } from '@bendyline/squisq/storage';
|
|
18
18
|
import { exportImageEditDoc, type ImageEditExportFormat } from '@bendyline/squisq/imageEdit';
|
|
19
19
|
import type { SurfaceScheme, Theme } from '@bendyline/squisq/schemas';
|
|
@@ -24,6 +24,13 @@ import { PropertiesPanel } from './imageEditor/PropertiesPanel.js';
|
|
|
24
24
|
import { Toolbar } from './imageEditor/Toolbar.js';
|
|
25
25
|
import { useImageEditor } from './imageEditor/useImageEditor.js';
|
|
26
26
|
import { useImageEditorTokens } from './imageEditor/useImageEditorTokens.js';
|
|
27
|
+
import {
|
|
28
|
+
createShapeLayer,
|
|
29
|
+
createLinearShapeLayer,
|
|
30
|
+
isLinearShapeKind,
|
|
31
|
+
} from './imageEditor/createShapeLayer.js';
|
|
32
|
+
|
|
33
|
+
const ZOOM_STEPS = [0.0625, 0.083, 0.125, 0.167, 0.25, 0.333, 0.5, 0.667, 1, 1.5, 2, 3, 4, 8, 16];
|
|
27
34
|
|
|
28
35
|
export interface ImageEditorProps {
|
|
29
36
|
/**
|
|
@@ -107,6 +114,33 @@ export function ImageEditor(props: ImageEditorProps) {
|
|
|
107
114
|
// re-lists without polling.
|
|
108
115
|
const [historyRefreshKey, setHistoryRefreshKey] = useState(0);
|
|
109
116
|
|
|
117
|
+
const surfaceRef = useRef<HTMLDivElement>(null);
|
|
118
|
+
const [zoom, setZoom] = useState(1.0);
|
|
119
|
+
|
|
120
|
+
const handleZoomIn = useCallback(() => {
|
|
121
|
+
setZoom((z) => ZOOM_STEPS.find((s) => s > z + 0.001) ?? 16);
|
|
122
|
+
}, []);
|
|
123
|
+
const handleZoomOut = useCallback(() => {
|
|
124
|
+
setZoom((z) => [...ZOOM_STEPS].reverse().find((s) => s < z - 0.001) ?? 0.0625);
|
|
125
|
+
}, []);
|
|
126
|
+
const handleZoomSet = useCallback((z: number) => {
|
|
127
|
+
setZoom(Math.max(0.0625, Math.min(16, z)));
|
|
128
|
+
}, []);
|
|
129
|
+
const handleZoom1to1 = useCallback(() => setZoom(1), []);
|
|
130
|
+
const handleZoomFit = useCallback(() => {
|
|
131
|
+
if (!state || !surfaceRef.current) return;
|
|
132
|
+
const { clientWidth, clientHeight } = surfaceRef.current;
|
|
133
|
+
const PADDING = 32;
|
|
134
|
+
const fitZ = Math.min(
|
|
135
|
+
(clientWidth - PADDING) / state.doc.canvas.width,
|
|
136
|
+
(clientHeight - PADDING) / state.doc.canvas.height,
|
|
137
|
+
);
|
|
138
|
+
setZoom(Math.max(0.0625, Math.min(16, fitZ)));
|
|
139
|
+
}, [state]);
|
|
140
|
+
|
|
141
|
+
// ID of a newly-created text layer that should immediately enter inline editing.
|
|
142
|
+
const [requestEditLayerId, setRequestEditLayerId] = useState<string | null>(null);
|
|
143
|
+
|
|
110
144
|
const handleExport = useCallback(
|
|
111
145
|
async (format: ImageEditExportFormat) => {
|
|
112
146
|
if (!state) return;
|
|
@@ -198,9 +232,11 @@ export function ImageEditor(props: ImageEditorProps) {
|
|
|
198
232
|
|
|
199
233
|
const handleCreateTextAt = useCallback(
|
|
200
234
|
(x: number, y: number) => {
|
|
235
|
+
const id = `layer-${Math.random().toString(36).slice(2, 10)}`;
|
|
201
236
|
dispatch({
|
|
202
237
|
type: 'add-layer',
|
|
203
238
|
layer: {
|
|
239
|
+
id,
|
|
204
240
|
type: 'text',
|
|
205
241
|
name: 'Text',
|
|
206
242
|
position: { x: Math.round(x), y: Math.round(y), width: 240, height: 48 },
|
|
@@ -211,35 +247,33 @@ export function ImageEditor(props: ImageEditorProps) {
|
|
|
211
247
|
},
|
|
212
248
|
});
|
|
213
249
|
dispatch({ type: 'set-tool', tool: 'select' });
|
|
250
|
+
setRequestEditLayerId(id);
|
|
214
251
|
},
|
|
215
252
|
[dispatch],
|
|
216
253
|
);
|
|
217
254
|
|
|
255
|
+
const shapeKind = state?.shapeKind ?? 'rectangle';
|
|
256
|
+
const shapeDragDraw = isLinearShapeKind(shapeKind);
|
|
257
|
+
|
|
218
258
|
const handleCreateShapeAt = useCallback(
|
|
219
259
|
(x: number, y: number) => {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
stroke: '#1a4d80',
|
|
235
|
-
strokeWidth: 2,
|
|
236
|
-
borderRadius: 8,
|
|
237
|
-
},
|
|
238
|
-
},
|
|
239
|
-
});
|
|
260
|
+
const layer = createShapeLayer(shapeKind, x, y);
|
|
261
|
+
const id = `layer-${Math.random().toString(36).slice(2, 10)}`;
|
|
262
|
+
dispatch({ type: 'add-layer', layer: { ...layer, id } });
|
|
263
|
+
dispatch({ type: 'set-tool', tool: 'select' });
|
|
264
|
+
if (layer.type === 'text') {
|
|
265
|
+
setRequestEditLayerId(id);
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
[dispatch, shapeKind],
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const handleCreateShapeFromPoints = useCallback(
|
|
272
|
+
(x1: number, y1: number, x2: number, y2: number) => {
|
|
273
|
+
dispatch({ type: 'add-layer', layer: createLinearShapeLayer(shapeKind, x1, y1, x2, y2) });
|
|
240
274
|
dispatch({ type: 'set-tool', tool: 'select' });
|
|
241
275
|
},
|
|
242
|
-
[dispatch],
|
|
276
|
+
[dispatch, shapeKind],
|
|
243
277
|
);
|
|
244
278
|
|
|
245
279
|
if (error) {
|
|
@@ -275,6 +309,7 @@ export function ImageEditor(props: ImageEditorProps) {
|
|
|
275
309
|
<Toolbar
|
|
276
310
|
doc={state.doc}
|
|
277
311
|
tool={state.tool}
|
|
312
|
+
shapeKind={state.shapeKind}
|
|
278
313
|
dispatch={dispatch}
|
|
279
314
|
uploadAsset={uploadAsset}
|
|
280
315
|
onExport={handleExport}
|
|
@@ -296,6 +331,12 @@ export function ImageEditor(props: ImageEditorProps) {
|
|
|
296
331
|
/>
|
|
297
332
|
) : null
|
|
298
333
|
}
|
|
334
|
+
zoom={zoom}
|
|
335
|
+
onZoomIn={handleZoomIn}
|
|
336
|
+
onZoomOut={handleZoomOut}
|
|
337
|
+
onZoomSet={handleZoomSet}
|
|
338
|
+
onZoomFit={handleZoomFit}
|
|
339
|
+
onZoom1to1={handleZoom1to1}
|
|
299
340
|
/>
|
|
300
341
|
<div className="squisq-image-editor-body">
|
|
301
342
|
<div className="squisq-image-editor-center">
|
|
@@ -307,6 +348,12 @@ export function ImageEditor(props: ImageEditorProps) {
|
|
|
307
348
|
dispatch={dispatch}
|
|
308
349
|
onCreateTextAt={handleCreateTextAt}
|
|
309
350
|
onCreateShapeAt={handleCreateShapeAt}
|
|
351
|
+
onCreateShapeFromPoints={handleCreateShapeFromPoints}
|
|
352
|
+
shapeDragDraw={shapeDragDraw}
|
|
353
|
+
zoom={zoom}
|
|
354
|
+
onSetZoom={handleZoomSet}
|
|
355
|
+
surfaceRef={surfaceRef}
|
|
356
|
+
requestEditLayerId={requestEditLayerId}
|
|
310
357
|
/>
|
|
311
358
|
</div>
|
|
312
359
|
<div className="squisq-image-editor-side">
|
package/src/OutlinePanel.tsx
CHANGED
|
@@ -25,10 +25,45 @@ export interface OutlinePanelProps {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export function OutlinePanel({ width = 240, className }: OutlinePanelProps) {
|
|
28
|
-
const {
|
|
28
|
+
const {
|
|
29
|
+
doc,
|
|
30
|
+
markdownSource,
|
|
31
|
+
setMarkdownSource,
|
|
32
|
+
layoutMode,
|
|
33
|
+
goToBlockByLine,
|
|
34
|
+
activeBlockStartLine,
|
|
35
|
+
} = useEditorContext();
|
|
29
36
|
const paneRef = useRef<HTMLElement | null>(null);
|
|
30
37
|
const { scrollToBlock } = useHeadingLayout(paneRef);
|
|
31
|
-
const
|
|
38
|
+
const cursorActiveId = useActiveOutlineBlockId();
|
|
39
|
+
|
|
40
|
+
// In block-at-a-time mode there's no live cursor across the whole document
|
|
41
|
+
// (only the active block is mounted), so the highlight follows the card:
|
|
42
|
+
// match the block whose heading begins on the active slice's source line.
|
|
43
|
+
const blockModeActiveId = useMemo(() => {
|
|
44
|
+
if (layoutMode !== 'block' || activeBlockStartLine == null || !doc) return null;
|
|
45
|
+
const match = flattenBlocks(doc.blocks).find(
|
|
46
|
+
(b) => b.sourceHeading?.position?.start.line === activeBlockStartLine,
|
|
47
|
+
);
|
|
48
|
+
return match?.id ?? null;
|
|
49
|
+
}, [layoutMode, activeBlockStartLine, doc]);
|
|
50
|
+
const activeBlockId = layoutMode === 'block' ? blockModeActiveId : cursorActiveId;
|
|
51
|
+
|
|
52
|
+
// Clicking a row jumps the card to that block in block mode, or scrolls the
|
|
53
|
+
// editor to it in document mode.
|
|
54
|
+
const handleSelect = useCallback(
|
|
55
|
+
(block: Block) => {
|
|
56
|
+
if (layoutMode === 'block') {
|
|
57
|
+
const line = block.sourceHeading?.position?.start.line;
|
|
58
|
+
if (typeof line === 'number') {
|
|
59
|
+
goToBlockByLine(line);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
scrollToBlock(block);
|
|
64
|
+
},
|
|
65
|
+
[layoutMode, goToBlockByLine, scrollToBlock],
|
|
66
|
+
);
|
|
32
67
|
|
|
33
68
|
// Promote / demote the row's heading by rewriting just the `#` prefix
|
|
34
69
|
// on the heading line. Falls through when the new depth would leave the
|
|
@@ -81,7 +116,7 @@ export function OutlinePanel({ width = 240, className }: OutlinePanelProps) {
|
|
|
81
116
|
key={b.id}
|
|
82
117
|
block={b}
|
|
83
118
|
activeBlockId={activeBlockId}
|
|
84
|
-
onSelect={
|
|
119
|
+
onSelect={handleSelect}
|
|
85
120
|
onChangeLevel={changeHeadingLevel}
|
|
86
121
|
/>
|
|
87
122
|
))}
|
package/src/PlainHtmlPreview.tsx
CHANGED
|
@@ -21,10 +21,25 @@ import type { CSSProperties } from 'react';
|
|
|
21
21
|
import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
22
22
|
import type { MarkdownDocument, HtmlNode } from '@bendyline/squisq/markdown';
|
|
23
23
|
import type { MediaProvider, Theme } from '@bendyline/squisq/schemas';
|
|
24
|
-
import { markdownDocToPlainHtml } from '@bendyline/squisq-formats/html';
|
|
25
24
|
import { normalizeMalformedAssetUrl } from './utils/normalizeMalformedAssetUrl';
|
|
26
25
|
import { collectInlineFontAwesomeCss } from './utils/collectInlineFontAwesomeCss';
|
|
27
26
|
|
|
27
|
+
// formats/html is ~50 KB and only needed for the plain-HTML preview pane.
|
|
28
|
+
// Lazy-load on first render so it doesn't sit in the editor's main chunk.
|
|
29
|
+
type RenderFn = typeof import('@bendyline/squisq-formats/html').markdownDocToPlainHtml;
|
|
30
|
+
let cachedRender: RenderFn | null = null;
|
|
31
|
+
let cachedRenderPromise: Promise<RenderFn> | null = null;
|
|
32
|
+
function loadRenderFn(): Promise<RenderFn> {
|
|
33
|
+
if (cachedRender) return Promise.resolve(cachedRender);
|
|
34
|
+
if (!cachedRenderPromise) {
|
|
35
|
+
cachedRenderPromise = import('@bendyline/squisq-formats/html').then((m) => {
|
|
36
|
+
cachedRender = m.markdownDocToPlainHtml;
|
|
37
|
+
return cachedRender;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return cachedRenderPromise;
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
export interface PlainHtmlPreviewProps {
|
|
29
44
|
/** Raw markdown source. */
|
|
30
45
|
markdown: string;
|
|
@@ -129,9 +144,21 @@ export function PlainHtmlPreview({
|
|
|
129
144
|
// fetch under `allow-same-origin`.
|
|
130
145
|
const iconsCss = useMemo(() => collectInlineFontAwesomeCss(), []);
|
|
131
146
|
|
|
147
|
+
const [renderFn, setRenderFn] = useState<RenderFn | null>(() => cachedRender);
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (renderFn) return;
|
|
150
|
+
let cancelled = false;
|
|
151
|
+
loadRenderFn().then((fn) => {
|
|
152
|
+
if (!cancelled) setRenderFn(() => fn);
|
|
153
|
+
});
|
|
154
|
+
return () => {
|
|
155
|
+
cancelled = true;
|
|
156
|
+
};
|
|
157
|
+
}, [renderFn]);
|
|
158
|
+
|
|
132
159
|
const html = useMemo(
|
|
133
|
-
() =>
|
|
134
|
-
[mdDoc, title, mergedImages, theme, iconsCss],
|
|
160
|
+
() => (renderFn ? renderFn(mdDoc, { title, images: mergedImages, theme, iconsCss }) : ''),
|
|
161
|
+
[renderFn, mdDoc, title, mergedImages, theme, iconsCss],
|
|
135
162
|
);
|
|
136
163
|
|
|
137
164
|
return (
|
package/src/PreviewControls.tsx
CHANGED
|
@@ -22,13 +22,19 @@ import {
|
|
|
22
22
|
import type { ReactNode } from 'react';
|
|
23
23
|
import type { DisplayMode, CaptionStyle } from '@bendyline/squisq-react';
|
|
24
24
|
import type { ViewportPreset, ViewportConfig } from '@bendyline/squisq/schemas';
|
|
25
|
-
import { VIEWPORT_PRESETS, getThemeSummaries
|
|
25
|
+
import { VIEWPORT_PRESETS, getThemeSummaries } from '@bendyline/squisq/schemas';
|
|
26
26
|
import type { Theme } from '@bendyline/squisq/schemas';
|
|
27
27
|
import { ThemePicker } from './ThemePicker';
|
|
28
28
|
import { getTransformStyleSummaries } from '@bendyline/squisq/transform';
|
|
29
29
|
import type { Doc } from '@bendyline/squisq/schemas';
|
|
30
30
|
import { setFrontmatterValues } from '@bendyline/squisq/markdown';
|
|
31
|
+
import {
|
|
32
|
+
resolveThemeForDoc,
|
|
33
|
+
writeCustomThemesToFrontmatter,
|
|
34
|
+
FRONTMATTER_CUSTOM_THEMES_KEY,
|
|
35
|
+
} from '@bendyline/squisq/doc';
|
|
31
36
|
import { useEditorContext } from './EditorContext';
|
|
37
|
+
import { useCustomThemes, CustomThemeDialog, type ThemeSaveTarget } from './customThemes';
|
|
32
38
|
|
|
33
39
|
// ── Context ──────────────────────────────────────────────────────
|
|
34
40
|
|
|
@@ -45,6 +51,23 @@ export interface PreviewSettings {
|
|
|
45
51
|
setSelectedTransformStyle: (id: string | null) => void;
|
|
46
52
|
activeCaptionStyle: CaptionStyle;
|
|
47
53
|
setSelectedCaptionStyle: (style: CaptionStyle | null) => void;
|
|
54
|
+
/** User-authored themes (doc + browser library) for the picker's "Custom" group. */
|
|
55
|
+
customThemes: Theme[];
|
|
56
|
+
/** Open the custom-theme designer for a theme (or null to create a new one). */
|
|
57
|
+
openThemeDesigner: (theme: Theme | null) => void;
|
|
58
|
+
/** Remove a custom theme from the doc and the library. */
|
|
59
|
+
deleteCustomTheme: (id: string) => void;
|
|
60
|
+
/** Config for the docked theme designer, or null when closed. Rendered by
|
|
61
|
+
* `<ThemeDesignerDock>` in the editor's content row. */
|
|
62
|
+
themeDesigner: ThemeDesignerConfig | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Everything `<ThemeDesignerDock>` needs to render the designer pane. */
|
|
66
|
+
export interface ThemeDesignerConfig {
|
|
67
|
+
value: Theme | null;
|
|
68
|
+
onChange: (theme: Theme) => void;
|
|
69
|
+
onSave: (theme: Theme, target: ThemeSaveTarget) => void;
|
|
70
|
+
onClose: () => void;
|
|
48
71
|
}
|
|
49
72
|
|
|
50
73
|
const PreviewSettingsContext = createContext<PreviewSettings | null>(null);
|
|
@@ -98,12 +121,17 @@ function resolveDisplayMode(value: unknown): DisplayMode | null {
|
|
|
98
121
|
|
|
99
122
|
const VALID_THEME_IDS = new Set(getThemeSummaries().map((s) => s.id));
|
|
100
123
|
|
|
101
|
-
function resolveFrontmatterTheme(value: unknown): string | null {
|
|
124
|
+
function resolveFrontmatterTheme(value: unknown, customIds?: Set<string>): string | null {
|
|
102
125
|
if (typeof value !== 'string') return null;
|
|
103
|
-
const
|
|
126
|
+
const raw = value.trim();
|
|
127
|
+
const v = raw.toLowerCase();
|
|
104
128
|
if (VALID_THEME_IDS.has(v)) return v;
|
|
105
129
|
const normalized = v.replace(/\s+/g, '-');
|
|
106
130
|
if (VALID_THEME_IDS.has(normalized)) return normalized;
|
|
131
|
+
// Custom theme ids are doc-defined slugs (lowercase from the customizer).
|
|
132
|
+
// Admit them so an inline theme selection isn't rejected back to 'standard'.
|
|
133
|
+
if (customIds?.has(raw)) return raw;
|
|
134
|
+
if (customIds?.has(v)) return v;
|
|
107
135
|
return null;
|
|
108
136
|
}
|
|
109
137
|
|
|
@@ -192,21 +220,43 @@ export function PreviewSettingsProvider({
|
|
|
192
220
|
useEffect(() => setSelectedDisplayMode(null), [fmMode]);
|
|
193
221
|
const activeDisplayMode = selectedDisplayMode ?? fmMode ?? 'video';
|
|
194
222
|
|
|
223
|
+
// Custom themes (doc + browser library). `useCustomThemes` returns null when
|
|
224
|
+
// no provider is mounted; the picker then just shows built-ins.
|
|
225
|
+
const custom = useCustomThemes();
|
|
226
|
+
const customThemes = useMemo(() => custom?.allThemes ?? [], [custom]);
|
|
227
|
+
const customIds = useMemo(() => new Set(customThemes.map((t) => t.id)), [customThemes]);
|
|
228
|
+
|
|
195
229
|
// Theme — persisted to `squisq-theme` (legacy `theme` still read for compat)
|
|
196
230
|
const fmTheme = useMemo(
|
|
197
231
|
() =>
|
|
198
232
|
resolveFrontmatterTheme(
|
|
199
233
|
readFrontmatterKey(frontmatter, FM_KEYS.theme.canonical, FM_KEYS.theme.legacy),
|
|
234
|
+
customIds,
|
|
200
235
|
),
|
|
201
|
-
[frontmatter],
|
|
236
|
+
[frontmatter, customIds],
|
|
202
237
|
);
|
|
203
238
|
const [selectedThemeId, setSelectedThemeId] = useState<string | null>(null);
|
|
204
239
|
useEffect(() => setSelectedThemeId(null), [fmTheme]);
|
|
205
240
|
const resolvedThemeId = selectedThemeId ?? fmTheme ?? 'standard';
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
const
|
|
209
|
-
|
|
241
|
+
// Doc-scoped resolution: an inline custom theme id resolves from the doc's
|
|
242
|
+
// own `customThemes` before built-ins — no global registration needed.
|
|
243
|
+
const resolvedTheme = useMemo(
|
|
244
|
+
() => resolveThemeForDoc(doc, resolvedThemeId),
|
|
245
|
+
[doc, resolvedThemeId],
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
// In-progress theme from the designer dialog; previews live without mutating
|
|
249
|
+
// the doc until the user saves.
|
|
250
|
+
const [previewTheme, setPreviewTheme] = useState<Theme | null>(null);
|
|
251
|
+
const [designer, setDesigner] = useState<{ open: boolean; editing: Theme | null }>({
|
|
252
|
+
open: false,
|
|
253
|
+
editing: null,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Precedence: an active designer preview > external themeOverride > the
|
|
257
|
+
// dropdown/frontmatter selection.
|
|
258
|
+
const activeThemeId = previewTheme?.id ?? themeOverride?.id ?? resolvedThemeId;
|
|
259
|
+
const activeTheme = previewTheme ?? themeOverride ?? resolvedTheme;
|
|
210
260
|
const handleSetThemeId = useCallback(
|
|
211
261
|
(id: string | null) => {
|
|
212
262
|
setSelectedThemeId(id);
|
|
@@ -215,6 +265,45 @@ export function PreviewSettingsProvider({
|
|
|
215
265
|
[persistFrontmatter],
|
|
216
266
|
);
|
|
217
267
|
|
|
268
|
+
const openThemeDesigner = useCallback((theme: Theme | null) => {
|
|
269
|
+
setDesigner({ open: true, editing: theme });
|
|
270
|
+
setPreviewTheme(theme);
|
|
271
|
+
}, []);
|
|
272
|
+
const closeThemeDesigner = useCallback(() => {
|
|
273
|
+
setDesigner({ open: false, editing: null });
|
|
274
|
+
setPreviewTheme(null);
|
|
275
|
+
}, []);
|
|
276
|
+
const handleDesignerSave = useCallback(
|
|
277
|
+
(theme: Theme, target: ThemeSaveTarget) => {
|
|
278
|
+
if (target === 'library') {
|
|
279
|
+
custom?.upsertLibraryTheme(theme);
|
|
280
|
+
} else {
|
|
281
|
+
// Write the theme payload AND select it in a SINGLE frontmatter update.
|
|
282
|
+
// Two separate `setMarkdownSource` calls (upsertDocTheme + a squisq-theme
|
|
283
|
+
// write) would each derive from the same stale source, so the second
|
|
284
|
+
// would clobber the first and drop the custom-themes payload.
|
|
285
|
+
const docThemes = custom?.docThemes ?? [];
|
|
286
|
+
const idx = docThemes.findIndex((t) => t.id === theme.id);
|
|
287
|
+
const nextThemes =
|
|
288
|
+
idx >= 0 ? docThemes.map((t, i) => (i === idx ? theme : t)) : [...docThemes, theme];
|
|
289
|
+
persistFrontmatter({
|
|
290
|
+
[FRONTMATTER_CUSTOM_THEMES_KEY]: writeCustomThemesToFrontmatter(nextThemes) ?? null,
|
|
291
|
+
[FM_KEYS.theme.canonical]: theme.id,
|
|
292
|
+
});
|
|
293
|
+
setSelectedThemeId(theme.id);
|
|
294
|
+
}
|
|
295
|
+
closeThemeDesigner();
|
|
296
|
+
},
|
|
297
|
+
[custom, persistFrontmatter, closeThemeDesigner],
|
|
298
|
+
);
|
|
299
|
+
const deleteCustomTheme = useCallback(
|
|
300
|
+
(id: string) => {
|
|
301
|
+
custom?.removeDocTheme(id);
|
|
302
|
+
custom?.removeLibraryTheme(id);
|
|
303
|
+
},
|
|
304
|
+
[custom],
|
|
305
|
+
);
|
|
306
|
+
|
|
218
307
|
// Transform — persisted to `squisq-transform` (legacy `transform-style` read for compat)
|
|
219
308
|
const fmTransform = useMemo(
|
|
220
309
|
() =>
|
|
@@ -256,6 +345,21 @@ export function PreviewSettingsProvider({
|
|
|
256
345
|
[persistFrontmatter],
|
|
257
346
|
);
|
|
258
347
|
|
|
348
|
+
// Config for the docked designer (rendered by `<ThemeDesignerDock>` in the
|
|
349
|
+
// editor content row). Null when closed. setPreviewTheme is a stable setter.
|
|
350
|
+
const themeDesigner = useMemo<ThemeDesignerConfig | null>(
|
|
351
|
+
() =>
|
|
352
|
+
designer.open
|
|
353
|
+
? {
|
|
354
|
+
value: designer.editing,
|
|
355
|
+
onChange: setPreviewTheme,
|
|
356
|
+
onSave: handleDesignerSave,
|
|
357
|
+
onClose: closeThemeDesigner,
|
|
358
|
+
}
|
|
359
|
+
: null,
|
|
360
|
+
[designer.open, designer.editing, handleDesignerSave, closeThemeDesigner],
|
|
361
|
+
);
|
|
362
|
+
|
|
259
363
|
const value = useMemo<PreviewSettings>(
|
|
260
364
|
() => ({
|
|
261
365
|
activePreset,
|
|
@@ -270,6 +374,10 @@ export function PreviewSettingsProvider({
|
|
|
270
374
|
setSelectedTransformStyle: handleSetTransformStyle,
|
|
271
375
|
activeCaptionStyle,
|
|
272
376
|
setSelectedCaptionStyle: handleSetCaptionStyle,
|
|
377
|
+
customThemes,
|
|
378
|
+
openThemeDesigner,
|
|
379
|
+
deleteCustomTheme,
|
|
380
|
+
themeDesigner,
|
|
273
381
|
}),
|
|
274
382
|
[
|
|
275
383
|
activePreset,
|
|
@@ -282,6 +390,10 @@ export function PreviewSettingsProvider({
|
|
|
282
390
|
handleSetThemeId,
|
|
283
391
|
handleSetTransformStyle,
|
|
284
392
|
handleSetCaptionStyle,
|
|
393
|
+
customThemes,
|
|
394
|
+
openThemeDesigner,
|
|
395
|
+
deleteCustomTheme,
|
|
396
|
+
themeDesigner,
|
|
285
397
|
],
|
|
286
398
|
);
|
|
287
399
|
|
|
@@ -290,6 +402,25 @@ export function PreviewSettingsProvider({
|
|
|
290
402
|
);
|
|
291
403
|
}
|
|
292
404
|
|
|
405
|
+
/**
|
|
406
|
+
* ThemeDesignerDock — renders the docked custom-theme designer pane when open.
|
|
407
|
+
* Placed as a flex sibling of the preview in `EditorShell`'s content row so the
|
|
408
|
+
* preview reflows narrower beside it. Renders nothing when the designer is
|
|
409
|
+
* closed. Must be mounted inside a `PreviewSettingsProvider`.
|
|
410
|
+
*/
|
|
411
|
+
export function ThemeDesignerDock() {
|
|
412
|
+
const { themeDesigner } = usePreviewSettings();
|
|
413
|
+
if (!themeDesigner) return null;
|
|
414
|
+
return (
|
|
415
|
+
<CustomThemeDialog
|
|
416
|
+
value={themeDesigner.value}
|
|
417
|
+
onChange={themeDesigner.onChange}
|
|
418
|
+
onSave={themeDesigner.onSave}
|
|
419
|
+
onClose={themeDesigner.onClose}
|
|
420
|
+
/>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
293
424
|
// ── Dropdown options ─────────────────────────────────────────────
|
|
294
425
|
|
|
295
426
|
const VIEWPORT_OPTIONS: { key: ViewportPreset; label: string }[] = [
|
|
@@ -372,6 +503,19 @@ export function PreviewToolbarControls() {
|
|
|
372
503
|
return () => document.removeEventListener('mousedown', handler);
|
|
373
504
|
}, [popoverOpen]);
|
|
374
505
|
|
|
506
|
+
// "Edit theme" pencil next to the dropdown. A custom theme opens directly in
|
|
507
|
+
// the designer; a built-in seeds a NEW "Modified <name>" custom theme based
|
|
508
|
+
// on it — Cancel leaves the current theme untouched, Save engages the custom.
|
|
509
|
+
const handleEditCurrentTheme = useCallback(() => {
|
|
510
|
+
const existing = s.customThemes.find((t) => t.id === s.activeThemeId);
|
|
511
|
+
if (existing) {
|
|
512
|
+
s.openThemeDesigner(existing);
|
|
513
|
+
} else {
|
|
514
|
+
const base = s.activeTheme;
|
|
515
|
+
s.openThemeDesigner({ ...base, name: `Modified ${base.name}`, basedOn: base.id });
|
|
516
|
+
}
|
|
517
|
+
}, [s]);
|
|
518
|
+
|
|
375
519
|
const controls = (
|
|
376
520
|
<>
|
|
377
521
|
<PreviewSelect
|
|
@@ -396,7 +540,35 @@ export function PreviewToolbarControls() {
|
|
|
396
540
|
value={s.activeThemeId}
|
|
397
541
|
onChange={(v) => s.setSelectedThemeId(v)}
|
|
398
542
|
ariaLabel="Theme"
|
|
543
|
+
customThemes={s.customThemes}
|
|
544
|
+
onCreateCustom={() => s.openThemeDesigner(null)}
|
|
545
|
+
onEditCustom={(id) =>
|
|
546
|
+
s.openThemeDesigner(s.customThemes.find((t) => t.id === id) ?? null)
|
|
547
|
+
}
|
|
548
|
+
onDeleteCustom={(id) => s.deleteCustomTheme(id)}
|
|
399
549
|
/>
|
|
550
|
+
<button
|
|
551
|
+
type="button"
|
|
552
|
+
className="squisq-theme-edit-btn"
|
|
553
|
+
onClick={handleEditCurrentTheme}
|
|
554
|
+
aria-label="Edit theme"
|
|
555
|
+
title="Edit this theme"
|
|
556
|
+
>
|
|
557
|
+
<svg
|
|
558
|
+
width="14"
|
|
559
|
+
height="14"
|
|
560
|
+
viewBox="0 0 16 16"
|
|
561
|
+
fill="none"
|
|
562
|
+
stroke="currentColor"
|
|
563
|
+
strokeWidth="1.4"
|
|
564
|
+
strokeLinecap="round"
|
|
565
|
+
strokeLinejoin="round"
|
|
566
|
+
aria-hidden="true"
|
|
567
|
+
>
|
|
568
|
+
<path d="M2.5 13.5l1-3 7-7 2 2-7 7-3 1z" />
|
|
569
|
+
<path d="M9.5 4.5l2 2" />
|
|
570
|
+
</svg>
|
|
571
|
+
</button>
|
|
400
572
|
</div>
|
|
401
573
|
<PreviewSelect
|
|
402
574
|
label="Transform"
|