@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.
- package/README.md +57 -10
- package/dist/index.d.ts +476 -105
- package/dist/index.js +8703 -6741
- package/dist/index.js.map +1 -1
- package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
- package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
- package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
- package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
- package/dist/styles/index.css +14617 -0
- package/package.json +15 -7
- package/src/BlockPropertiesPopover.tsx +23 -7
- package/src/EditorContext.tsx +22 -16
- package/src/EditorShell.tsx +121 -35
- package/src/MediaBin.tsx +171 -28
- package/src/OutlinePanel.tsx +26 -4
- package/src/PreviewControls.tsx +475 -145
- package/src/PreviewPanel.tsx +17 -9
- package/src/RawEditor.tsx +10 -4
- 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 +547 -217
- package/src/TransitionPicker.tsx +8 -1
- package/src/VersionHistoryPanel.tsx +2 -2
- package/src/ViewSwitcher.tsx +4 -4
- package/src/WysiwygEditor.tsx +45 -3
- package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
- package/src/__tests__/codeContextSectionView.test.tsx +95 -0
- package/src/__tests__/codeContextZoneManager.test.ts +127 -0
- package/src/__tests__/diffContextSections.test.ts +39 -0
- package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
- package/src/__tests__/editorShellProps.test.tsx +363 -0
- 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 +163 -0
- 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/__tests__/useJsonEditorTokens.test.ts +59 -0
- package/src/__tests__/useMediaRecorder.test.ts +17 -0
- package/src/codeContext/CodeContextSectionView.tsx +124 -0
- package/src/codeContext/CodeContextZoneManager.ts +149 -0
- package/src/codeContext/CodeContextZones.tsx +121 -0
- package/src/codeContext/diffContextSections.ts +38 -0
- package/src/codeContext/types.ts +75 -0
- package/src/diagram/DiagramWidget.tsx +6 -4
- package/src/headingTransition.ts +96 -21
- package/src/index.ts +34 -2
- package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
- package/src/mediaReferences.ts +299 -0
- package/src/recorder/hooks/useMediaRecorder.ts +9 -10
- 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/code-context.css +155 -0
- package/src/styles/editor.css +991 -84
- package/src/styles/index.css +1 -0
- package/src/templateContentPreviewResolver.ts +353 -0
- package/src/tiptapBridge.ts +60 -33
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
6
|
+
import { EditorProvider, useEditorContext } from '../EditorContext';
|
|
7
|
+
import {
|
|
8
|
+
PreviewModeSwitch,
|
|
9
|
+
PreviewSettingsProvider,
|
|
10
|
+
PreviewToolbarControls,
|
|
11
|
+
usePreviewSettings,
|
|
12
|
+
} from '../PreviewControls';
|
|
13
|
+
|
|
14
|
+
function ModeProbe() {
|
|
15
|
+
const { activeDisplayMode } = usePreviewSettings();
|
|
16
|
+
return <div data-testid="active-mode">{activeDisplayMode}</div>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function PreviewHarness() {
|
|
20
|
+
const { doc } = useEditorContext();
|
|
21
|
+
return (
|
|
22
|
+
<PreviewSettingsProvider doc={doc}>
|
|
23
|
+
<PreviewModeSwitch />
|
|
24
|
+
<ModeProbe />
|
|
25
|
+
</PreviewSettingsProvider>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function PreviewToolbarHarness() {
|
|
30
|
+
const { doc } = useEditorContext();
|
|
31
|
+
return (
|
|
32
|
+
<PreviewSettingsProvider doc={doc}>
|
|
33
|
+
<PreviewToolbarControls />
|
|
34
|
+
</PreviewSettingsProvider>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderPreviewControls(markdown: string) {
|
|
39
|
+
render(
|
|
40
|
+
<EditorProvider initialMarkdown={markdown}>
|
|
41
|
+
<PreviewHarness />
|
|
42
|
+
</EditorProvider>,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function renderPreviewToolbar(markdown: string) {
|
|
47
|
+
render(
|
|
48
|
+
<EditorProvider initialMarkdown={markdown}>
|
|
49
|
+
<PreviewToolbarHarness />
|
|
50
|
+
</EditorProvider>,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
afterEach(() => cleanup());
|
|
55
|
+
|
|
56
|
+
describe('PreviewModeSwitch', () => {
|
|
57
|
+
it('labels the plain document preview as Document and the styled view as Page', () => {
|
|
58
|
+
renderPreviewControls('# Hello');
|
|
59
|
+
|
|
60
|
+
const labels = screen
|
|
61
|
+
.getAllByRole('button')
|
|
62
|
+
.map((button) => button.textContent)
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
|
|
65
|
+
expect(labels).toEqual(['Video', 'Slideshow', 'Page', 'Document']);
|
|
66
|
+
|
|
67
|
+
fireEvent.click(screen.getByRole('button', { name: 'Document' }));
|
|
68
|
+
expect(screen.getByTestId('active-mode').textContent).toBe('page');
|
|
69
|
+
|
|
70
|
+
fireEvent.click(screen.getByRole('button', { name: 'Page' }));
|
|
71
|
+
expect(screen.getByTestId('active-mode').textContent).toBe('linear');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('maps product-facing display-mode frontmatter to the correct renderer values', async () => {
|
|
75
|
+
renderPreviewControls('---\ndisplay-mode: document\n---\n\n# Hello');
|
|
76
|
+
|
|
77
|
+
await waitFor(() => {
|
|
78
|
+
expect(screen.getByTestId('active-mode').textContent).toBe('page');
|
|
79
|
+
});
|
|
80
|
+
expect(screen.getByRole('button', { name: 'Document' }).getAttribute('aria-pressed')).toBe(
|
|
81
|
+
'true',
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
cleanup();
|
|
85
|
+
renderPreviewControls('---\ndisplay-mode: page\n---\n\n# Hello');
|
|
86
|
+
|
|
87
|
+
await waitFor(() => {
|
|
88
|
+
expect(screen.getByTestId('active-mode').textContent).toBe('linear');
|
|
89
|
+
});
|
|
90
|
+
expect(screen.getByRole('button', { name: 'Page' }).getAttribute('aria-pressed')).toBe('true');
|
|
91
|
+
});
|
|
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"]}',
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { renderHook } from '@testing-library/react';
|
|
3
|
+
import { DARK_SURFACE, LIGHT_SURFACE, DEFAULT_THEME } from '@bendyline/squisq/schemas';
|
|
4
|
+
import { useJsonEditorTokens } from '../jsonEditor/useJsonEditorTokens';
|
|
5
|
+
|
|
6
|
+
type StyleBag = Record<string, string>;
|
|
7
|
+
|
|
8
|
+
/** Install a matchMedia stub whose `(prefers-color-scheme: dark)` resolves to `dark`. */
|
|
9
|
+
function mockPrefersDark(dark: boolean): void {
|
|
10
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
11
|
+
configurable: true,
|
|
12
|
+
value: (query: string) => ({
|
|
13
|
+
matches: dark && query.includes('dark'),
|
|
14
|
+
media: query,
|
|
15
|
+
onchange: null,
|
|
16
|
+
addListener: vi.fn(),
|
|
17
|
+
removeListener: vi.fn(),
|
|
18
|
+
addEventListener: vi.fn(),
|
|
19
|
+
removeEventListener: vi.fn(),
|
|
20
|
+
dispatchEvent: vi.fn(() => false),
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('useJsonEditorTokens', () => {
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
vi.restoreAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('emits jsonform-prefixed tokens', () => {
|
|
31
|
+
mockPrefersDark(false);
|
|
32
|
+
const { result } = renderHook(() => useJsonEditorTokens(DEFAULT_THEME, LIGHT_SURFACE));
|
|
33
|
+
const style = result.current.style as StyleBag;
|
|
34
|
+
expect(style['--squisq-jsonform-bg']).toBe(LIGHT_SURFACE.background);
|
|
35
|
+
expect(style).toHaveProperty('--squisq-jsonform-warning');
|
|
36
|
+
expect(style).toHaveProperty('--squisq-jsonform-input-bg');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("responds to a dark OS preference under surface='auto' (reactive via useAutoSurface)", () => {
|
|
40
|
+
mockPrefersDark(true);
|
|
41
|
+
const { result } = renderHook(() => useJsonEditorTokens(DEFAULT_THEME, 'auto'));
|
|
42
|
+
const style = result.current.style as StyleBag;
|
|
43
|
+
expect(style['--squisq-jsonform-bg']).toBe(DARK_SURFACE.background);
|
|
44
|
+
expect(style['--squisq-jsonform-text']).toBe(DARK_SURFACE.text);
|
|
45
|
+
expect(result.current.theme.colors.background).toBe(DARK_SURFACE.background);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("uses light surface under surface='auto' when the OS prefers light", () => {
|
|
49
|
+
mockPrefersDark(false);
|
|
50
|
+
const { result } = renderHook(() => useJsonEditorTokens(DEFAULT_THEME, 'auto'));
|
|
51
|
+
const style = result.current.style as StyleBag;
|
|
52
|
+
expect(style['--squisq-jsonform-bg']).toBe(LIGHT_SURFACE.background);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
// Default to a defined matchMedia so the hook's useAutoSurface can subscribe.
|
|
57
|
+
mockPrefersDark(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -165,6 +165,23 @@ describe('useMediaRecorder lifecycle', () => {
|
|
|
165
165
|
expect(result.current.stream).not.toBeNull();
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
+
it('defaults to the mic source when called with no options', async () => {
|
|
169
|
+
const { result } = renderHook(() => useMediaRecorder());
|
|
170
|
+
|
|
171
|
+
expect(result.current.state).toBe('idle');
|
|
172
|
+
|
|
173
|
+
await act(async () => {
|
|
174
|
+
await result.current.request();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Mic path: audio-only capture via getUserMedia, lands in `audio/`.
|
|
178
|
+
expect(result.current.state).toBe('ready');
|
|
179
|
+
expect(result.current.mimeType).toMatch(/^audio\/webm/);
|
|
180
|
+
expect(result.current.directory).toBe('audio');
|
|
181
|
+
const getUserMedia = navigator.mediaDevices.getUserMedia as ReturnType<typeof vi.fn>;
|
|
182
|
+
expect(getUserMedia).toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
|
|
168
185
|
it('camera includes the mic by default', async () => {
|
|
169
186
|
const { result } = renderHook(() => useMediaRecorder({ source: 'camera' }));
|
|
170
187
|
await act(async () => {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
2
|
+
import { MarkdownRenderer } from '@bendyline/squisq-react';
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
4
|
+
import type { CodeContext, CodeContextSection } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One context section rendered inside a Monaco view zone: a single-line
|
|
8
|
+
* disclosure strip, plus the full markdown body while expanded. The body is
|
|
9
|
+
* rendered lazily — a file with 100 collapsed sections parses 100 one-liners,
|
|
10
|
+
* nothing more.
|
|
11
|
+
*/
|
|
12
|
+
export interface CodeContextSectionViewProps {
|
|
13
|
+
section: Omit<CodeContextSection, 'line'>;
|
|
14
|
+
expanded: boolean;
|
|
15
|
+
onToggle: (id: string) => void;
|
|
16
|
+
linkSchemes?: readonly string[] | undefined;
|
|
17
|
+
onLinkClick?: CodeContext['onLinkClick'] | undefined;
|
|
18
|
+
/** Native `#L<n>` handling: reveal that line in the editor. */
|
|
19
|
+
onRevealLine: (line: number) => void;
|
|
20
|
+
/** Reports the rendered content height so the zone can be resized to fit. */
|
|
21
|
+
onMeasure: (id: string, px: number) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function CodeContextSectionView({
|
|
25
|
+
section,
|
|
26
|
+
expanded,
|
|
27
|
+
onToggle,
|
|
28
|
+
linkSchemes,
|
|
29
|
+
onLinkClick,
|
|
30
|
+
onRevealLine,
|
|
31
|
+
onMeasure,
|
|
32
|
+
}: CodeContextSectionViewProps) {
|
|
33
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
34
|
+
|
|
35
|
+
const stripNodes = useMemo(
|
|
36
|
+
() => parseMarkdown(section.summaryMarkdown).children,
|
|
37
|
+
[section.summaryMarkdown],
|
|
38
|
+
);
|
|
39
|
+
const bodyNodes = useMemo(
|
|
40
|
+
() => (expanded && section.markdown ? parseMarkdown(section.markdown).children : null),
|
|
41
|
+
[expanded, section.markdown],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Height feedback loop: report the content's real height whenever it
|
|
45
|
+
// changes. Monaco display:none's offscreen zones and ResizeObserver reports
|
|
46
|
+
// 0×0 for those — ignore zeros so scrolled-away zones keep their height.
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const el = rootRef.current;
|
|
49
|
+
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
50
|
+
const report = () => {
|
|
51
|
+
const h = el.offsetHeight;
|
|
52
|
+
if (h > 0) onMeasure(section.id, h);
|
|
53
|
+
};
|
|
54
|
+
report();
|
|
55
|
+
const ro = new ResizeObserver(report);
|
|
56
|
+
ro.observe(el);
|
|
57
|
+
return () => ro.disconnect();
|
|
58
|
+
}, [section.id, onMeasure]);
|
|
59
|
+
|
|
60
|
+
// Delegated link interception. `#L<n>` reveals natively; everything else
|
|
61
|
+
// goes to the host callback (returning false opts back into default
|
|
62
|
+
// navigation).
|
|
63
|
+
const handleClick = useCallback(
|
|
64
|
+
(e: React.MouseEvent) => {
|
|
65
|
+
const anchor = (e.target as HTMLElement).closest?.('a');
|
|
66
|
+
if (!anchor) return;
|
|
67
|
+
const href = anchor.getAttribute('href') ?? '';
|
|
68
|
+
const lineMatch = /^#L(\d+)$/.exec(href);
|
|
69
|
+
if (lineMatch) {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
e.stopPropagation();
|
|
72
|
+
onRevealLine(Number(lineMatch[1]));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (onLinkClick) {
|
|
76
|
+
const handled = onLinkClick(href, { sectionId: section.id });
|
|
77
|
+
if (handled !== false) {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
e.stopPropagation();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
[onLinkClick, onRevealLine, section.id],
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Keep Monaco's container-level mousedown handler from hijacking clicks
|
|
87
|
+
// and text selection inside the section.
|
|
88
|
+
const stopMouseDown = useCallback((e: React.MouseEvent) => {
|
|
89
|
+
e.stopPropagation();
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
// Click handler is delegated anchor interception only; keyboard users
|
|
94
|
+
// reach links natively and the strip itself is a real <button>.
|
|
95
|
+
<div
|
|
96
|
+
ref={rootRef}
|
|
97
|
+
className={`squisq-ccx-section${expanded ? ' squisq-ccx-section--expanded' : ''}`}
|
|
98
|
+
onClick={handleClick}
|
|
99
|
+
onMouseDown={stopMouseDown}
|
|
100
|
+
>
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
className="squisq-ccx-strip"
|
|
104
|
+
aria-expanded={expanded}
|
|
105
|
+
onClick={() => onToggle(section.id)}
|
|
106
|
+
>
|
|
107
|
+
<span className="squisq-ccx-chevron" aria-hidden="true">
|
|
108
|
+
{expanded ? '▾' : '▸'}
|
|
109
|
+
</span>
|
|
110
|
+
<span className="squisq-ccx-strip-text">
|
|
111
|
+
<MarkdownRenderer nodes={stripNodes} {...(linkSchemes ? { linkSchemes } : {})} />
|
|
112
|
+
</span>
|
|
113
|
+
</button>
|
|
114
|
+
{expanded &&
|
|
115
|
+
(bodyNodes ? (
|
|
116
|
+
<div className="squisq-ccx-body">
|
|
117
|
+
<MarkdownRenderer nodes={bodyNodes} {...(linkSchemes ? { linkSchemes } : {})} />
|
|
118
|
+
</div>
|
|
119
|
+
) : (
|
|
120
|
+
<div className="squisq-ccx-body squisq-ccx-body--loading">Loading…</div>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|