@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.
- package/dist/index.d.ts +87 -41
- package/dist/index.js +8263 -6705
- package/dist/index.js.map +1 -1
- package/dist/styles/index.css +841 -91
- package/package.json +4 -4
- package/src/BlockPropertiesPopover.tsx +23 -7
- package/src/EditorShell.tsx +65 -20
- package/src/MediaBin.tsx +171 -28
- package/src/PreviewControls.tsx +177 -44
- package/src/PreviewPanel.tsx +2 -0
- package/src/TemplateAnnotation.ts +22 -7
- package/src/TemplateContentPreview.tsx +56 -0
- package/src/TemplatePicker.tsx +295 -128
- package/src/ThemeCustomizerPanel.tsx +22 -15
- package/src/Toolbar.tsx +527 -207
- package/src/TransitionPicker.tsx +8 -1
- package/src/ViewSwitcher.tsx +4 -4
- package/src/WysiwygEditor.tsx +45 -3
- package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
- package/src/__tests__/editorShellProps.test.tsx +268 -1
- package/src/__tests__/headingTransition.test.ts +59 -9
- package/src/__tests__/imageEditorShell.test.tsx +23 -0
- package/src/__tests__/mediaReferences.test.ts +82 -0
- package/src/__tests__/previewControls.test.tsx +94 -1
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +23 -2
- package/src/__tests__/templateContentPreview.test.ts +101 -0
- package/src/__tests__/tiptapBridge.test.ts +47 -0
- package/src/diagram/DiagramWidget.tsx +6 -4
- package/src/headingTransition.ts +96 -21
- package/src/index.ts +2 -1
- package/src/mediaReferences.ts +299 -0
- package/src/scene/Scene.tsx +53 -7
- package/src/scene/SceneBlockWidget.tsx +6 -3
- package/src/scene/SceneSelection.tsx +19 -15
- package/src/scene/SceneSideToolbar.tsx +89 -0
- package/src/scene/layers/DiagramEdges.tsx +4 -3
- package/src/scene/layers/edgeGeometry.ts +23 -4
- package/src/scene/scene.css +142 -2
- package/src/scene/tools/ConnectTool.ts +113 -24
- package/src/scene/tools/DrawingConnectTool.ts +86 -16
- package/src/scene/tools/SceneTool.ts +2 -0
- package/src/styles/editor.css +862 -101
- package/src/templateContentPreviewResolver.ts +353 -0
- 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 {
|
|
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"
|
|
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"
|
|
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> indented</p>');
|
|
33
|
+
expect(markdownToTiptap('Alpha\n\n Beta')).toBe('<p>Alpha</p><p> 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> indented</p>')).toBe(' indented\n');
|
|
224
|
+
expect(tiptapToMarkdown('<p>\u00a0\u00a0indented</p>')).toBe(' indented\n');
|
|
225
|
+
expect(tiptapToMarkdown('<p>  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
|
-
{
|
|
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
|
}
|
package/src/headingTransition.ts
CHANGED
|
@@ -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
|
-
* (
|
|
10
|
-
* `
|
|
9
|
+
* (Pandoc `{…}` inner) and `dataTemplateParams` string (the params inside
|
|
10
|
+
* the squisq-native `{[…]}` annotation).
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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 (
|
|
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
|
-
?
|
|
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
|
-
*
|
|
163
|
-
* Non-heading lines are
|
|
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
|
-
|
|
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 (
|
|
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` (
|
|
192
|
-
* inner) plus `dataTemplateParams` (
|
|
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
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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,
|