@bendyline/squisq-editor-react 1.6.0 → 1.6.2

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 (68) hide show
  1. package/README.md +57 -10
  2. package/dist/index.d.ts +476 -105
  3. package/dist/index.js +8703 -6741
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
  6. package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
  7. package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
  8. package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
  9. package/dist/styles/index.css +14617 -0
  10. package/package.json +15 -7
  11. package/src/BlockPropertiesPopover.tsx +23 -7
  12. package/src/EditorContext.tsx +22 -16
  13. package/src/EditorShell.tsx +121 -35
  14. package/src/MediaBin.tsx +171 -28
  15. package/src/OutlinePanel.tsx +26 -4
  16. package/src/PreviewControls.tsx +475 -145
  17. package/src/PreviewPanel.tsx +17 -9
  18. package/src/RawEditor.tsx +10 -4
  19. package/src/TemplateAnnotation.ts +22 -7
  20. package/src/TemplateContentPreview.tsx +56 -0
  21. package/src/TemplatePicker.tsx +295 -128
  22. package/src/ThemeCustomizerPanel.tsx +22 -15
  23. package/src/Toolbar.tsx +547 -217
  24. package/src/TransitionPicker.tsx +8 -1
  25. package/src/VersionHistoryPanel.tsx +2 -2
  26. package/src/ViewSwitcher.tsx +4 -4
  27. package/src/WysiwygEditor.tsx +45 -3
  28. package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
  29. package/src/__tests__/codeContextSectionView.test.tsx +95 -0
  30. package/src/__tests__/codeContextZoneManager.test.ts +127 -0
  31. package/src/__tests__/diffContextSections.test.ts +39 -0
  32. package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
  33. package/src/__tests__/editorShellProps.test.tsx +363 -0
  34. package/src/__tests__/headingTransition.test.ts +59 -9
  35. package/src/__tests__/imageEditorShell.test.tsx +23 -0
  36. package/src/__tests__/mediaReferences.test.ts +82 -0
  37. package/src/__tests__/previewControls.test.tsx +163 -0
  38. package/src/__tests__/templateAnnotationRoundTrip.test.ts +23 -2
  39. package/src/__tests__/templateContentPreview.test.ts +101 -0
  40. package/src/__tests__/tiptapBridge.test.ts +47 -0
  41. package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
  42. package/src/__tests__/useMediaRecorder.test.ts +17 -0
  43. package/src/codeContext/CodeContextSectionView.tsx +124 -0
  44. package/src/codeContext/CodeContextZoneManager.ts +149 -0
  45. package/src/codeContext/CodeContextZones.tsx +121 -0
  46. package/src/codeContext/diffContextSections.ts +38 -0
  47. package/src/codeContext/types.ts +75 -0
  48. package/src/diagram/DiagramWidget.tsx +6 -4
  49. package/src/headingTransition.ts +96 -21
  50. package/src/index.ts +34 -2
  51. package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
  52. package/src/mediaReferences.ts +299 -0
  53. package/src/recorder/hooks/useMediaRecorder.ts +9 -10
  54. package/src/scene/Scene.tsx +53 -7
  55. package/src/scene/SceneBlockWidget.tsx +6 -3
  56. package/src/scene/SceneSelection.tsx +19 -15
  57. package/src/scene/SceneSideToolbar.tsx +89 -0
  58. package/src/scene/layers/DiagramEdges.tsx +4 -3
  59. package/src/scene/layers/edgeGeometry.ts +23 -4
  60. package/src/scene/scene.css +142 -2
  61. package/src/scene/tools/ConnectTool.ts +113 -24
  62. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  63. package/src/scene/tools/SceneTool.ts +2 -0
  64. package/src/styles/code-context.css +155 -0
  65. package/src/styles/editor.css +991 -84
  66. package/src/styles/index.css +1 -0
  67. package/src/templateContentPreviewResolver.ts +353 -0
  68. package/src/tiptapBridge.ts +60 -33
@@ -5,6 +5,7 @@
5
5
 
6
6
  @import '@fortawesome/fontawesome-free/css/all.min.css';
7
7
  @import './editor.css';
8
+ @import './code-context.css';
8
9
  @import './folder-view.css';
9
10
  @import '../jsonEditor/json-editor.css';
10
11
  @import '../imageEditor/image-editor.css';
@@ -0,0 +1,353 @@
1
+ import type {
2
+ Block,
3
+ DocBlock,
4
+ MediaProvider,
5
+ Theme,
6
+ ViewportConfig,
7
+ } from '@bendyline/squisq/schemas';
8
+ import {
9
+ deriveTemplateInputs,
10
+ extractBodyPlainText,
11
+ extractBlockquoteText,
12
+ extractImages,
13
+ extractListItems,
14
+ extractTableFromContents,
15
+ getLayers,
16
+ type RenderContext,
17
+ } from '@bendyline/squisq/doc';
18
+ import { extractPlainText } from '@bendyline/squisq/markdown';
19
+
20
+ export interface TemplatePreviewSource {
21
+ block: Block;
22
+ theme: Theme;
23
+ viewport: ViewportConfig;
24
+ basePath?: string;
25
+ mediaProvider?: MediaProvider | null;
26
+ }
27
+
28
+ export interface TemplatePreviewResult {
29
+ visual: Block | null;
30
+ warning?: string;
31
+ }
32
+
33
+ const NUMBER_RE =
34
+ /(?:[$\u20ac\u00a3\u00a5]\s?\d+(?:[.,]\d+)*(?:\s?(?:[MBK]|million|billion|thousand))?|\d+(?:[.,]\d+)*\s?(?:%|\u2030|x|\u00d7|[MBK]|million|billion|thousand|percent|years?|days?|hours?)|\d{3,}(?:[.,]\d+)*)/i;
35
+
36
+ const DATE_RE =
37
+ /\b(?:\d{4}-\d{2}-\d{2}|\d{1,2}\/\d{1,2}\/\d{2,4}|Q[1-4]\s+\d{4}|\d{4}s|(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}|\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\.?\s+\d{4})\b/i;
38
+
39
+ const AUDIO_VIDEO_SOURCE_RE =
40
+ /\.(?:aac|flac|m4a|m4v|mkv|mov|mp3|mp4|oga|ogg|ogv|opus|wav|webm)(?:$|[?#\s"'<>),])/i;
41
+ const AUDIO_VIDEO_TAG_RE = /<\/?(?:audio|source|video)\b/i;
42
+ const AUDIO_VIDEO_MIME_RE = /^(?:audio|video)\//i;
43
+
44
+ export function resolveTemplateContentPreview(
45
+ templateName: string,
46
+ source: TemplatePreviewSource,
47
+ ): Block | null {
48
+ return resolveTemplateContentPreviewResult(templateName, source).visual;
49
+ }
50
+
51
+ export function resolveTemplateContentPreviewResult(
52
+ templateName: string,
53
+ source: TemplatePreviewSource,
54
+ ): TemplatePreviewResult {
55
+ const { block, theme, viewport } = source;
56
+ const headingText = getHeadingText(block);
57
+ const bodyText = extractBodyPlainText(block.contents);
58
+ const sameTemplate = block.template === templateName;
59
+ const existingInputs =
60
+ sameTemplate &&
61
+ ((block.templateData && Object.keys(block.templateData).length > 0) ||
62
+ (block.templateOverrides && Object.keys(block.templateOverrides).length > 0));
63
+ const { inputs, warning } = buildTemplatePreviewInputs(
64
+ templateName,
65
+ block,
66
+ headingText,
67
+ bodyText,
68
+ );
69
+
70
+ if (!inputs && !existingInputs) return { visual: null, warning };
71
+
72
+ const candidate: Block = {
73
+ ...block,
74
+ id: `template-preview-${block.id}-${templateName}`,
75
+ template: templateName,
76
+ title: headingText || block.title || templateName,
77
+ duration: 1,
78
+ audioSegment: 0,
79
+ layers: undefined,
80
+ templateData: sameTemplate ? block.templateData : undefined,
81
+ templateOverrides: sameTemplate ? block.templateOverrides : undefined,
82
+ ...(inputs ?? {}),
83
+ };
84
+
85
+ const ctx: RenderContext = {
86
+ blockIndex: 0,
87
+ totalBlocks: 1,
88
+ theme,
89
+ viewport,
90
+ };
91
+
92
+ try {
93
+ const layers = getLayers(candidate as unknown as DocBlock, ctx);
94
+ if (layers.length === 0) return { visual: null, warning };
95
+ return { visual: { ...candidate, layers } };
96
+ } catch {
97
+ return { visual: null, warning };
98
+ }
99
+ }
100
+
101
+ function buildTemplatePreviewInputs(
102
+ templateName: string,
103
+ block: Block,
104
+ headingText: string,
105
+ bodyText: string,
106
+ ): { inputs: Record<string, unknown> | null; warning?: string } {
107
+ const contents = block.contents;
108
+ const text = [headingText, bodyText].filter(Boolean).join('\n');
109
+
110
+ switch (templateName) {
111
+ case 'title':
112
+ return {
113
+ inputs: text
114
+ ? {
115
+ title: headingText || firstLine(bodyText) || 'Untitled',
116
+ ...(headingText && bodyText ? { subtitle: bodyText } : {}),
117
+ }
118
+ : null,
119
+ };
120
+ case 'sectionHeader': {
121
+ if (!text) return { inputs: null };
122
+ const image = extractImages(contents, 1)[0];
123
+ return {
124
+ inputs: {
125
+ title: headingText || firstLine(bodyText) || 'Untitled',
126
+ ...(image
127
+ ? {
128
+ imageSrc: image.src,
129
+ imageAlt: image.alt || headingText,
130
+ }
131
+ : {}),
132
+ },
133
+ };
134
+ }
135
+ case 'statHighlight':
136
+ if (!NUMBER_RE.test(text)) {
137
+ return { inputs: null, warning: 'No stat found in this block' };
138
+ }
139
+ return {
140
+ inputs: deriveTemplateInputs(templateName, headingText, contents, { placeholders: false }),
141
+ };
142
+ case 'quote':
143
+ case 'fullBleedQuote': {
144
+ const quoteText = extractBlockquoteText(contents) || bodyText;
145
+ return {
146
+ inputs: quoteText
147
+ ? deriveTemplateInputs(templateName, headingText, contents, { placeholders: false })
148
+ : null,
149
+ };
150
+ }
151
+ case 'pullQuote':
152
+ return {
153
+ inputs: derivePullQuoteInputs(block, headingText, bodyText),
154
+ ...(extractImages(contents, 1).length === 0
155
+ ? { warning: 'No image found in this block' }
156
+ : {}),
157
+ };
158
+ case 'factCard':
159
+ case 'definitionCard':
160
+ return {
161
+ inputs:
162
+ headingText && bodyText
163
+ ? deriveTemplateInputs(templateName, headingText, contents, { placeholders: false })
164
+ : null,
165
+ };
166
+ case 'twoColumn':
167
+ return { inputs: deriveTwoColumnInputs(block, headingText, bodyText) };
168
+ case 'dateEvent':
169
+ if (!DATE_RE.test(text)) {
170
+ return { inputs: null, warning: 'No date found in this block' };
171
+ }
172
+ return {
173
+ inputs: deriveTemplateInputs(templateName, headingText, contents, { placeholders: false }),
174
+ };
175
+ case 'comparisonBar': {
176
+ const comparisonInputs = deriveComparisonInputs(block);
177
+ return {
178
+ inputs: comparisonInputs,
179
+ ...(comparisonInputs ? {} : { warning: 'No stat found in this block' }),
180
+ };
181
+ }
182
+ case 'imageWithCaption':
183
+ case 'leftFeature':
184
+ case 'rightFeature':
185
+ case 'photoGrid': {
186
+ const imageCount = extractImages(contents, templateName === 'photoGrid' ? 2 : 1).length;
187
+ const needsMoreImages = templateName === 'photoGrid' ? imageCount < 2 : imageCount === 0;
188
+ return {
189
+ inputs: needsMoreImages
190
+ ? null
191
+ : deriveTemplateInputs(templateName, headingText, contents, { placeholders: false }),
192
+ ...(needsMoreImages ? { warning: 'No image found in this block' } : {}),
193
+ };
194
+ }
195
+ case 'map':
196
+ return { inputs: null };
197
+ case 'videoWithCaption':
198
+ case 'videoPullQuote':
199
+ return {
200
+ inputs: null,
201
+ ...(hasAudioVideoMedia(contents) ? {} : { warning: 'No audio/video found in this block' }),
202
+ };
203
+ case 'diagram':
204
+ case 'layout':
205
+ case 'drawing':
206
+ return {
207
+ inputs: block.children && block.children.length > 0 ? { title: headingText } : null,
208
+ };
209
+ default:
210
+ return {
211
+ inputs: deriveTemplateInputs(templateName, headingText, contents, { placeholders: false }),
212
+ };
213
+ }
214
+ }
215
+
216
+ function derivePullQuoteInputs(
217
+ block: Block,
218
+ headingText: string,
219
+ bodyText: string,
220
+ ): Record<string, unknown> | null {
221
+ const image = extractImages(block.contents, 1)[0];
222
+ const text = extractBlockquoteText(block.contents) || bodyText || headingText;
223
+ if (!image || !text) return null;
224
+ return {
225
+ text,
226
+ backgroundImage: {
227
+ src: image.src,
228
+ alt: image.alt || headingText,
229
+ },
230
+ };
231
+ }
232
+
233
+ function deriveTwoColumnInputs(
234
+ block: Block,
235
+ headingText: string,
236
+ bodyText: string,
237
+ ): Record<string, unknown> | null {
238
+ const items = extractListItems(block.contents);
239
+ if (items.length >= 2) {
240
+ return {
241
+ ...(headingText ? { header: headingText } : {}),
242
+ left: { label: trimPreviewText(items[0]) },
243
+ right: { label: trimPreviewText(items[1]) },
244
+ };
245
+ }
246
+
247
+ const chunks = bodyText
248
+ .split(/\n{2,}|\n|(?<=[.!?])\s+/)
249
+ .map((part) => part.trim())
250
+ .filter(Boolean);
251
+ if (headingText && chunks.length >= 1) {
252
+ return {
253
+ left: { label: trimPreviewText(headingText) },
254
+ right: { label: trimPreviewText(chunks[0]) },
255
+ };
256
+ }
257
+ if (chunks.length >= 2) {
258
+ return {
259
+ left: { label: trimPreviewText(chunks[0]) },
260
+ right: { label: trimPreviewText(chunks[1]) },
261
+ };
262
+ }
263
+ return null;
264
+ }
265
+
266
+ function deriveComparisonInputs(block: Block): Record<string, unknown> | null {
267
+ const table = extractTableFromContents(block.contents);
268
+ if (table) {
269
+ const values = table.rows
270
+ .map((row) => ({
271
+ label: row[0] || 'Value',
272
+ value: parseNumericValue(row.slice(1).join(' ')),
273
+ }))
274
+ .filter((row): row is { label: string; value: number } => row.value !== null);
275
+ if (values.length >= 2) {
276
+ return {
277
+ leftLabel: trimPreviewText(values[0].label, 24),
278
+ leftValue: values[0].value,
279
+ rightLabel: trimPreviewText(values[1].label, 24),
280
+ rightValue: values[1].value,
281
+ };
282
+ }
283
+ }
284
+
285
+ const bodyText = extractBodyPlainText(block.contents);
286
+ const matches = Array.from(bodyText.matchAll(new RegExp(NUMBER_RE.source, 'gi')))
287
+ .map((match) => parseNumericValue(match[0]))
288
+ .filter((value): value is number => value !== null);
289
+ if (matches.length < 2) return null;
290
+
291
+ return {
292
+ leftLabel: 'A',
293
+ leftValue: matches[0],
294
+ rightLabel: 'B',
295
+ rightValue: matches[1],
296
+ };
297
+ }
298
+
299
+ function parseNumericValue(raw: string): number | null {
300
+ const normalized = raw
301
+ .replace(/[$\u20ac\u00a3\u00a5,%\u2030]/g, '')
302
+ .replace(/,/g, '')
303
+ .trim();
304
+ const match = normalized.match(/-?\d+(?:\.\d+)?/);
305
+ if (!match) return null;
306
+ const value = Number.parseFloat(match[0]);
307
+ return Number.isFinite(value) ? value : null;
308
+ }
309
+
310
+ function hasAudioVideoMedia(value: unknown): boolean {
311
+ if (typeof value === 'string') {
312
+ return (
313
+ AUDIO_VIDEO_TAG_RE.test(value) ||
314
+ AUDIO_VIDEO_SOURCE_RE.test(value) ||
315
+ AUDIO_VIDEO_MIME_RE.test(value)
316
+ );
317
+ }
318
+ if (Array.isArray(value)) return value.some(hasAudioVideoMedia);
319
+ if (!value || typeof value !== 'object') return false;
320
+
321
+ const record = value as Record<string, unknown>;
322
+ const tagName = typeof record.tagName === 'string' ? record.tagName.toLowerCase() : '';
323
+ if (tagName === 'audio' || tagName === 'video') return true;
324
+ if (record.type === 'audio' || record.type === 'video') return true;
325
+
326
+ const attributes = record.attributes;
327
+ if (attributes && typeof attributes === 'object') {
328
+ const attrs = attributes as Record<string, unknown>;
329
+ if (hasAudioVideoMedia(attrs.src) || hasAudioVideoMedia(attrs.type)) return true;
330
+ }
331
+
332
+ return Object.values(record).some(hasAudioVideoMedia);
333
+ }
334
+
335
+ function getHeadingText(block: Block): string {
336
+ if (block.sourceHeading) return extractPlainText(block.sourceHeading).trim();
337
+ return (block.title ?? block.id ?? '').trim();
338
+ }
339
+
340
+ function firstLine(value: string): string {
341
+ return (
342
+ value
343
+ .split(/\r?\n/)
344
+ .map((line) => line.trim())
345
+ .find(Boolean) ?? ''
346
+ );
347
+ }
348
+
349
+ function trimPreviewText(value: string, max = 72): string {
350
+ const normalized = value.replace(/\s+/g, ' ').trim();
351
+ if (normalized.length <= max) return normalized;
352
+ return `${normalized.slice(0, max - 1).trimEnd()}...`;
353
+ }
@@ -85,12 +85,28 @@ export function markdownToTiptap(markdown: string): string {
85
85
  let listType: 'ul' | 'ol' | 'task' = 'ul';
86
86
  let inTable = false;
87
87
  let tableLines: string[] = [];
88
+ let pendingBlankLines = 0;
89
+
90
+ const flushPendingBlankParagraphs = () => {
91
+ if (pendingBlankLines === 0) return;
92
+ const emptyParagraphs =
93
+ outputBlocks.length === 0 ? pendingBlankLines : Math.max(0, pendingBlankLines - 1);
94
+ for (let i = 0; i < emptyParagraphs; i++) {
95
+ outputBlocks.push('<p></p>');
96
+ }
97
+ pendingBlankLines = 0;
98
+ };
99
+
100
+ const pushBlock = (block: string) => {
101
+ flushPendingBlankParagraphs();
102
+ outputBlocks.push(block);
103
+ };
88
104
 
89
105
  const flushList = () => {
90
106
  if (inList && listItems.length > 0) {
91
107
  const tag = listType === 'ol' ? 'ol' : 'ul';
92
108
  const attr = listType === 'task' ? ' data-type="taskList"' : '';
93
- outputBlocks.push(`<${tag}${attr}>${listItems.join('')}</${tag}>`);
109
+ pushBlock(`<${tag}${attr}>${listItems.join('')}</${tag}>`);
94
110
  listItems = [];
95
111
  inList = false;
96
112
  }
@@ -111,7 +127,7 @@ export function markdownToTiptap(markdown: string): string {
111
127
  if (tableLines.length < 2 || !isSeparator) {
112
128
  // Not a valid table — render accumulated lines as paragraphs
113
129
  for (const tl of tableLines) {
114
- outputBlocks.push(`<p>${inlineToHtml(tl)}</p>`);
130
+ pushBlock(`<p>${inlineToHtml(tl)}</p>`);
115
131
  }
116
132
  inTable = false;
117
133
  tableLines = [];
@@ -146,7 +162,7 @@ export function markdownToTiptap(markdown: string): string {
146
162
  })
147
163
  .join('');
148
164
 
149
- outputBlocks.push(`<table><thead><tr>${thHtml}</tr></thead><tbody>${bodyHtml}</tbody></table>`);
165
+ pushBlock(`<table><thead><tr>${thHtml}</tr></thead><tbody>${bodyHtml}</tbody></table>`);
150
166
 
151
167
  inTable = false;
152
168
  tableLines = [];
@@ -159,15 +175,14 @@ export function markdownToTiptap(markdown: string): string {
159
175
  if (line.startsWith('```')) {
160
176
  if (!inCodeBlock) {
161
177
  flushList();
178
+ flushPendingBlankParagraphs();
162
179
  inCodeBlock = true;
163
180
  codeBlockLang = line.slice(3).trim();
164
181
  codeBlockLines = [];
165
182
  continue;
166
183
  } else {
167
184
  const langAttr = codeBlockLang ? ` class="language-${escapeHtml(codeBlockLang)}"` : '';
168
- outputBlocks.push(
169
- `<pre><code${langAttr}>${escapeHtml(codeBlockLines.join('\n'))}</code></pre>`,
170
- );
185
+ pushBlock(`<pre><code${langAttr}>${escapeHtml(codeBlockLines.join('\n'))}</code></pre>`);
171
186
  inCodeBlock = false;
172
187
  codeBlockLang = '';
173
188
  codeBlockLines = [];
@@ -188,9 +203,12 @@ export function markdownToTiptap(markdown: string): string {
188
203
  // Blank line flushes list
189
204
  if (line.trim() === '') {
190
205
  flushList();
206
+ pendingBlankLines++;
191
207
  continue;
192
208
  }
193
209
 
210
+ flushPendingBlankParagraphs();
211
+
194
212
  // Headings
195
213
  const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
196
214
  if (headingMatch) {
@@ -233,8 +251,11 @@ export function markdownToTiptap(markdown: string): string {
233
251
  // tokens are stored raw — quotes included — so tiptapToMarkdown
234
252
  // can re-join them into the annotation verbatim.
235
253
  const tokens = tokenizeAttrTokens(templateInner);
236
- attrs += ` data-template="${escapeHtml(tokens[0] ?? '')}"`;
237
- const params = tokens.slice(1).filter((t) => t.includes('='));
254
+ const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
255
+ if (!firstIsParam && tokens[0]) {
256
+ attrs += ` data-template="${escapeHtml(tokens[0])}"`;
257
+ }
258
+ const params = tokens.slice(firstIsParam ? 0 : 1).filter((t) => t.includes('='));
238
259
  if (params.length > 0) {
239
260
  attrs += ` data-template-params="${escapeHtml(params.join(' '))}"`;
240
261
  }
@@ -243,21 +264,21 @@ export function markdownToTiptap(markdown: string): string {
243
264
  attrs += ` data-block-attrs="${escapeHtml(pandocInner)}"`;
244
265
  }
245
266
 
246
- outputBlocks.push(`<h${level}${attrs}>${inlineToHtml(text)}</h${level}>`);
267
+ pushBlock(`<h${level}${attrs}>${inlineToHtml(text)}</h${level}>`);
247
268
  continue;
248
269
  }
249
270
 
250
271
  // Thematic break
251
272
  if (/^(---|\*\*\*|___)(\s*)$/.test(line.trim())) {
252
273
  flushList();
253
- outputBlocks.push('<hr>');
274
+ pushBlock('<hr>');
254
275
  continue;
255
276
  }
256
277
 
257
278
  // Blockquote
258
279
  if (line.startsWith('> ')) {
259
280
  flushList();
260
- outputBlocks.push(`<blockquote><p>${inlineToHtml(line.slice(2))}</p></blockquote>`);
281
+ pushBlock(`<blockquote><p>${inlineToHtml(line.slice(2))}</p></blockquote>`);
261
282
  continue;
262
283
  }
263
284
 
@@ -325,7 +346,7 @@ export function markdownToTiptap(markdown: string): string {
325
346
  flushList();
326
347
  const alt = escapeHtml(standaloneImageMatch[1] ?? '');
327
348
  const src = escapeHtml(standaloneImageMatch[2] ?? '');
328
- outputBlocks.push(`<img alt="${alt}" src="${src}">`);
349
+ pushBlock(`<img alt="${alt}" src="${src}">`);
329
350
  continue;
330
351
  }
331
352
 
@@ -337,7 +358,7 @@ export function markdownToTiptap(markdown: string): string {
337
358
  const trimmed = line.trim();
338
359
  if (/^<img\b[^>]*>$/i.test(trimmed)) {
339
360
  flushList();
340
- outputBlocks.push(trimmed);
361
+ pushBlock(trimmed);
341
362
  continue;
342
363
  }
343
364
 
@@ -348,24 +369,23 @@ export function markdownToTiptap(markdown: string): string {
348
369
  // picks up the tag attributes (`src`, `controls`, …).
349
370
  if (/^<(?:video|audio)\b[^>]*>(?:[\s\S]*?<\/(?:video|audio)>)?$/i.test(trimmed)) {
350
371
  flushList();
351
- outputBlocks.push(trimmed);
372
+ pushBlock(trimmed);
352
373
  continue;
353
374
  }
354
375
 
355
376
  // Regular paragraph
356
377
  flushList();
357
- outputBlocks.push(`<p>${inlineToHtml(line)}</p>`);
378
+ pushBlock(`<p>${inlineToHtml(line)}</p>`);
358
379
  }
359
380
 
360
381
  // Close any remaining open blocks
361
382
  if (inCodeBlock) {
362
383
  const langAttr = codeBlockLang ? ` class="language-${escapeHtml(codeBlockLang)}"` : '';
363
- outputBlocks.push(
364
- `<pre><code${langAttr}>${escapeHtml(codeBlockLines.join('\n'))}</code></pre>`,
365
- );
384
+ pushBlock(`<pre><code${langAttr}>${escapeHtml(codeBlockLines.join('\n'))}</code></pre>`);
366
385
  }
367
386
  flushList();
368
387
  flushTable();
388
+ flushPendingBlankParagraphs();
369
389
 
370
390
  return outputBlocks.join('') || '<p></p>';
371
391
  }
@@ -392,10 +412,11 @@ export function tiptapToMarkdown(html: string): string {
392
412
  let text = htmlToInline(headingMatch[3]);
393
413
 
394
414
  // Re-inject heading annotations from data attributes. Canonical
395
- // emit order: Pandoc `{#…}` first, then squisq `{[…]}` template
396
- // annotation (matches blockToMdast in core/markdown/convert.ts).
415
+ // emit order: Pandoc `{#…}` first, then squisq `{[…]}` annotation
416
+ // (matches blockToMdast in core/markdown/convert.ts).
397
417
  const blockAttrsMatch = attrs.match(/data-block-attrs="([^"]*)"/);
398
418
  const tmplMatch = attrs.match(/data-template="([^"]+)"/);
419
+ const paramsMatch = attrs.match(/data-template-params="([^"]+)"/);
399
420
  if (tmplMatch) {
400
421
  // Strip an accidental trailing copy of the template label that an
401
422
  // earlier broken build briefly rendered as real text inside the
@@ -409,11 +430,10 @@ export function tiptapToMarkdown(html: string): string {
409
430
  const inner = unescapeHtml(blockAttrsMatch[1]);
410
431
  text += ` {${inner}}`;
411
432
  }
412
- if (tmplMatch) {
413
- let annotation = tmplMatch[1];
414
- const paramsMatch = attrs.match(/data-template-params="([^"]+)"/);
433
+ if (tmplMatch || paramsMatch) {
434
+ let annotation = tmplMatch ? tmplMatch[1] : '';
415
435
  if (paramsMatch) {
416
- annotation += ' ' + unescapeHtml(paramsMatch[1]);
436
+ annotation += (annotation ? ' ' : '') + unescapeHtml(paramsMatch[1]);
417
437
  }
418
438
  text += ` {[${annotation}]}`;
419
439
  }
@@ -575,6 +595,8 @@ export function tiptapToMarkdown(html: string): string {
575
595
  if (text.trim()) {
576
596
  lines.push(text);
577
597
  lines.push('');
598
+ } else {
599
+ lines.push('');
578
600
  }
579
601
  remaining = remaining.slice(pMatch[0].length);
580
602
  continue;
@@ -633,13 +655,7 @@ export function tiptapToMarkdown(html: string): string {
633
655
  remaining = remaining.slice(1);
634
656
  }
635
657
 
636
- // Clean up trailing blank lines
637
- return (
638
- lines
639
- .join('\n')
640
- .replace(/\n{3,}/g, '\n\n')
641
- .trim() + '\n'
642
- );
658
+ return lines.join('\n');
643
659
  }
644
660
 
645
661
  /**
@@ -793,6 +809,7 @@ function unescapeHtml(text: string): string {
793
809
  .replace(/&lt;/g, '<')
794
810
  .replace(/&gt;/g, '>')
795
811
  .replace(/&quot;/g, '"')
812
+ .replace(/&nbsp;|&#160;|&#xA0;/gi, '\u00a0')
796
813
  .replace(/&amp;/g, '&');
797
814
  }
798
815
 
@@ -869,7 +886,7 @@ function inlineToHtml(text: string): string {
869
886
  // eslint-disable-next-line no-control-regex
870
887
  result = result.replace(/\u0000PH(\d+)\u0000/g, (_m, idx) => placeholders[Number(idx)] ?? '');
871
888
 
872
- return result;
889
+ return preserveLeadingSpaces(result);
873
890
  }
874
891
 
875
892
  /** Convert inline HTML back to markdown */
@@ -929,5 +946,15 @@ function htmlToInline(html: string): string {
929
946
  // Strip remaining tags
930
947
  result = result.replace(RE_STRIP_TAGS, '');
931
948
 
932
- return unescapeHtml(result);
949
+ return restoreLeadingSpaces(unescapeHtml(result));
950
+ }
951
+
952
+ function preserveLeadingSpaces(html: string): string {
953
+ return html.replace(/^ +/, (spaces) => '&nbsp;'.repeat(spaces.length));
954
+ }
955
+
956
+ function restoreLeadingSpaces(text: string): string {
957
+ return text.replace(/(^|\n)(\u00a0+)/g, (_match, prefix: string, spaces: string) => {
958
+ return prefix + ' '.repeat(spaces.length);
959
+ });
933
960
  }