@bendyline/squisq-editor-react 1.5.3 → 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.
Files changed (165) hide show
  1. package/dist/index.d.ts +757 -20
  2. package/dist/index.js +16875 -6883
  3. package/dist/index.js.map +1 -1
  4. package/package.json +4 -4
  5. package/src/BlockCardView.tsx +121 -0
  6. package/src/BlockPreviewPanel.tsx +69 -0
  7. package/src/BlockPropertiesPopover.tsx +191 -0
  8. package/src/EditorContext.tsx +143 -0
  9. package/src/EditorShell.tsx +200 -120
  10. package/src/FolderView.tsx +131 -0
  11. package/src/Icon.tsx +26 -0
  12. package/src/ImageEditor.tsx +69 -22
  13. package/src/OutlinePanel.tsx +38 -3
  14. package/src/PlainHtmlPreview.tsx +30 -3
  15. package/src/PreviewControls.tsx +180 -8
  16. package/src/RawEditor.tsx +169 -13
  17. package/src/RecorderEntry.tsx +9 -16
  18. package/src/TemplateAnnotation.ts +44 -0
  19. package/src/TemplatePicker.tsx +329 -54
  20. package/src/ThemeCustomizerPanel.tsx +30 -336
  21. package/src/ThemePicker.tsx +112 -3
  22. package/src/TimelineBlockPreview.tsx +37 -0
  23. package/src/TimelineTrack.tsx +671 -0
  24. package/src/Toolbar.tsx +528 -174
  25. package/src/Tooltip.tsx +22 -4
  26. package/src/TransitionPicker.tsx +351 -0
  27. package/src/VersionHistoryPanel.tsx +2 -14
  28. package/src/ViewMenuPanel.tsx +17 -14
  29. package/src/WysiwygEditor.tsx +161 -65
  30. package/src/__tests__/blockProperties.test.ts +92 -0
  31. package/src/__tests__/blockRange.test.ts +105 -0
  32. package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
  33. package/src/__tests__/createShapeLayer.test.ts +46 -0
  34. package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
  35. package/src/__tests__/embeddedMedia.test.ts +48 -0
  36. package/src/__tests__/headingTransition.test.ts +138 -0
  37. package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
  38. package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
  39. package/src/__tests__/recorderMediaInsert.test.ts +86 -0
  40. package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
  41. package/src/__tests__/templatePickerMetadata.test.ts +32 -0
  42. package/src/__tests__/timelineSource.test.ts +134 -0
  43. package/src/__tests__/tiptapBridge.test.ts +92 -0
  44. package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
  45. package/src/__tests__/tooltip.test.tsx +72 -0
  46. package/src/__tests__/transitionCatalog.test.ts +64 -0
  47. package/src/__tests__/useBlockNavigator.test.tsx +67 -0
  48. package/src/__tests__/useMediaRecorder.test.ts +24 -0
  49. package/src/__tests__/useTimelineClock.test.ts +21 -0
  50. package/src/blockProperties.ts +88 -0
  51. package/src/blockRange.ts +132 -0
  52. package/src/buildPreviewDoc.ts +98 -9
  53. package/src/customTemplates/AddBin.tsx +126 -0
  54. package/src/customTemplates/CustomLayoutManager.tsx +233 -0
  55. package/src/customTemplates/CustomTemplateContext.tsx +182 -0
  56. package/src/customTemplates/LayerToolbar.tsx +580 -0
  57. package/src/customTemplates/ShapeGlyph.tsx +47 -0
  58. package/src/customTemplates/TemplateDesigner.tsx +430 -0
  59. package/src/customTemplates/__tests__/library.test.ts +88 -0
  60. package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
  61. package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
  62. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
  63. package/src/customTemplates/designer.css +673 -0
  64. package/src/customTemplates/index.ts +31 -0
  65. package/src/customTemplates/library.ts +97 -0
  66. package/src/customTemplates/normalizePositions.ts +75 -0
  67. package/src/customTemplates/shapeDefs.ts +131 -0
  68. package/src/customTemplates/thumbnail.tsx +63 -0
  69. package/src/customTemplates/tokenDefs.ts +60 -0
  70. package/src/customTemplates/useDocCustomTemplates.ts +52 -0
  71. package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
  72. package/src/customThemes/CustomThemeContext.tsx +179 -0
  73. package/src/customThemes/CustomThemeDialog.tsx +286 -0
  74. package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
  75. package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
  76. package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
  77. package/src/customThemes/customThemeLibrary.ts +97 -0
  78. package/src/customThemes/index.ts +31 -0
  79. package/src/customThemes/themeControls.tsx +229 -0
  80. package/src/customThemes/themeDraft.ts +272 -0
  81. package/src/customThemes/useDocCustomThemes.ts +49 -0
  82. package/src/diagram/DiagramCanvas.tsx +240 -0
  83. package/src/diagram/DiagramExtension.ts +209 -0
  84. package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
  85. package/src/diagram/DiagramWidget.tsx +270 -0
  86. package/src/diagram/diagramCommands.ts +604 -0
  87. package/src/diagram/diagramConstants.ts +17 -0
  88. package/src/diagram/useDiagramData.ts +126 -0
  89. package/src/embeddedMedia.ts +78 -0
  90. package/src/frontmatter.ts +29 -0
  91. package/src/headingTransition.ts +231 -0
  92. package/src/imageEditor/CanvasSurface.tsx +383 -88
  93. package/src/imageEditor/PropertiesPanel.tsx +47 -1
  94. package/src/imageEditor/Toolbar.tsx +229 -16
  95. package/src/imageEditor/createShapeLayer.ts +280 -0
  96. package/src/imageEditor/icons.tsx +34 -114
  97. package/src/imageEditor/image-editor.css +54 -5
  98. package/src/imageEditor/state.ts +23 -3
  99. package/src/index.ts +77 -0
  100. package/src/recorder/RecorderModal.tsx +120 -53
  101. package/src/recorder/RecorderPanel.tsx +2 -26
  102. package/src/recorder/hooks/useMediaRecorder.ts +8 -1
  103. package/src/recorder/insertMediaBlock.ts +30 -0
  104. package/src/resolveBlockVisual.ts +33 -0
  105. package/src/scene/Scene.tsx +540 -0
  106. package/src/scene/SceneBlockExtension.ts +198 -0
  107. package/src/scene/SceneBlockToolbar.tsx +201 -0
  108. package/src/scene/SceneBlockWidget.tsx +434 -0
  109. package/src/scene/ScenePropsBar.tsx +85 -0
  110. package/src/scene/SceneSelection.tsx +107 -0
  111. package/src/scene/SceneViewport.tsx +102 -0
  112. package/src/scene/ShapePalette.tsx +181 -0
  113. package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
  114. package/src/scene/__tests__/bezierEdit.test.ts +85 -0
  115. package/src/scene/__tests__/blockLayers.test.ts +57 -0
  116. package/src/scene/__tests__/shapeLayers.test.ts +106 -0
  117. package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
  118. package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
  119. package/src/scene/adapters/DiagramAdapter.ts +168 -0
  120. package/src/scene/adapters/DrawingAdapter.ts +415 -0
  121. package/src/scene/adapters/LayoutAdapter.ts +310 -0
  122. package/src/scene/adapters/blockLayers.ts +159 -0
  123. package/src/scene/commands/SceneCommand.ts +70 -0
  124. package/src/scene/commands/drawingCommands.ts +318 -0
  125. package/src/scene/commands/layoutCommands.ts +301 -0
  126. package/src/scene/hooks/useSceneHitTest.ts +105 -0
  127. package/src/scene/hooks/useScenePanZoom.ts +147 -0
  128. package/src/scene/hooks/useSceneSelection.ts +62 -0
  129. package/src/scene/index.ts +95 -0
  130. package/src/scene/layers/DiagramEdges.tsx +127 -0
  131. package/src/scene/layers/edgeGeometry.ts +77 -0
  132. package/src/scene/layers/nodeCard.tsx +145 -0
  133. package/src/scene/layers/renderLayer.tsx +70 -0
  134. package/src/scene/layers/shapeLayers.ts +201 -0
  135. package/src/scene/paths/bezierEdit.ts +208 -0
  136. package/src/scene/scene.css +649 -0
  137. package/src/scene/text/SceneTextOverlay.tsx +161 -0
  138. package/src/scene/text/sceneTextChannel.ts +40 -0
  139. package/src/scene/text/sceneTextConfig.ts +27 -0
  140. package/src/scene/text/sceneTiptap.ts +36 -0
  141. package/src/scene/text/useSceneTextEditing.ts +39 -0
  142. package/src/scene/tools/ConnectTool.ts +111 -0
  143. package/src/scene/tools/DrawingConnectTool.ts +161 -0
  144. package/src/scene/tools/PathTool.ts +158 -0
  145. package/src/scene/tools/PlaceTool.ts +47 -0
  146. package/src/scene/tools/SceneTool.ts +75 -0
  147. package/src/scene/tools/SelectTool.ts +284 -0
  148. package/src/scene/tools/ShapeTool.ts +144 -0
  149. package/src/scene/tools/TextTool.ts +72 -0
  150. package/src/scene/tools/TokenTool.ts +95 -0
  151. package/src/scene/tools/createDrawShapeTool.ts +82 -0
  152. package/src/styles/diagram.css +183 -0
  153. package/src/styles/editor.css +1615 -203
  154. package/src/styles/folder-view.css +210 -0
  155. package/src/styles/image-edit-affordance.css +2 -2
  156. package/src/styles/index.css +4 -0
  157. package/src/timelineSource.ts +244 -0
  158. package/src/tiptapBridge.ts +115 -34
  159. package/src/tooltipPlacement.ts +13 -0
  160. package/src/transitionCatalog.ts +159 -0
  161. package/src/types/monaco-shims.d.ts +10 -0
  162. package/src/useBlockNavigator.ts +153 -0
  163. package/src/useMonacoLoader.ts +23 -1
  164. package/src/useTimelineClock.ts +76 -0
  165. 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
+ }