@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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read diagram nodes + edges from the live Tiptap state.
|
|
3
|
+
*
|
|
4
|
+
* For a given parent heading position, walks the diagram section's child
|
|
5
|
+
* headings, builds synthetic `Block` objects from their text + Pandoc
|
|
6
|
+
* attributes, runs `computeDiagramLayout` from core to fill in missing
|
|
7
|
+
* positions, and returns the result in the shape React Flow consumes.
|
|
8
|
+
*
|
|
9
|
+
* The hook re-derives on every editor transaction — no caching layer
|
|
10
|
+
* means there's nothing to invalidate when the user types or the markdown
|
|
11
|
+
* is reloaded from disk.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
15
|
+
import type { Editor } from '@tiptap/react';
|
|
16
|
+
import type { Block } from '@bendyline/squisq/schemas';
|
|
17
|
+
import { computeDiagramLayout } from '@bendyline/squisq/doc';
|
|
18
|
+
import { listDiagramChildren } from './diagramCommands';
|
|
19
|
+
|
|
20
|
+
export interface DiagramRFNode {
|
|
21
|
+
id: string;
|
|
22
|
+
position: { x: number; y: number };
|
|
23
|
+
data: { label: string };
|
|
24
|
+
type?: string;
|
|
25
|
+
/** Per-node width override (from the heading's `w=` Pandoc param). */
|
|
26
|
+
width?: number;
|
|
27
|
+
/** Per-node height override (from the heading's `h=` Pandoc param). */
|
|
28
|
+
height?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DiagramRFEdge {
|
|
32
|
+
id: string;
|
|
33
|
+
source: string;
|
|
34
|
+
target: string;
|
|
35
|
+
label?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DiagramData {
|
|
39
|
+
nodes: DiagramRFNode[];
|
|
40
|
+
edges: DiagramRFEdge[];
|
|
41
|
+
warnings: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useDiagramData(editor: Editor, parentPos: number): DiagramData {
|
|
45
|
+
// Track a version counter that bumps on every editor transaction so the
|
|
46
|
+
// hook re-renders. We avoid storing the full doc to keep references
|
|
47
|
+
// stable for React Flow's internal memoisation.
|
|
48
|
+
const [version, setVersion] = useState(0);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const onUpdate = () => setVersion((v) => v + 1);
|
|
51
|
+
editor.on('transaction', onUpdate);
|
|
52
|
+
return () => {
|
|
53
|
+
editor.off('transaction', onUpdate);
|
|
54
|
+
};
|
|
55
|
+
}, [editor]);
|
|
56
|
+
|
|
57
|
+
return useMemo(() => {
|
|
58
|
+
const children = listDiagramChildren(editor, parentPos);
|
|
59
|
+
// Per-node size overrides keyed by id, so the post-layout pass can
|
|
60
|
+
// re-attach them without polluting the Block schema used by SSR.
|
|
61
|
+
const sizeOverrides = new Map<string, { width?: number; height?: number }>();
|
|
62
|
+
// Build synthetic Blocks for the layout helper.
|
|
63
|
+
const syntheticBlocks: Block[] = children.map((c) => {
|
|
64
|
+
const params = c.attrs.params ?? {};
|
|
65
|
+
const xRaw = params.x;
|
|
66
|
+
const yRaw = params.y;
|
|
67
|
+
const x = xRaw != null ? Number(xRaw) : undefined;
|
|
68
|
+
const y = yRaw != null ? Number(yRaw) : undefined;
|
|
69
|
+
const wRaw = params.w;
|
|
70
|
+
const hRaw = params.h;
|
|
71
|
+
const w = wRaw != null ? Number(wRaw) : undefined;
|
|
72
|
+
const h = hRaw != null ? Number(hRaw) : undefined;
|
|
73
|
+
if (Number.isFinite(w) || Number.isFinite(h)) {
|
|
74
|
+
sizeOverrides.set(c.id, {
|
|
75
|
+
...(Number.isFinite(w) ? { width: w as number } : {}),
|
|
76
|
+
...(Number.isFinite(h) ? { height: h as number } : {}),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const blockMeta = c.attrs.blockMeta;
|
|
80
|
+
const text = getHeadingText(c.node);
|
|
81
|
+
return {
|
|
82
|
+
id: c.id,
|
|
83
|
+
startTime: 0,
|
|
84
|
+
duration: 0,
|
|
85
|
+
audioSegment: 0,
|
|
86
|
+
title: text || c.id,
|
|
87
|
+
...(Number.isFinite(x) ? { x: x as number } : {}),
|
|
88
|
+
...(Number.isFinite(y) ? { y: y as number } : {}),
|
|
89
|
+
...(blockMeta?.connectsTo ? { connectsTo: blockMeta.connectsTo } : {}),
|
|
90
|
+
...(c.attrs.classes ? { classes: c.attrs.classes } : {}),
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const layout = computeDiagramLayout(syntheticBlocks);
|
|
95
|
+
|
|
96
|
+
const nodes: DiagramRFNode[] = layout.nodes.map((n) => {
|
|
97
|
+
const size = sizeOverrides.get(n.id);
|
|
98
|
+
return {
|
|
99
|
+
id: n.id,
|
|
100
|
+
position: { x: n.x, y: n.y },
|
|
101
|
+
data: { label: n.label },
|
|
102
|
+
...(size?.width != null ? { width: size.width } : {}),
|
|
103
|
+
...(size?.height != null ? { height: size.height } : {}),
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
const edges: DiagramRFEdge[] = layout.edges.map((e) => ({
|
|
107
|
+
id: e.id,
|
|
108
|
+
source: e.source,
|
|
109
|
+
target: e.target,
|
|
110
|
+
...(e.type ? { label: e.type } : {}),
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
return { nodes, edges, warnings: layout.warnings };
|
|
114
|
+
// `version` is the dependency we track for transactions; editor and
|
|
115
|
+
// parentPos shouldn't change on re-render.
|
|
116
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
117
|
+
}, [editor, parentPos, version]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getHeadingText(node: import('@tiptap/pm/model').Node): string {
|
|
121
|
+
let text = '';
|
|
122
|
+
node.content.forEach((child) => {
|
|
123
|
+
if (child.isText) text += child.text ?? '';
|
|
124
|
+
});
|
|
125
|
+
return text;
|
|
126
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* embeddedMedia
|
|
3
|
+
*
|
|
4
|
+
* Finds audio/video embedded directly in a block's body — recorder output
|
|
5
|
+
* (`<video src="…">` / `<audio src="…">` HTML tags), dropped files, or markdown
|
|
6
|
+
* links/images to a media file — as opposed to authored `{[audio …]}` clip
|
|
7
|
+
* annotations. The timeline surfaces these so authors see (and can re-time) the
|
|
8
|
+
* media they inserted; editing one converts it to a timed clip annotation.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Block } from '@bendyline/squisq/schemas';
|
|
12
|
+
|
|
13
|
+
const VIDEO_EXT = new Set(['webm', 'mp4', 'mov', 'm4v', 'ogv']);
|
|
14
|
+
const AUDIO_EXT = new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus']);
|
|
15
|
+
|
|
16
|
+
/** Audio/video kind inferred from a URL's extension, or null for other files. */
|
|
17
|
+
export function mediaKindFromUrl(url: string): 'audio' | 'video' | null {
|
|
18
|
+
const ext = url.split('?')[0].split('#')[0].split('.').pop()?.toLowerCase();
|
|
19
|
+
if (!ext) return null;
|
|
20
|
+
if (VIDEO_EXT.has(ext)) return 'video';
|
|
21
|
+
if (AUDIO_EXT.has(ext)) return 'audio';
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface EmbeddedMedia {
|
|
26
|
+
src: string;
|
|
27
|
+
kind: 'audio' | 'video';
|
|
28
|
+
/** 1-based source line of the embed, for re-timing / relocation. */
|
|
29
|
+
sourceLine?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Recursively collect embedded audio/video from a block's body content. */
|
|
33
|
+
export function collectEmbeddedMedia(block: Block): EmbeddedMedia[] {
|
|
34
|
+
const out: EmbeddedMedia[] = [];
|
|
35
|
+
|
|
36
|
+
const visit = (node: unknown, line: number | undefined): void => {
|
|
37
|
+
if (!node || typeof node !== 'object') return;
|
|
38
|
+
const n = node as {
|
|
39
|
+
type?: string;
|
|
40
|
+
url?: unknown;
|
|
41
|
+
tagName?: string;
|
|
42
|
+
attributes?: Record<string, string>;
|
|
43
|
+
children?: unknown;
|
|
44
|
+
htmlChildren?: unknown;
|
|
45
|
+
position?: { start?: { line?: number } };
|
|
46
|
+
};
|
|
47
|
+
const here = n.position?.start?.line ?? line;
|
|
48
|
+
|
|
49
|
+
// Markdown image/link to a media file.
|
|
50
|
+
if ((n.type === 'image' || n.type === 'link') && typeof n.url === 'string') {
|
|
51
|
+
const kind = mediaKindFromUrl(n.url);
|
|
52
|
+
if (kind) out.push({ src: n.url, kind, sourceLine: here });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Raw HTML `<video>` / `<audio>` element (recorder output). The src can be
|
|
56
|
+
// on the element itself or on a nested `<source>`.
|
|
57
|
+
if (n.type === 'htmlElement' && (n.tagName === 'video' || n.tagName === 'audio')) {
|
|
58
|
+
const kind = n.tagName === 'video' ? 'video' : 'audio';
|
|
59
|
+
let src = typeof n.attributes?.src === 'string' ? n.attributes.src : undefined;
|
|
60
|
+
if (!src && Array.isArray(n.children)) {
|
|
61
|
+
for (const child of n.children) {
|
|
62
|
+
const c = child as { tagName?: string; attributes?: Record<string, string> };
|
|
63
|
+
if (c.tagName === 'source' && typeof c.attributes?.src === 'string') {
|
|
64
|
+
src = c.attributes.src;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (src) out.push({ src, kind, sourceLine: here });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (Array.isArray(n.children)) n.children.forEach((c) => visit(c, here));
|
|
73
|
+
if (Array.isArray(n.htmlChildren)) n.htmlChildren.forEach((c) => visit(c, here));
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
(block.contents ?? []).forEach((node) => visit(node, undefined));
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* frontmatter
|
|
3
|
+
*
|
|
4
|
+
* Small helpers for the YAML frontmatter block at the top of a markdown
|
|
5
|
+
* document. The WYSIWYG editor hides frontmatter from its editing surface
|
|
6
|
+
* (and re-prepends it on save), and the block-at-a-time slicer
|
|
7
|
+
* (`blockRange.ts`) needs to know where the body starts so it never folds
|
|
8
|
+
* frontmatter into a block slice. Both share this one definition.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Regex matching a YAML frontmatter block at the start of the document. */
|
|
12
|
+
export const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
13
|
+
|
|
14
|
+
/** Strip YAML frontmatter from markdown, returning both parts. */
|
|
15
|
+
export function stripFrontmatter(md: string): { body: string; frontmatter: string } {
|
|
16
|
+
const m = md.match(FRONTMATTER_RE);
|
|
17
|
+
if (!m) return { body: md, frontmatter: '' };
|
|
18
|
+
return { body: md.slice(m[0].length), frontmatter: m[0] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Character offset where the document body begins — i.e. the length of the
|
|
23
|
+
* leading frontmatter block (including its trailing newline), or 0 when the
|
|
24
|
+
* source has no frontmatter.
|
|
25
|
+
*/
|
|
26
|
+
export function frontmatterEndOffset(source: string): number {
|
|
27
|
+
const m = source.match(FRONTMATTER_RE);
|
|
28
|
+
return m ? m[0].length : 0;
|
|
29
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* headingTransition
|
|
3
|
+
*
|
|
4
|
+
* Read and write a block's transition (`transition` / `transitionDirection` /
|
|
5
|
+
* `transitionDuration`) on a heading, in both editing surfaces:
|
|
6
|
+
*
|
|
7
|
+
* - Markdown (Monaco): operate on the raw heading line string.
|
|
8
|
+
* - WYSIWYG (Tiptap): operate on the heading node's `dataBlockAttrs` string
|
|
9
|
+
* (the inner of the Pandoc `{…}` block, no braces — matching how
|
|
10
|
+
* `tiptapBridge` stores and re-emits it).
|
|
11
|
+
*
|
|
12
|
+
* Transitions are stored in the Pandoc `{#id .class key=value}` attribute
|
|
13
|
+
* block, NOT the `{[template …]}` annotation. That mirrors the canonical
|
|
14
|
+
* serializer (`core/doc/docToMarkdown.ts` → `ensureTransitionAttributes`,
|
|
15
|
+
* which always emits the `{…}` form) and `diagram/diagramCommands.ts`, so a
|
|
16
|
+
* value set here round-trips through a Doc render without being duplicated
|
|
17
|
+
* or moved. Reads still look at the `{[…]}` params too, so a hand-typed
|
|
18
|
+
* `{[title transition=fade]}` shows up in the picker.
|
|
19
|
+
*
|
|
20
|
+
* All the brace-matching / tokenizing / serializing is delegated to the
|
|
21
|
+
* shared core helpers so this stays in lockstep with the parser by import
|
|
22
|
+
* rather than by copied regexes.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
matchTrailingTemplateAnnotation,
|
|
27
|
+
matchTrailingPandocAttr,
|
|
28
|
+
parsePandocAttrTokens,
|
|
29
|
+
serializePandocAttributes,
|
|
30
|
+
tokenizeAttrTokens,
|
|
31
|
+
splitKeyValueToken,
|
|
32
|
+
type HeadingAttributes,
|
|
33
|
+
} from '@bendyline/squisq/markdown';
|
|
34
|
+
|
|
35
|
+
/** Raw (un-coerced) transition attribute values for one block. */
|
|
36
|
+
export interface TransitionFields {
|
|
37
|
+
/** `transition` value. Empty string means "none" (`cut`). */
|
|
38
|
+
type: string;
|
|
39
|
+
/** `transitionDirection` value, or '' when unset. */
|
|
40
|
+
direction: string;
|
|
41
|
+
/** `transitionDuration` value (raw, e.g. `0.7` or `700ms`), or '' when unset. */
|
|
42
|
+
duration: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const EMPTY_TRANSITION: TransitionFields = { type: '', direction: '', duration: '' };
|
|
46
|
+
|
|
47
|
+
/** The three keys this module owns, in canonical emit order. */
|
|
48
|
+
const TRANSITION_KEYS = ['transition', 'transitionDirection', 'transitionDuration'] as const;
|
|
49
|
+
|
|
50
|
+
const HEADING_LINE_RE = /^(#{1,6}\s+)([\s\S]*)$/;
|
|
51
|
+
|
|
52
|
+
// ============================================
|
|
53
|
+
// params map ⇄ fields
|
|
54
|
+
// ============================================
|
|
55
|
+
|
|
56
|
+
function fieldsFromParams(params: Record<string, string> | undefined): TransitionFields {
|
|
57
|
+
if (!params) return { ...EMPTY_TRANSITION };
|
|
58
|
+
return {
|
|
59
|
+
type: params.transition ?? '',
|
|
60
|
+
direction: params.transitionDirection ?? '',
|
|
61
|
+
duration: params.transitionDuration ?? '',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Return a new params map with the transition keys rewritten from `next`,
|
|
67
|
+
* preserving every other key in its original order. When `type` is empty
|
|
68
|
+
* (none), all three transition keys are dropped; direction/duration are only
|
|
69
|
+
* written when a type is present (they're meaningless on their own).
|
|
70
|
+
*/
|
|
71
|
+
function applyFieldsToParams(
|
|
72
|
+
params: Record<string, string>,
|
|
73
|
+
next: TransitionFields,
|
|
74
|
+
): Record<string, string> {
|
|
75
|
+
const out: Record<string, string> = {};
|
|
76
|
+
for (const [key, value] of Object.entries(params)) {
|
|
77
|
+
if ((TRANSITION_KEYS as readonly string[]).includes(key)) continue;
|
|
78
|
+
out[key] = value;
|
|
79
|
+
}
|
|
80
|
+
if (next.type) {
|
|
81
|
+
out.transition = next.type;
|
|
82
|
+
if (next.direction) out.transitionDirection = next.direction;
|
|
83
|
+
if (next.duration) out.transitionDuration = next.duration;
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Parse a bare `key=value …` token string into a params map. */
|
|
89
|
+
function paramsFromTokenString(input: string, skipFirstToken: boolean): Record<string, string> {
|
|
90
|
+
const tokens = tokenizeAttrTokens(input);
|
|
91
|
+
const params: Record<string, string> = {};
|
|
92
|
+
for (const token of skipFirstToken ? tokens.slice(1) : tokens) {
|
|
93
|
+
const kv = splitKeyValueToken(token);
|
|
94
|
+
if (kv) params[kv.key] = kv.value;
|
|
95
|
+
}
|
|
96
|
+
return params;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ============================================
|
|
100
|
+
// Heading line (Markdown / Monaco view)
|
|
101
|
+
// ============================================
|
|
102
|
+
|
|
103
|
+
interface SplitHeadingLine {
|
|
104
|
+
/** `## ` */
|
|
105
|
+
prefix: string;
|
|
106
|
+
/** Bare heading text (trailing whitespace stripped). */
|
|
107
|
+
text: string;
|
|
108
|
+
/** Inner of the trailing Pandoc `{…}` block, or null. */
|
|
109
|
+
pandocInner: string | null;
|
|
110
|
+
/** Verbatim trailing `{[…]}` template annotation (no leading space), or ''. */
|
|
111
|
+
templateText: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Split a heading line into its prefix, bare text, and trailing annotation
|
|
116
|
+
* blocks. Mirrors the peel-off order in `tiptapBridge` / core's parser:
|
|
117
|
+
* the `{[…]}` template annotation sits at the very end, the Pandoc `{…}`
|
|
118
|
+
* block just before it. Returns null when `line` is not a heading.
|
|
119
|
+
*/
|
|
120
|
+
function splitHeadingLine(line: string): SplitHeadingLine | null {
|
|
121
|
+
const m = line.match(HEADING_LINE_RE);
|
|
122
|
+
if (!m) return null;
|
|
123
|
+
let rest = m[2];
|
|
124
|
+
|
|
125
|
+
let templateText = '';
|
|
126
|
+
const tm = matchTrailingTemplateAnnotation(rest);
|
|
127
|
+
if (tm) {
|
|
128
|
+
templateText = rest.slice(tm.index).trim();
|
|
129
|
+
rest = rest.slice(0, tm.index);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let pandocInner: string | null = null;
|
|
133
|
+
const pm = matchTrailingPandocAttr(rest);
|
|
134
|
+
if (pm) {
|
|
135
|
+
pandocInner = pm.inner;
|
|
136
|
+
rest = rest.slice(0, pm.index);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { prefix: m[1], text: rest.replace(/\s+$/, ''), pandocInner, templateText };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Read the transition fields off a heading line. Looks in both the Pandoc
|
|
144
|
+
* `{…}` block (canonical) and the `{[…]}` template params (hand-typed),
|
|
145
|
+
* with the Pandoc block taking precedence. Returns the empty transition for
|
|
146
|
+
* non-heading lines.
|
|
147
|
+
*/
|
|
148
|
+
export function readHeadingLineTransition(line: string): TransitionFields {
|
|
149
|
+
const split = splitHeadingLine(line);
|
|
150
|
+
if (!split) return { ...EMPTY_TRANSITION };
|
|
151
|
+
const fromTemplate = split.templateText
|
|
152
|
+
? paramsFromTokenString(stripTemplateBraces(split.templateText), true)
|
|
153
|
+
: {};
|
|
154
|
+
const fromPandoc = split.pandocInner
|
|
155
|
+
? (parsePandocAttrTokens(split.pandocInner).params ?? {})
|
|
156
|
+
: {};
|
|
157
|
+
return fieldsFromParams({ ...fromTemplate, ...fromPandoc });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Return `line` with its transition rewritten from `next`, writing into the
|
|
162
|
+
* Pandoc `{…}` block and leaving the `{[…]}` template annotation untouched.
|
|
163
|
+
* Non-heading lines are returned unchanged.
|
|
164
|
+
*/
|
|
165
|
+
export function setHeadingLineTransition(line: string, next: TransitionFields): string {
|
|
166
|
+
const split = splitHeadingLine(line);
|
|
167
|
+
if (!split) return line;
|
|
168
|
+
const attrs: HeadingAttributes = split.pandocInner
|
|
169
|
+
? parsePandocAttrTokens(split.pandocInner)
|
|
170
|
+
: {};
|
|
171
|
+
attrs.params = applyFieldsToParams(attrs.params ?? {}, next);
|
|
172
|
+
const pandoc = pandocBlockOrNull(attrs);
|
|
173
|
+
|
|
174
|
+
let out = split.prefix + split.text;
|
|
175
|
+
if (pandoc) out += ` ${pandoc}`;
|
|
176
|
+
if (split.templateText) out += ` ${split.templateText}`;
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Strip the outer `{[` … `]}` from a verbatim template annotation. */
|
|
181
|
+
function stripTemplateBraces(templateText: string): string {
|
|
182
|
+
const m = templateText.match(/^\{\[([\s\S]*)\]\}[\s\]}]*$/);
|
|
183
|
+
return m ? m[1] : templateText;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ============================================
|
|
187
|
+
// data-block-attrs (WYSIWYG / Tiptap view)
|
|
188
|
+
// ============================================
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Read the transition fields from a heading node's `dataBlockAttrs` (Pandoc
|
|
192
|
+
* inner) plus `dataTemplateParams` (the `{[…]}` params). Pandoc wins.
|
|
193
|
+
*/
|
|
194
|
+
export function readBlockAttrsTransition(
|
|
195
|
+
blockAttrsInner: string | null | undefined,
|
|
196
|
+
templateParams: string | null | undefined,
|
|
197
|
+
): TransitionFields {
|
|
198
|
+
const fromTemplate = templateParams ? paramsFromTokenString(templateParams, false) : {};
|
|
199
|
+
const fromPandoc = blockAttrsInner ? (parsePandocAttrTokens(blockAttrsInner).params ?? {}) : {};
|
|
200
|
+
return fieldsFromParams({ ...fromTemplate, ...fromPandoc });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Rewrite the transition in a heading node's `dataBlockAttrs` inner string.
|
|
205
|
+
* Returns the new inner (no braces), or null when the block carries no
|
|
206
|
+
* attributes at all — matching how `tiptapBridge` stores `dataBlockAttrs`
|
|
207
|
+
* (absent attribute → null, not `{}`).
|
|
208
|
+
*/
|
|
209
|
+
export function setBlockAttrsTransition(
|
|
210
|
+
blockAttrsInner: string | null | undefined,
|
|
211
|
+
next: TransitionFields,
|
|
212
|
+
): string | null {
|
|
213
|
+
const attrs: HeadingAttributes = blockAttrsInner ? parsePandocAttrTokens(blockAttrsInner) : {};
|
|
214
|
+
attrs.params = applyFieldsToParams(attrs.params ?? {}, next);
|
|
215
|
+
const pandoc = pandocBlockOrNull(attrs);
|
|
216
|
+
return pandoc ? pandoc.slice(1, -1) : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ============================================
|
|
220
|
+
// shared
|
|
221
|
+
// ============================================
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Serialize `attrs` to a `{…}` block, or null when there's nothing to emit.
|
|
225
|
+
* `serializePandocAttributes` returns the literal `{}` marker for an empty
|
|
226
|
+
* attribute set; we treat that as "no block".
|
|
227
|
+
*/
|
|
228
|
+
function pandocBlockOrNull(attrs: HeadingAttributes): string | null {
|
|
229
|
+
const raw = serializePandocAttributes(attrs);
|
|
230
|
+
return raw == null || raw === '{}' ? null : raw;
|
|
231
|
+
}
|