@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
package/src/TransitionPicker.tsx
CHANGED
|
@@ -32,7 +32,14 @@ export interface TransitionPickerProps {
|
|
|
32
32
|
onChange: (next: TransitionFields) => void;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
/**
|
|
36
|
+
* DOM id of the portaled transition flyout. Exported so hosts that embed the
|
|
37
|
+
* picker inside their own popovers (e.g. the toolbar overflow menu) can treat
|
|
38
|
+
* clicks inside the flyout as "inside" in their outside-click handling.
|
|
39
|
+
*/
|
|
40
|
+
export const TRANSITION_FLYOUT_PORTAL_ID = 'squisq-transition-flyout-portal';
|
|
41
|
+
|
|
42
|
+
const FLYOUT_ID = TRANSITION_FLYOUT_PORTAL_ID;
|
|
36
43
|
|
|
37
44
|
export function TransitionPicker({ value, onChange }: TransitionPickerProps) {
|
|
38
45
|
const [open, setOpen] = useState(false);
|
|
@@ -115,7 +115,7 @@ function formatBytes(n: number): string {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
export function VersionHistoryPanel() {
|
|
118
|
-
const { versioning, replaceAll, markdownSource,
|
|
118
|
+
const { versioning, replaceAll, markdownSource, colorScheme } = useEditorContext();
|
|
119
119
|
const [open, setOpen] = useState(false);
|
|
120
120
|
const [state, setState] = useState<PanelState>(initialState);
|
|
121
121
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -214,7 +214,7 @@ export function VersionHistoryPanel() {
|
|
|
214
214
|
[markdownSource],
|
|
215
215
|
);
|
|
216
216
|
const hasDiff = state.selected !== null;
|
|
217
|
-
const diffTheme =
|
|
217
|
+
const diffTheme = colorScheme === 'dark' ? 'vs-dark' : 'vs';
|
|
218
218
|
|
|
219
219
|
if (!versioning) return null;
|
|
220
220
|
|
package/src/ViewSwitcher.tsx
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ViewSwitcher
|
|
3
3
|
*
|
|
4
|
-
* Tab bar for switching between
|
|
4
|
+
* Tab bar for switching between Write, Source, and Use editor views.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { useEditorContext, type EditorView } from './EditorContext';
|
|
8
8
|
|
|
9
9
|
const VIEWS: { id: EditorView; label: string; shortLabel?: string; shortcut: string }[] = [
|
|
10
|
-
{ id: '
|
|
11
|
-
{ id: '
|
|
12
|
-
{ id: 'preview', label: '
|
|
10
|
+
{ id: 'wysiwyg', label: 'Write', shortcut: '⌘1' },
|
|
11
|
+
{ id: 'raw', label: 'Source', shortcut: '⌘2' },
|
|
12
|
+
{ id: 'preview', label: 'Use', shortcut: '⌘3' },
|
|
13
13
|
];
|
|
14
14
|
|
|
15
15
|
export interface ViewSwitcherProps {
|
package/src/WysiwygEditor.tsx
CHANGED
|
@@ -22,7 +22,9 @@ import TaskList from '@tiptap/extension-task-list';
|
|
|
22
22
|
import TaskItem from '@tiptap/extension-task-item';
|
|
23
23
|
import Link from '@tiptap/extension-link';
|
|
24
24
|
import Placeholder from '@tiptap/extension-placeholder';
|
|
25
|
-
import { resolveFontFamily, FONT_FALLBACKS } from '@bendyline/squisq/schemas';
|
|
25
|
+
import { resolveFontFamily, FONT_FALLBACKS, VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
|
|
26
|
+
import { DEFAULT_THEME, flattenBlocks, markdownToDoc } from '@bendyline/squisq/doc';
|
|
27
|
+
import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
26
28
|
import { HeadingWithTemplate } from './TemplateAnnotation';
|
|
27
29
|
import { DiagramExtension } from './diagram/DiagramExtension';
|
|
28
30
|
import { SceneBlockExtension } from './scene/SceneBlockExtension';
|
|
@@ -108,6 +110,8 @@ export function WysiwygEditor({
|
|
|
108
110
|
mentionProvider,
|
|
109
111
|
blockTagsVisible,
|
|
110
112
|
themeInheritance,
|
|
113
|
+
colorScheme,
|
|
114
|
+
bumpMediaRevision,
|
|
111
115
|
} = useEditorContext();
|
|
112
116
|
// Custom templates inlined in the active doc's frontmatter + the
|
|
113
117
|
// persist callback that writes a new list back into the source.
|
|
@@ -257,7 +261,7 @@ export function WysiwygEditor({
|
|
|
257
261
|
const imageFiles = filesFromClipboard(clipboard);
|
|
258
262
|
if (imageFiles.length > 0 && mediaProviderRef.current) {
|
|
259
263
|
event.preventDefault();
|
|
260
|
-
uploadAndInsertImages(view, imageFiles, mediaProviderRef.current);
|
|
264
|
+
uploadAndInsertImages(view, imageFiles, mediaProviderRef.current, bumpMediaRevision);
|
|
261
265
|
return true;
|
|
262
266
|
}
|
|
263
267
|
|
|
@@ -326,7 +330,7 @@ export function WysiwygEditor({
|
|
|
326
330
|
|
|
327
331
|
event.preventDefault();
|
|
328
332
|
moveSelectionToDropPoint(view, event);
|
|
329
|
-
uploadAndInsertImages(view, imageFiles, mediaProviderRef.current);
|
|
333
|
+
uploadAndInsertImages(view, imageFiles, mediaProviderRef.current, bumpMediaRevision);
|
|
330
334
|
return true;
|
|
331
335
|
},
|
|
332
336
|
},
|
|
@@ -467,6 +471,29 @@ export function WysiwygEditor({
|
|
|
467
471
|
// a PreviewSettingsProvider in scope).
|
|
468
472
|
const previewSettings = usePreviewSettingsOptional();
|
|
469
473
|
const activeTheme = previewSettings?.activeTheme;
|
|
474
|
+
const badgePreviewSource = useMemo(() => {
|
|
475
|
+
if (!badgeMenu) return undefined;
|
|
476
|
+
try {
|
|
477
|
+
const previewDoc = markdownToDoc(parseMarkdown(editorSource), { autoTemplates: false });
|
|
478
|
+
const block = flattenBlocks(previewDoc.blocks)[badgeMenu.headingIndex];
|
|
479
|
+
if (!block) return undefined;
|
|
480
|
+
return {
|
|
481
|
+
block,
|
|
482
|
+
theme: previewSettings?.activeTheme ?? DEFAULT_THEME,
|
|
483
|
+
viewport: previewSettings?.activeViewport ?? VIEWPORT_PRESETS.landscape,
|
|
484
|
+
basePath: '/',
|
|
485
|
+
mediaProvider,
|
|
486
|
+
};
|
|
487
|
+
} catch {
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
}, [
|
|
491
|
+
badgeMenu,
|
|
492
|
+
editorSource,
|
|
493
|
+
mediaProvider,
|
|
494
|
+
previewSettings?.activeTheme,
|
|
495
|
+
previewSettings?.activeViewport,
|
|
496
|
+
]);
|
|
470
497
|
const themeStyle = useMemo<CSSProperties>(() => {
|
|
471
498
|
if (themeInheritance === 'none' || !activeTheme) return {};
|
|
472
499
|
const out: Record<string, string> = {
|
|
@@ -507,6 +534,7 @@ export function WysiwygEditor({
|
|
|
507
534
|
<TemplateBadgePopover
|
|
508
535
|
anchorRect={badgeMenu.rect}
|
|
509
536
|
value={badgeMenu.template}
|
|
537
|
+
colorScheme={colorScheme}
|
|
510
538
|
recommended={(() => {
|
|
511
539
|
// `headingIndex` is counted within the mounted Tiptap doc, which
|
|
512
540
|
// reflects `editorSource` — the active block's slice in block
|
|
@@ -516,6 +544,7 @@ export function WysiwygEditor({
|
|
|
516
544
|
const profile = profileBlockContents(slice);
|
|
517
545
|
return recommendTemplatesForBlock(profile, TEMPLATE_NAMES).recommended;
|
|
518
546
|
})()}
|
|
547
|
+
previewSource={badgePreviewSource}
|
|
519
548
|
onOpenDesigner={() => {
|
|
520
549
|
setBadgeMenu(null);
|
|
521
550
|
setDesignerState({});
|
|
@@ -546,6 +575,17 @@ export function WysiwygEditor({
|
|
|
546
575
|
});
|
|
547
576
|
editor.view.dispatch(tr);
|
|
548
577
|
}}
|
|
578
|
+
onAnnotationChange={(next) => {
|
|
579
|
+
if (!editor) return;
|
|
580
|
+
const current = editor.state.doc.nodeAt(propsMenu.headingPos);
|
|
581
|
+
if (!current || current.type.name !== 'heading') return;
|
|
582
|
+
const tr = editor.state.tr.setNodeMarkup(propsMenu.headingPos, undefined, {
|
|
583
|
+
...current.attrs,
|
|
584
|
+
dataBlockAttrs: next.blockAttrsInner,
|
|
585
|
+
dataTemplateParams: next.templateParams,
|
|
586
|
+
});
|
|
587
|
+
editor.view.dispatch(tr);
|
|
588
|
+
}}
|
|
549
589
|
onClose={() => setPropsMenu(null)}
|
|
550
590
|
/>
|
|
551
591
|
)}
|
|
@@ -631,6 +671,7 @@ async function uploadAndInsertImages(
|
|
|
631
671
|
view: any,
|
|
632
672
|
files: File[],
|
|
633
673
|
mediaProvider: import('@bendyline/squisq/schemas').MediaProvider,
|
|
674
|
+
onMediaUploaded?: () => void,
|
|
634
675
|
): Promise<void> {
|
|
635
676
|
for (const file of files) {
|
|
636
677
|
try {
|
|
@@ -643,6 +684,7 @@ async function uploadAndInsertImages(
|
|
|
643
684
|
const relativePath = await mediaProvider.addMedia(name, buffer, mimeType);
|
|
644
685
|
const altText = name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
|
|
645
686
|
insertImageNode(view, relativePath, altText);
|
|
687
|
+
onMediaUploaded?.();
|
|
646
688
|
} catch (err) {
|
|
647
689
|
console.error('Failed to upload dropped image:', err);
|
|
648
690
|
}
|
|
@@ -11,8 +11,7 @@ function previewSlides(md: string) {
|
|
|
11
11
|
|
|
12
12
|
describe('buildPreviewDoc transition mapping', () => {
|
|
13
13
|
it('carries an authored transition through to the player slide', () => {
|
|
14
|
-
//
|
|
15
|
-
// into the heading's Pandoc `{…}` attribute block.
|
|
14
|
+
// Legacy Pandoc transition syntax remains supported.
|
|
16
15
|
const md = ['# Intro', '', '# Second {transition=vortex}', '', 'body'].join('\n');
|
|
17
16
|
const slides = previewSlides(md);
|
|
18
17
|
expect(slides[1].transition).toEqual({ type: 'vortex' });
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { fireEvent, render } from '@testing-library/react';
|
|
6
|
+
import { CodeContextSectionView } from '../codeContext/CodeContextSectionView';
|
|
7
|
+
|
|
8
|
+
const noop = () => {};
|
|
9
|
+
|
|
10
|
+
const baseSection = {
|
|
11
|
+
id: 'foo@10',
|
|
12
|
+
summaryMarkdown: '**foo** — does things · ↓2 imported-by',
|
|
13
|
+
markdown: 'Body text with [`a.ts`](gezel-nav:src%2Fa.ts) and [line 4](#L4).',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function renderView(over: Partial<Parameters<typeof CodeContextSectionView>[0]> = {}) {
|
|
17
|
+
const props = {
|
|
18
|
+
section: baseSection,
|
|
19
|
+
expanded: false,
|
|
20
|
+
onToggle: vi.fn(),
|
|
21
|
+
linkSchemes: ['gezel-nav'] as const,
|
|
22
|
+
onLinkClick: vi.fn(),
|
|
23
|
+
onRevealLine: vi.fn(),
|
|
24
|
+
onMeasure: noop,
|
|
25
|
+
...over,
|
|
26
|
+
};
|
|
27
|
+
const utils = render(<CodeContextSectionView {...props} />);
|
|
28
|
+
return { ...utils, props };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('<CodeContextSectionView>', () => {
|
|
32
|
+
it('renders the strip collapsed, body absent (lazy)', () => {
|
|
33
|
+
const { container } = renderView();
|
|
34
|
+
expect(container.querySelector('.squisq-ccx-strip')).toBeTruthy();
|
|
35
|
+
expect(container.textContent).toContain('foo');
|
|
36
|
+
expect(container.querySelector('.squisq-ccx-body')).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('strip click calls onToggle with the section id', () => {
|
|
40
|
+
const { container, props } = renderView();
|
|
41
|
+
fireEvent.click(container.querySelector('.squisq-ccx-strip')!);
|
|
42
|
+
expect(props.onToggle).toHaveBeenCalledWith('foo@10');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('expanded body renders markdown with host-scheme links as real anchors', () => {
|
|
46
|
+
const { container } = renderView({ expanded: true });
|
|
47
|
+
const body = container.querySelector('.squisq-ccx-body')!;
|
|
48
|
+
expect(body.textContent).toContain('Body text');
|
|
49
|
+
const anchors = [...body.querySelectorAll('a')].map((a) => a.getAttribute('href'));
|
|
50
|
+
expect(anchors).toContain('gezel-nav:src%2Fa.ts');
|
|
51
|
+
expect(anchors).toContain('#L4');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('expanded without markdown shows the loading row', () => {
|
|
55
|
+
const { container } = renderView({
|
|
56
|
+
expanded: true,
|
|
57
|
+
section: { ...baseSection, markdown: undefined },
|
|
58
|
+
});
|
|
59
|
+
expect(container.querySelector('.squisq-ccx-body--loading')?.textContent).toBe('Loading…');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('link clicks are intercepted: handled by default, defaulted on false', () => {
|
|
63
|
+
const onLinkClick = vi.fn(() => undefined);
|
|
64
|
+
const { container } = renderView({ expanded: true, onLinkClick });
|
|
65
|
+
const nav = [...container.querySelectorAll('a')].find(
|
|
66
|
+
(a) => a.getAttribute('href') === 'gezel-nav:src%2Fa.ts',
|
|
67
|
+
)!;
|
|
68
|
+
const first = fireEvent.click(nav);
|
|
69
|
+
expect(onLinkClick).toHaveBeenCalledWith('gezel-nav:src%2Fa.ts', { sectionId: 'foo@10' });
|
|
70
|
+
expect(first).toBe(false); // preventDefault was called
|
|
71
|
+
|
|
72
|
+
onLinkClick.mockReturnValue(false as unknown as undefined);
|
|
73
|
+
const second = fireEvent.click(nav);
|
|
74
|
+
expect(second).toBe(true); // host declined — default navigation allowed
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('#L links reveal natively and never reach onLinkClick', () => {
|
|
78
|
+
const onLinkClick = vi.fn();
|
|
79
|
+
const onRevealLine = vi.fn();
|
|
80
|
+
const { container } = renderView({ expanded: true, onLinkClick, onRevealLine });
|
|
81
|
+
const line = [...container.querySelectorAll('a')].find(
|
|
82
|
+
(a) => a.getAttribute('href') === '#L4',
|
|
83
|
+
)!;
|
|
84
|
+
fireEvent.click(line);
|
|
85
|
+
expect(onRevealLine).toHaveBeenCalledWith(4);
|
|
86
|
+
expect(onLinkClick).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('without linkSchemes, custom-scheme links render blocked (no anchor)', () => {
|
|
90
|
+
const { container } = renderView({ expanded: true, linkSchemes: undefined });
|
|
91
|
+
const anchors = [...container.querySelectorAll('a')].map((a) => a.getAttribute('href'));
|
|
92
|
+
expect(anchors).not.toContain('gezel-nav:src%2Fa.ts');
|
|
93
|
+
expect(container.querySelector('.squisq-md-link--blocked')).toBeTruthy();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*
|
|
4
|
+
* Exercises the zone manager against a fake Monaco editor with a recording
|
|
5
|
+
* changeViewZones accessor — batch semantics, delegate mutation on move and
|
|
6
|
+
* height changes, model-change rebuild, and dispose cleanup.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import type { editor as MonacoEditorNs } from 'monaco-editor';
|
|
10
|
+
import { CodeContextZoneManager } from '../codeContext/CodeContextZoneManager';
|
|
11
|
+
|
|
12
|
+
type Zone = MonacoEditorNs.IViewZone & { ordinal?: number };
|
|
13
|
+
|
|
14
|
+
function fakeEditor() {
|
|
15
|
+
const zones = new Map<string, Zone>();
|
|
16
|
+
const layoutCalls: string[] = [];
|
|
17
|
+
let counter = 0;
|
|
18
|
+
let batches = 0;
|
|
19
|
+
let modelCb: (() => void) | null = null;
|
|
20
|
+
const accessor = {
|
|
21
|
+
addZone: (zone: Zone) => {
|
|
22
|
+
const id = `z${++counter}`;
|
|
23
|
+
zones.set(id, zone);
|
|
24
|
+
return id;
|
|
25
|
+
},
|
|
26
|
+
removeZone: (id: string) => {
|
|
27
|
+
zones.delete(id);
|
|
28
|
+
},
|
|
29
|
+
layoutZone: (id: string) => {
|
|
30
|
+
layoutCalls.push(id);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
const editor = {
|
|
34
|
+
changeViewZones: (cb: (a: typeof accessor) => void) => {
|
|
35
|
+
batches++;
|
|
36
|
+
cb(accessor);
|
|
37
|
+
},
|
|
38
|
+
onDidChangeModel: (cb: () => void) => {
|
|
39
|
+
modelCb = cb;
|
|
40
|
+
return { dispose: vi.fn() };
|
|
41
|
+
},
|
|
42
|
+
} as unknown as MonacoEditorNs.IStandaloneCodeEditor;
|
|
43
|
+
return {
|
|
44
|
+
editor,
|
|
45
|
+
zones,
|
|
46
|
+
layoutCalls,
|
|
47
|
+
get batches() {
|
|
48
|
+
return batches;
|
|
49
|
+
},
|
|
50
|
+
fireModelChange: () => modelCb?.(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('CodeContextZoneManager', () => {
|
|
55
|
+
it('creates zones above the anchor line in one batch, with dom nodes', () => {
|
|
56
|
+
const fake = fakeEditor();
|
|
57
|
+
const mgr = new CodeContextZoneManager(fake.editor);
|
|
58
|
+
mgr.sync([
|
|
59
|
+
{ id: 'file', line: 0, ordinal: 0 },
|
|
60
|
+
{ id: 'foo@10', line: 10, ordinal: 1 },
|
|
61
|
+
]);
|
|
62
|
+
expect(fake.batches).toBe(1);
|
|
63
|
+
expect(fake.zones.size).toBe(2);
|
|
64
|
+
const list = [...fake.zones.values()];
|
|
65
|
+
expect(list.map((z) => z.afterLineNumber)).toEqual([0, 9]);
|
|
66
|
+
expect(list.map((z) => z.ordinal)).toEqual([0, 1]);
|
|
67
|
+
expect(list.every((z) => z.suppressMouseDown)).toBe(true);
|
|
68
|
+
expect(mgr.getDomNode('foo@10')?.className).toBe('squisq-ccx-zone');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('sync with no changes performs no batch', () => {
|
|
72
|
+
const fake = fakeEditor();
|
|
73
|
+
const mgr = new CodeContextZoneManager(fake.editor);
|
|
74
|
+
mgr.sync([{ id: 'a', line: 3, ordinal: 1 }]);
|
|
75
|
+
mgr.sync([{ id: 'a', line: 3, ordinal: 1 }]);
|
|
76
|
+
expect(fake.batches).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('moves mutate the delegate and layoutZone — dom node survives', () => {
|
|
80
|
+
const fake = fakeEditor();
|
|
81
|
+
const mgr = new CodeContextZoneManager(fake.editor);
|
|
82
|
+
mgr.sync([{ id: 'a', line: 3, ordinal: 1 }]);
|
|
83
|
+
const nodeBefore = mgr.getDomNode('a');
|
|
84
|
+
mgr.sync([{ id: 'a', line: 8, ordinal: 1 }]);
|
|
85
|
+
expect(fake.zones.size).toBe(1);
|
|
86
|
+
expect([...fake.zones.values()][0]!.afterLineNumber).toBe(7);
|
|
87
|
+
expect(fake.layoutCalls.length).toBe(1);
|
|
88
|
+
expect(mgr.getDomNode('a')).toBe(nodeBefore);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('setHeight mutates heightInPx and lays out; sub-pixel deltas are ignored', () => {
|
|
92
|
+
const fake = fakeEditor();
|
|
93
|
+
const mgr = new CodeContextZoneManager(fake.editor);
|
|
94
|
+
mgr.sync([{ id: 'a', line: 3, ordinal: 1 }]);
|
|
95
|
+
mgr.setHeight('a', 120);
|
|
96
|
+
expect([...fake.zones.values()][0]!.heightInPx).toBe(120);
|
|
97
|
+
expect(fake.layoutCalls.length).toBe(1);
|
|
98
|
+
mgr.setHeight('a', 119.5); // ceils back to 120 — no relayout
|
|
99
|
+
expect(fake.layoutCalls.length).toBe(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('a model change drops bookkeeping; the next sync recreates zones', () => {
|
|
103
|
+
const fake = fakeEditor();
|
|
104
|
+
const mgr = new CodeContextZoneManager(fake.editor);
|
|
105
|
+
const onChange = vi.fn();
|
|
106
|
+
mgr.onDidChangeZones(onChange);
|
|
107
|
+
mgr.sync([{ id: 'a', line: 3, ordinal: 1 }]);
|
|
108
|
+
fake.fireModelChange();
|
|
109
|
+
expect(onChange).toHaveBeenCalledTimes(2); // sync + model change
|
|
110
|
+
expect(mgr.getDomNode('a')).toBeUndefined();
|
|
111
|
+
mgr.sync([{ id: 'a', line: 3, ordinal: 1 }]);
|
|
112
|
+
expect(mgr.getDomNode('a')).toBeDefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('dispose removes every zone and goes inert', () => {
|
|
116
|
+
const fake = fakeEditor();
|
|
117
|
+
const mgr = new CodeContextZoneManager(fake.editor);
|
|
118
|
+
mgr.sync([
|
|
119
|
+
{ id: 'a', line: 1, ordinal: 1 },
|
|
120
|
+
{ id: 'b', line: 2, ordinal: 2 },
|
|
121
|
+
]);
|
|
122
|
+
mgr.dispose();
|
|
123
|
+
expect(fake.zones.size).toBe(0);
|
|
124
|
+
mgr.sync([{ id: 'c', line: 3, ordinal: 1 }]);
|
|
125
|
+
expect(fake.zones.size).toBe(0);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { diffContextSections, type ZoneSpec } from '../codeContext/diffContextSections';
|
|
3
|
+
|
|
4
|
+
const spec = (id: string, line: number, ordinal = 0): ZoneSpec => ({ id, line, ordinal });
|
|
5
|
+
|
|
6
|
+
describe('diffContextSections', () => {
|
|
7
|
+
it('adds everything on first sync', () => {
|
|
8
|
+
const d = diffContextSections([], [spec('a', 1), spec('b', 5)]);
|
|
9
|
+
expect(d.add.map((z) => z.id)).toEqual(['a', 'b']);
|
|
10
|
+
expect(d.remove).toEqual([]);
|
|
11
|
+
expect(d.move).toEqual([]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('is a no-op for identical specs', () => {
|
|
15
|
+
const specs = [spec('a', 1, 1), spec('b', 5, 2)];
|
|
16
|
+
const d = diffContextSections(
|
|
17
|
+
specs,
|
|
18
|
+
specs.map((s) => ({ ...s })),
|
|
19
|
+
);
|
|
20
|
+
expect(d.add).toEqual([]);
|
|
21
|
+
expect(d.remove).toEqual([]);
|
|
22
|
+
expect(d.move).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('moves when a line or ordinal changes, removes vanished ids', () => {
|
|
26
|
+
const d = diffContextSections(
|
|
27
|
+
[spec('a', 1, 1), spec('b', 5, 2), spec('c', 9, 3)],
|
|
28
|
+
[spec('a', 2, 1), spec('b', 5, 4)],
|
|
29
|
+
);
|
|
30
|
+
expect(d.move.map((z) => z.id)).toEqual(['a', 'b']);
|
|
31
|
+
expect(d.remove).toEqual(['c']);
|
|
32
|
+
expect(d.add).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('duplicate ids in next: first occurrence wins, no double-add', () => {
|
|
36
|
+
const d = diffContextSections([], [spec('a', 1), spec('a', 7)]);
|
|
37
|
+
expect(d.add).toEqual([spec('a', 1)]);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*
|
|
4
|
+
* Prop-contract test for `codeContext`: the shell threads it to the
|
|
5
|
+
* CodeContextZones bridge in code mode only (mirrors the submitOnEnter
|
|
6
|
+
* threading tests). Heavy surfaces are stubbed.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
9
|
+
import { render } from '@testing-library/react';
|
|
10
|
+
import type { CodeContext } from '../codeContext/types';
|
|
11
|
+
|
|
12
|
+
const zoneOptions: CodeContext[] = [];
|
|
13
|
+
|
|
14
|
+
vi.mock('../RawEditor', () => ({
|
|
15
|
+
RawEditor: () => <div data-testid="raw-editor-stub" />,
|
|
16
|
+
}));
|
|
17
|
+
vi.mock('../WysiwygEditor', () => ({
|
|
18
|
+
WysiwygEditor: () => <div data-testid="wysiwyg-editor-stub" />,
|
|
19
|
+
}));
|
|
20
|
+
vi.mock('../PreviewPanel', () => ({
|
|
21
|
+
PreviewPanel: () => <div data-testid="preview-stub" />,
|
|
22
|
+
}));
|
|
23
|
+
vi.mock('../codeContext/CodeContextZones', () => ({
|
|
24
|
+
CodeContextZones: ({ options }: { options: CodeContext }) => {
|
|
25
|
+
zoneOptions.push(options);
|
|
26
|
+
return <div data-testid="code-context-stub" />;
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import { EditorShell } from '../EditorShell';
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
zoneOptions.length = 0;
|
|
34
|
+
if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') {
|
|
35
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
36
|
+
configurable: true,
|
|
37
|
+
value: (query: string) => ({
|
|
38
|
+
matches: false,
|
|
39
|
+
media: query,
|
|
40
|
+
onchange: null,
|
|
41
|
+
addListener: vi.fn(),
|
|
42
|
+
removeListener: vi.fn(),
|
|
43
|
+
addEventListener: vi.fn(),
|
|
44
|
+
removeEventListener: vi.fn(),
|
|
45
|
+
dispatchEvent: vi.fn(() => false),
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (typeof globalThis.ResizeObserver === 'undefined') {
|
|
50
|
+
class ResizeObserverStub {
|
|
51
|
+
observe(): void {}
|
|
52
|
+
unobserve(): void {}
|
|
53
|
+
disconnect(): void {}
|
|
54
|
+
}
|
|
55
|
+
(globalThis as unknown as { ResizeObserver: typeof ResizeObserverStub }).ResizeObserver =
|
|
56
|
+
ResizeObserverStub;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const codeContext: CodeContext = {
|
|
61
|
+
sections: [{ id: 'foo@2', line: 2, summaryMarkdown: '**foo**', markdown: 'body' }],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
describe('<EditorShell> codeContext prop', () => {
|
|
65
|
+
it('mounts CodeContextZones with the options in code mode', () => {
|
|
66
|
+
const { queryByTestId } = render(
|
|
67
|
+
<EditorShell initialMarkdown="const x = 1;" fileName="a.ts" codeContext={codeContext} />,
|
|
68
|
+
);
|
|
69
|
+
expect(queryByTestId('code-context-stub')).toBeTruthy();
|
|
70
|
+
expect(zoneOptions[0]).toBe(codeContext);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('does not mount without the prop', () => {
|
|
74
|
+
const { queryByTestId } = render(
|
|
75
|
+
<EditorShell initialMarkdown="const x = 1;" fileName="a.ts" />,
|
|
76
|
+
);
|
|
77
|
+
expect(queryByTestId('code-context-stub')).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('ignores codeContext in markdown mode', () => {
|
|
81
|
+
const { queryByTestId } = render(
|
|
82
|
+
<EditorShell initialMarkdown="# hi" fileName="a.md" codeContext={codeContext} />,
|
|
83
|
+
);
|
|
84
|
+
expect(queryByTestId('code-context-stub')).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
});
|