@bendyline/squisq-editor-react 1.6.1 → 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 (44) hide show
  1. package/dist/index.d.ts +87 -41
  2. package/dist/index.js +8263 -6705
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles/index.css +841 -91
  5. package/package.json +4 -4
  6. package/src/BlockPropertiesPopover.tsx +23 -7
  7. package/src/EditorShell.tsx +65 -20
  8. package/src/MediaBin.tsx +171 -28
  9. package/src/PreviewControls.tsx +177 -44
  10. package/src/PreviewPanel.tsx +2 -0
  11. package/src/TemplateAnnotation.ts +22 -7
  12. package/src/TemplateContentPreview.tsx +56 -0
  13. package/src/TemplatePicker.tsx +295 -128
  14. package/src/ThemeCustomizerPanel.tsx +22 -15
  15. package/src/Toolbar.tsx +527 -207
  16. package/src/TransitionPicker.tsx +8 -1
  17. package/src/ViewSwitcher.tsx +4 -4
  18. package/src/WysiwygEditor.tsx +45 -3
  19. package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
  20. package/src/__tests__/editorShellProps.test.tsx +268 -1
  21. package/src/__tests__/headingTransition.test.ts +59 -9
  22. package/src/__tests__/imageEditorShell.test.tsx +23 -0
  23. package/src/__tests__/mediaReferences.test.ts +82 -0
  24. package/src/__tests__/previewControls.test.tsx +94 -1
  25. package/src/__tests__/templateAnnotationRoundTrip.test.ts +23 -2
  26. package/src/__tests__/templateContentPreview.test.ts +101 -0
  27. package/src/__tests__/tiptapBridge.test.ts +47 -0
  28. package/src/diagram/DiagramWidget.tsx +6 -4
  29. package/src/headingTransition.ts +96 -21
  30. package/src/index.ts +2 -1
  31. package/src/mediaReferences.ts +299 -0
  32. package/src/scene/Scene.tsx +53 -7
  33. package/src/scene/SceneBlockWidget.tsx +6 -3
  34. package/src/scene/SceneSelection.tsx +19 -15
  35. package/src/scene/SceneSideToolbar.tsx +89 -0
  36. package/src/scene/layers/DiagramEdges.tsx +4 -3
  37. package/src/scene/layers/edgeGeometry.ts +23 -4
  38. package/src/scene/scene.css +142 -2
  39. package/src/scene/tools/ConnectTool.ts +113 -24
  40. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  41. package/src/scene/tools/SceneTool.ts +2 -0
  42. package/src/styles/editor.css +862 -101
  43. package/src/templateContentPreviewResolver.ts +353 -0
  44. package/src/tiptapBridge.ts +60 -33
@@ -4,7 +4,12 @@
4
4
  import { afterEach, describe, expect, it } from 'vitest';
5
5
  import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
6
6
  import { EditorProvider, useEditorContext } from '../EditorContext';
7
- import { PreviewModeSwitch, PreviewSettingsProvider, usePreviewSettings } from '../PreviewControls';
7
+ import {
8
+ PreviewModeSwitch,
9
+ PreviewSettingsProvider,
10
+ PreviewToolbarControls,
11
+ usePreviewSettings,
12
+ } from '../PreviewControls';
8
13
 
9
14
  function ModeProbe() {
10
15
  const { activeDisplayMode } = usePreviewSettings();
@@ -21,6 +26,15 @@ function PreviewHarness() {
21
26
  );
22
27
  }
23
28
 
29
+ function PreviewToolbarHarness() {
30
+ const { doc } = useEditorContext();
31
+ return (
32
+ <PreviewSettingsProvider doc={doc}>
33
+ <PreviewToolbarControls />
34
+ </PreviewSettingsProvider>
35
+ );
36
+ }
37
+
24
38
  function renderPreviewControls(markdown: string) {
25
39
  render(
26
40
  <EditorProvider initialMarkdown={markdown}>
@@ -29,6 +43,14 @@ function renderPreviewControls(markdown: string) {
29
43
  );
30
44
  }
31
45
 
46
+ function renderPreviewToolbar(markdown: string) {
47
+ render(
48
+ <EditorProvider initialMarkdown={markdown}>
49
+ <PreviewToolbarHarness />
50
+ </EditorProvider>,
51
+ );
52
+ }
53
+
32
54
  afterEach(() => cleanup());
33
55
 
34
56
  describe('PreviewModeSwitch', () => {
@@ -68,3 +90,74 @@ describe('PreviewModeSwitch', () => {
68
90
  expect(screen.getByRole('button', { name: 'Page' }).getAttribute('aria-pressed')).toBe('true');
69
91
  });
70
92
  });
93
+
94
+ describe('PreviewToolbarControls', () => {
95
+ it('keeps the overflow popover inside the left viewport edge', async () => {
96
+ const originalResizeObserver = globalThis.ResizeObserver;
97
+ const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
98
+ const originalClientWidth = Object.getOwnPropertyDescriptor(
99
+ HTMLElement.prototype,
100
+ 'clientWidth',
101
+ );
102
+
103
+ class ResizeObserverStub implements ResizeObserver {
104
+ readonly callback: ResizeObserverCallback;
105
+
106
+ constructor(callback: ResizeObserverCallback) {
107
+ this.callback = callback;
108
+ }
109
+
110
+ observe() {
111
+ this.callback([], this);
112
+ }
113
+
114
+ unobserve() {}
115
+
116
+ disconnect() {}
117
+ }
118
+
119
+ globalThis.ResizeObserver = ResizeObserverStub;
120
+ HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
121
+ if (this.classList.contains('squisq-preview-controls-popover')) {
122
+ return new DOMRect(0, 0, 220, 240);
123
+ }
124
+ if (this.getAttribute('aria-label') === 'More preview settings') {
125
+ return new DOMRect(6, 20, 28, 28);
126
+ }
127
+ if (this.classList.contains('squisq-preview-control')) {
128
+ return new DOMRect(0, 0, 100, 24);
129
+ }
130
+ return new DOMRect(0, 0, 0, 0);
131
+ };
132
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
133
+ configurable: true,
134
+ get() {
135
+ return this.classList.contains('squisq-preview-controls') ? 40 : 0;
136
+ },
137
+ });
138
+
139
+ try {
140
+ renderPreviewToolbar('# Hello');
141
+
142
+ fireEvent.click(await screen.findByRole('button', { name: 'More preview settings' }));
143
+
144
+ await waitFor(() => {
145
+ const popover = document.querySelector<HTMLElement>('.squisq-preview-controls-popover');
146
+ expect(popover).not.toBeNull();
147
+ expect(popover?.style.left).toBe('8px');
148
+ });
149
+ } finally {
150
+ if (originalResizeObserver) {
151
+ globalThis.ResizeObserver = originalResizeObserver;
152
+ } else {
153
+ Reflect.deleteProperty(globalThis, 'ResizeObserver');
154
+ }
155
+ HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
156
+ if (originalClientWidth) {
157
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth);
158
+ } else {
159
+ Reflect.deleteProperty(HTMLElement.prototype, 'clientWidth');
160
+ }
161
+ }
162
+ });
163
+ });
@@ -16,13 +16,23 @@ describe('Template annotation round-trip', () => {
16
16
  expect(back.trim()).toBe(original);
17
17
  });
18
18
 
19
+ it('round-trips param-only squiggly annotations', () => {
20
+ const original = '## Intro {[transition=fade]}';
21
+ const html = markdownToTiptap(original);
22
+ expect(html).not.toContain('data-template="transition=fade"');
23
+ expect(html).toContain('data-template-params="transition=fade"');
24
+ expect(tiptapToMarkdown(html).trim()).toBe(original);
25
+ });
26
+
19
27
  it('preserves template annotation through Tiptap-rendered HTML (with badge spans)', () => {
20
28
  // Simulates HTML that Tiptap actually renders after parse: includes the
21
29
  // squisq-heading-content + squisq-template-badge wrapper spans.
22
30
  const tiptapRendered =
23
31
  '<h2 data-template="comparisonBar">' +
24
32
  '<span class="squisq-heading-content">Getting Started</span>' +
25
- '<span class="squisq-template-badge" contenteditable="false" data-template="comparisonBar" data-template-label="Comparison Bar"></span>' +
33
+ '<span class="squisq-template-badge" contenteditable="false" data-template="comparisonBar">' +
34
+ '<span class="squisq-template-badge-core" data-template-label="Comparison Bar" aria-hidden="true"></span>' +
35
+ '</span>' +
26
36
  '</h2>';
27
37
  const md = tiptapToMarkdown(tiptapRendered);
28
38
  expect(md).toContain('## Getting Started');
@@ -39,7 +49,9 @@ describe('Template annotation round-trip', () => {
39
49
  const tiptapRendered =
40
50
  '<h2 data-block-attrs="transition=fade">' +
41
51
  '<span class="squisq-heading-content">Intro</span>' +
42
- '<span class="squisq-template-badge squisq-template-badge--empty" contenteditable="false"></span>' +
52
+ '<span class="squisq-template-badge squisq-template-badge--empty" contenteditable="false">' +
53
+ '<span class="squisq-template-badge-core" data-template-label="Block" aria-hidden="true"></span>' +
54
+ '</span>' +
43
55
  '<span class="squisq-props-badge" contenteditable="false" data-props-summary="Fade · 1:30 start"></span>' +
44
56
  '</h2>';
45
57
  const md = tiptapToMarkdown(tiptapRendered).trim();
@@ -49,4 +61,13 @@ describe('Template annotation round-trip', () => {
49
61
  // The CSS-painted summary lives in a data attribute; it must not bleed.
50
62
  expect(md).not.toContain('start');
51
63
  });
64
+
65
+ it('serializes data-template-params without data-template as a squiggly annotation', () => {
66
+ const tiptapRendered =
67
+ '<h2 data-template-params="transition=fade">' +
68
+ '<span class="squisq-heading-content">Intro</span>' +
69
+ '<span class="squisq-props-badge" contenteditable="false" data-props-summary="Fade"></span>' +
70
+ '</h2>';
71
+ expect(tiptapToMarkdown(tiptapRendered).trim()).toBe('## Intro {[transition=fade]}');
72
+ });
52
73
  });
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { DEFAULT_THEME, markdownToDoc } from '@bendyline/squisq/doc';
3
+ import { parseMarkdown } from '@bendyline/squisq/markdown';
4
+ import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
5
+ import {
6
+ resolveTemplateContentPreview,
7
+ resolveTemplateContentPreviewResult,
8
+ type TemplatePreviewSource,
9
+ } from '../templateContentPreviewResolver';
10
+
11
+ function previewSource(markdown: string): TemplatePreviewSource {
12
+ const doc = markdownToDoc(parseMarkdown(markdown), { autoTemplates: false });
13
+ const block = doc.blocks[0];
14
+ if (!block) throw new Error('expected markdown to produce a block');
15
+ return {
16
+ block,
17
+ theme: DEFAULT_THEME,
18
+ viewport: VIEWPORT_PRESETS.landscape,
19
+ basePath: '/',
20
+ };
21
+ }
22
+
23
+ describe('template content previews', () => {
24
+ it('renders a candidate preview from the active block content', () => {
25
+ const visual = resolveTemplateContentPreview(
26
+ 'list',
27
+ previewSource(`## Launch Steps
28
+
29
+ - Draft the outline
30
+ - Review the visuals
31
+ - Publish the page
32
+ `),
33
+ );
34
+
35
+ expect(visual).toBeTruthy();
36
+ expect(JSON.stringify(visual?.layers)).toContain('Draft the outline');
37
+ });
38
+
39
+ it('falls back for content-specific templates when the block is too sparse', () => {
40
+ const visual = resolveTemplateContentPreview('list', previewSource('## About Squisq'));
41
+
42
+ expect(visual).toBeNull();
43
+ });
44
+
45
+ it('reports why stat and date previews cannot be derived', () => {
46
+ expect(
47
+ resolveTemplateContentPreviewResult('statHighlight', previewSource('## About Squisq')),
48
+ ).toMatchObject({
49
+ visual: null,
50
+ warning: 'No stat found in this block',
51
+ });
52
+
53
+ expect(
54
+ resolveTemplateContentPreviewResult('dateEvent', previewSource('## About Squisq')),
55
+ ).toMatchObject({
56
+ visual: null,
57
+ warning: 'No date found in this block',
58
+ });
59
+ });
60
+
61
+ it('reports why image previews cannot be derived', () => {
62
+ expect(
63
+ resolveTemplateContentPreviewResult('imageWithCaption', previewSource('## About Squisq')),
64
+ ).toMatchObject({
65
+ visual: null,
66
+ warning: 'No image found in this block',
67
+ });
68
+ });
69
+
70
+ it('reports why video previews cannot be derived without media', () => {
71
+ expect(
72
+ resolveTemplateContentPreviewResult('videoWithCaption', previewSource('## About Squisq')),
73
+ ).toMatchObject({
74
+ visual: null,
75
+ warning: 'No audio/video found in this block',
76
+ });
77
+
78
+ expect(
79
+ resolveTemplateContentPreviewResult('videoPullQuote', previewSource('## About Squisq')),
80
+ ).toMatchObject({
81
+ visual: null,
82
+ warning: 'No audio/video found in this block',
83
+ });
84
+ });
85
+
86
+ it('does not warn about missing media when an audio or video tag is present', () => {
87
+ expect(
88
+ resolveTemplateContentPreviewResult(
89
+ 'videoWithCaption',
90
+ previewSource('## Demo\n\n<video src="media/demo.mp4" controls></video>'),
91
+ ).warning,
92
+ ).toBeUndefined();
93
+
94
+ expect(
95
+ resolveTemplateContentPreviewResult(
96
+ 'videoPullQuote',
97
+ previewSource('## Narration\n\n<audio src="audio/narration.webm" controls></audio>'),
98
+ ).warning,
99
+ ).toBeUndefined();
100
+ });
101
+ });
@@ -17,6 +17,22 @@ describe('markdownToTiptap', () => {
17
17
  expect(html).toContain('Hello world');
18
18
  });
19
19
 
20
+ it('preserves extra blank lines as empty paragraphs', () => {
21
+ expect(markdownToTiptap('Alpha\n\nBeta')).toBe('<p>Alpha</p><p>Beta</p>');
22
+ expect(markdownToTiptap('Alpha\n\n\nBeta')).toBe('<p>Alpha</p><p></p><p>Beta</p>');
23
+ expect(markdownToTiptap('Alpha\n\n\n\nBeta')).toBe('<p>Alpha</p><p></p><p></p><p>Beta</p>');
24
+ });
25
+
26
+ it('preserves trailing extra blank lines as empty paragraphs', () => {
27
+ expect(markdownToTiptap('Alpha\n')).toBe('<p>Alpha</p>');
28
+ expect(markdownToTiptap('Alpha\n\n')).toBe('<p>Alpha</p><p></p>');
29
+ });
30
+
31
+ it('preserves leading paragraph spaces as visible HTML whitespace', () => {
32
+ expect(markdownToTiptap(' indented')).toBe('<p>&nbsp;&nbsp;indented</p>');
33
+ expect(markdownToTiptap('Alpha\n\n Beta')).toBe('<p>Alpha</p><p>&nbsp;&nbsp;&nbsp;Beta</p>');
34
+ });
35
+
20
36
  it('converts headings h1-h3', () => {
21
37
  expect(markdownToTiptap('# Title')).toContain('<h1');
22
38
  expect(markdownToTiptap('## Subtitle')).toContain('<h2');
@@ -193,6 +209,22 @@ describe('tiptapToMarkdown', () => {
193
209
  expect(md).toContain('Hello world');
194
210
  });
195
211
 
212
+ it('preserves empty paragraphs as extra blank lines', () => {
213
+ expect(tiptapToMarkdown('<p>Alpha</p><p>Beta</p>')).toBe('Alpha\n\nBeta\n');
214
+ expect(tiptapToMarkdown('<p>Alpha</p><p></p><p>Beta</p>')).toBe('Alpha\n\n\nBeta\n');
215
+ expect(tiptapToMarkdown('<p>Alpha</p><p></p><p></p><p>Beta</p>')).toBe('Alpha\n\n\n\nBeta\n');
216
+ });
217
+
218
+ it('preserves trailing empty paragraphs', () => {
219
+ expect(tiptapToMarkdown('<p>Alpha</p><p></p>')).toBe('Alpha\n\n');
220
+ });
221
+
222
+ it('restores leading paragraph spaces from visible HTML whitespace', () => {
223
+ expect(tiptapToMarkdown('<p>&nbsp;&nbsp;indented</p>')).toBe(' indented\n');
224
+ expect(tiptapToMarkdown('<p>\u00a0\u00a0indented</p>')).toBe(' indented\n');
225
+ expect(tiptapToMarkdown('<p>&#160;&#xA0;indented</p>')).toBe(' indented\n');
226
+ });
227
+
196
228
  it('converts headings', () => {
197
229
  expect(tiptapToMarkdown('<h1>Title</h1>')).toContain('# Title');
198
230
  expect(tiptapToMarkdown('<h2>Sub</h2>')).toContain('## Sub');
@@ -430,6 +462,21 @@ describe('round-trip: markdownToTiptap → tiptapToMarkdown', () => {
430
462
  expect(result).not.toContain('- [x] buy milk');
431
463
  });
432
464
 
465
+ it('preserves extra blank lines between paragraphs exactly', () => {
466
+ expect(roundTrip('Alpha\n\nBeta\n')).toBe('Alpha\n\nBeta\n');
467
+ expect(roundTrip('Alpha\n\n\nBeta\n')).toBe('Alpha\n\n\nBeta\n');
468
+ expect(roundTrip('Alpha\n\n\n\nBeta\n')).toBe('Alpha\n\n\n\nBeta\n');
469
+ });
470
+
471
+ it('preserves trailing empty paragraphs exactly', () => {
472
+ expect(roundTrip('Alpha\n\n')).toBe('Alpha\n\n');
473
+ });
474
+
475
+ it('preserves leading paragraph spaces exactly', () => {
476
+ expect(roundTrip(' indented\n')).toBe(' indented\n');
477
+ expect(roundTrip('Alpha\n\n Beta\n')).toBe('Alpha\n\n Beta\n');
478
+ });
479
+
433
480
  it('preserves quoted template params with spaces', () => {
434
481
  const result = roundTrip(
435
482
  '## Gallery {[imageWithCaption src=photo.jpg caption="Beach at sunset"]}',
@@ -18,6 +18,7 @@ import { DiagramMaximizedOverlay } from './DiagramMaximizedOverlay';
18
18
  import { useDiagramData } from './useDiagramData';
19
19
  import { findDiagramHeadingPos } from './DiagramExtension';
20
20
  import { SceneBlockToolbar, type SceneBlockAction } from '../scene/SceneBlockToolbar';
21
+ import { SceneSideToolbar } from '../scene/SceneSideToolbar';
21
22
  import { nodeIdFromCardLayerId, NODE_WIDTH, NODE_HEIGHT } from '../scene';
22
23
  import { Icon } from '../Icon';
23
24
  import {
@@ -226,8 +227,6 @@ export function DiagramWidget({ editor, headingKey, fallbackParentPos, host }: D
226
227
  />
227
228
  );
228
229
 
229
- const sideToolbar = <div className="squisq-scene-side-toolbar">{toolbar}</div>;
230
-
231
230
  if (maximized) {
232
231
  return (
233
232
  <div
@@ -235,9 +234,10 @@ export function DiagramWidget({ editor, headingKey, fallbackParentPos, host }: D
235
234
  style={effectiveHeight != null ? { height: effectiveHeight } : undefined}
236
235
  >
237
236
  <DiagramMaximizedOverlay host={host ?? null} onClose={() => setMaximized(false)}>
237
+ {/* Maximized has a full screen for a static right column — no collapse. */}
238
238
  <div className="squisq-scene-block-max">
239
239
  {canvas}
240
- {sideToolbar}
240
+ <div className="squisq-scene-side-toolbar">{toolbar}</div>
241
241
  </div>
242
242
  </DiagramMaximizedOverlay>
243
243
  </div>
@@ -246,6 +246,9 @@ export function DiagramWidget({ editor, headingKey, fallbackParentPos, host }: D
246
246
 
247
247
  return (
248
248
  <div className="squisq-scene-shell">
249
+ {/* Before the canvas so the narrow-width fallback bar sits above it; the
250
+ wide-width gutter column is absolute and unaffected by DOM order. */}
251
+ <SceneSideToolbar>{toolbar}</SceneSideToolbar>
249
252
  <div
250
253
  className="squisq-diagram-inline"
251
254
  ref={inlineRef}
@@ -262,7 +265,6 @@ export function DiagramWidget({ editor, headingKey, fallbackParentPos, host }: D
262
265
  title="Drag to resize · double-click to reset"
263
266
  />
264
267
  </div>
265
- {sideToolbar}
266
268
  </div>
267
269
  );
268
270
  }
@@ -6,16 +6,13 @@
6
6
  *
7
7
  * - Markdown (Monaco): operate on the raw heading line string.
8
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).
9
+ * (Pandoc `{…}` inner) and `dataTemplateParams` string (the params inside
10
+ * the squisq-native `{[…]}` annotation).
11
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.
12
+ * The editor writes transitions to the squisq-native `{[…]}` annotation by
13
+ * default. It still reads legacy Pandoc `{transition=…}` attributes and
14
+ * removes/migrates those keys when rewriting, so the two channels cannot drift
15
+ * after a toolbar edit.
19
16
  *
20
17
  * All the brace-matching / tokenizing / serializing is delegated to the
21
18
  * shared core helpers so this stays in lockstep with the parser by import
@@ -29,6 +26,7 @@ import {
29
26
  serializePandocAttributes,
30
27
  tokenizeAttrTokens,
31
28
  splitKeyValueToken,
29
+ quoteAttrValue,
32
30
  type HeadingAttributes,
33
31
  } from '@bendyline/squisq/markdown';
34
32
 
@@ -85,6 +83,14 @@ function applyFieldsToParams(
85
83
  return out;
86
84
  }
87
85
 
86
+ function removeFieldsFromAttrs(attrs: HeadingAttributes): HeadingAttributes {
87
+ if (!attrs.params) return attrs;
88
+ const params = applyFieldsToParams(attrs.params, EMPTY_TRANSITION);
89
+ if (Object.keys(params).length > 0) attrs.params = params;
90
+ else delete attrs.params;
91
+ return attrs;
92
+ }
93
+
88
94
  /** Parse a bare `key=value …` token string into a params map. */
89
95
  function paramsFromTokenString(input: string, skipFirstToken: boolean): Record<string, string> {
90
96
  const tokens = tokenizeAttrTokens(input);
@@ -96,6 +102,39 @@ function paramsFromTokenString(input: string, skipFirstToken: boolean): Record<s
96
102
  return params;
97
103
  }
98
104
 
105
+ function templatePartsFromInner(inner: string | null | undefined): {
106
+ template: string | undefined;
107
+ params: Record<string, string>;
108
+ } {
109
+ if (!inner) return { template: undefined, params: {} };
110
+ const tokens = tokenizeAttrTokens(inner);
111
+ const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
112
+ const template = firstIsParam || tokens.length === 0 ? undefined : tokens[0];
113
+ const startIdx = template ? 1 : 0;
114
+ const params: Record<string, string> = {};
115
+ for (const token of tokens.slice(startIdx)) {
116
+ const kv = splitKeyValueToken(token);
117
+ if (kv) params[kv.key] = kv.value;
118
+ }
119
+ return { template, params };
120
+ }
121
+
122
+ function paramsToInner(params: Record<string, string>): string | null {
123
+ const parts = Object.entries(params).map(([key, value]) => `${key}=${quoteAttrValue(value)}`);
124
+ return parts.length > 0 ? parts.join(' ') : null;
125
+ }
126
+
127
+ function templateAnnotationOrNull(
128
+ template: string | undefined,
129
+ params: Record<string, string>,
130
+ ): string | null {
131
+ const parts: string[] = [];
132
+ if (template) parts.push(template);
133
+ const paramInner = paramsToInner(params);
134
+ if (paramInner) parts.push(paramInner);
135
+ return parts.length > 0 ? `{[${parts.join(' ')}]}` : null;
136
+ }
137
+
99
138
  // ============================================
100
139
  // Heading line (Markdown / Monaco view)
101
140
  // ============================================
@@ -141,7 +180,7 @@ function splitHeadingLine(line: string): SplitHeadingLine | null {
141
180
 
142
181
  /**
143
182
  * Read the transition fields off a heading line. Looks in both the Pandoc
144
- * `{…}` block (canonical) and the `{[…]}` template params (hand-typed),
183
+ * `{…}` block (legacy) and the `{[…]}` template params (canonical),
145
184
  * with the Pandoc block taking precedence. Returns the empty transition for
146
185
  * non-heading lines.
147
186
  */
@@ -149,7 +188,7 @@ export function readHeadingLineTransition(line: string): TransitionFields {
149
188
  const split = splitHeadingLine(line);
150
189
  if (!split) return { ...EMPTY_TRANSITION };
151
190
  const fromTemplate = split.templateText
152
- ? paramsFromTokenString(stripTemplateBraces(split.templateText), true)
191
+ ? templatePartsFromInner(stripTemplateBraces(split.templateText)).params
153
192
  : {};
154
193
  const fromPandoc = split.pandocInner
155
194
  ? (parsePandocAttrTokens(split.pandocInner).params ?? {})
@@ -159,8 +198,9 @@ export function readHeadingLineTransition(line: string): TransitionFields {
159
198
 
160
199
  /**
161
200
  * 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.
201
+ * squisq-native `{[]}` annotation. Legacy Pandoc transition keys are removed
202
+ * while preserving ids, classes, and other Pandoc params. Non-heading lines are
203
+ * returned unchanged.
164
204
  */
165
205
  export function setHeadingLineTransition(line: string, next: TransitionFields): string {
166
206
  const split = splitHeadingLine(line);
@@ -168,12 +208,18 @@ export function setHeadingLineTransition(line: string, next: TransitionFields):
168
208
  const attrs: HeadingAttributes = split.pandocInner
169
209
  ? parsePandocAttrTokens(split.pandocInner)
170
210
  : {};
171
- attrs.params = applyFieldsToParams(attrs.params ?? {}, next);
211
+ removeFieldsFromAttrs(attrs);
172
212
  const pandoc = pandocBlockOrNull(attrs);
173
213
 
214
+ const templateParts = templatePartsFromInner(
215
+ split.templateText ? stripTemplateBraces(split.templateText) : null,
216
+ );
217
+ const templateParams = applyFieldsToParams(templateParts.params, next);
218
+ const template = templateAnnotationOrNull(templateParts.template, templateParams);
219
+
174
220
  let out = split.prefix + split.text;
175
221
  if (pandoc) out += ` ${pandoc}`;
176
- if (split.templateText) out += ` ${split.templateText}`;
222
+ if (template) out += ` ${template}`;
177
223
  return out;
178
224
  }
179
225
 
@@ -188,8 +234,10 @@ function stripTemplateBraces(templateText: string): string {
188
234
  // ============================================
189
235
 
190
236
  /**
191
- * Read the transition fields from a heading node's `dataBlockAttrs` (Pandoc
192
- * inner) plus `dataTemplateParams` (the `{[…]}` params). Pandoc wins.
237
+ * Read the transition fields from a heading node's `dataBlockAttrs` (legacy
238
+ * Pandoc inner) plus `dataTemplateParams` (canonical `{[…]}` params). Pandoc
239
+ * wins so the picker mirrors the value that `markdownToDoc` will render when
240
+ * both channels are present.
193
241
  */
194
242
  export function readBlockAttrsTransition(
195
243
  blockAttrsInner: string | null | undefined,
@@ -200,11 +248,38 @@ export function readBlockAttrsTransition(
200
248
  return fieldsFromParams({ ...fromTemplate, ...fromPandoc });
201
249
  }
202
250
 
251
+ export interface HeadingTransitionAttrs {
252
+ /** Inner of the Pandoc `{…}` block, without braces. */
253
+ blockAttrsInner: string | null;
254
+ /** Param string inside the `{[…]}` annotation, without the template token. */
255
+ templateParams: string | null;
256
+ }
257
+
258
+ /**
259
+ * Rewrite a heading node's transition for Tiptap, writing the transition
260
+ * family into `dataTemplateParams` and removing any legacy transition keys
261
+ * from `dataBlockAttrs`.
262
+ */
263
+ export function setHeadingAttrsTransition(
264
+ blockAttrsInner: string | null | undefined,
265
+ templateParams: string | null | undefined,
266
+ next: TransitionFields,
267
+ ): HeadingTransitionAttrs {
268
+ const attrs: HeadingAttributes = blockAttrsInner ? parsePandocAttrTokens(blockAttrsInner) : {};
269
+ removeFieldsFromAttrs(attrs);
270
+ const pandoc = pandocBlockOrNull(attrs);
271
+
272
+ const params = applyFieldsToParams(paramsFromTokenString(templateParams ?? '', false), next);
273
+ return {
274
+ blockAttrsInner: pandoc ? pandoc.slice(1, -1) : null,
275
+ templateParams: paramsToInner(params),
276
+ };
277
+ }
278
+
203
279
  /**
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 `{}`).
280
+ * Legacy single-channel writer for callers that only own `dataBlockAttrs`.
281
+ * Internal editor surfaces should use {@link setHeadingAttrsTransition} so new
282
+ * transition edits land in the squisq-native `{[…]}` params.
208
283
  */
209
284
  export function setBlockAttrsTransition(
210
285
  blockAttrsInner: string | null | undefined,
package/src/index.ts CHANGED
@@ -141,10 +141,11 @@ export {
141
141
  readHeadingLineTransition,
142
142
  setHeadingLineTransition,
143
143
  readBlockAttrsTransition,
144
+ setHeadingAttrsTransition,
144
145
  setBlockAttrsTransition,
145
146
  EMPTY_TRANSITION,
146
147
  } from './headingTransition.js';
147
- export type { TransitionFields } from './headingTransition.js';
148
+ export type { HeadingTransitionAttrs, TransitionFields } from './headingTransition.js';
148
149
  export {
149
150
  readBlockAttrsParams,
150
151
  readBlockAttrsValue,