@bendyline/squisq-editor-react 1.6.0 → 1.6.1
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 +401 -76
- package/dist/index.js +1334 -930
- 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 +13867 -0
- package/package.json +15 -7
- package/src/EditorContext.tsx +22 -16
- package/src/EditorShell.tsx +68 -27
- package/src/OutlinePanel.tsx +26 -4
- package/src/PreviewControls.tsx +338 -141
- package/src/PreviewPanel.tsx +15 -9
- package/src/RawEditor.tsx +10 -4
- package/src/Toolbar.tsx +21 -11
- package/src/VersionHistoryPanel.tsx +2 -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 +96 -0
- package/src/__tests__/previewControls.test.tsx +70 -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/index.ts +32 -1
- package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
- package/src/recorder/hooks/useMediaRecorder.ts +9 -10
- package/src/styles/code-context.css +155 -0
- package/src/styles/editor.css +149 -3
- package/src/styles/index.css +1 -0
package/src/PreviewPanel.tsx
CHANGED
|
@@ -48,6 +48,7 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
|
|
|
48
48
|
activeTheme,
|
|
49
49
|
activeTransformStyle,
|
|
50
50
|
activeCaptionStyle,
|
|
51
|
+
activeCaptionsEnabled,
|
|
51
52
|
} = usePreviewSettings();
|
|
52
53
|
|
|
53
54
|
// Build the player-ready Doc whenever the parsed doc changes.
|
|
@@ -108,10 +109,15 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
|
|
|
108
109
|
);
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
// The public DisplayMode values predate the current labels: raw `page`
|
|
113
|
+
// is the plain Document preview, while raw `linear` is the styled Page view.
|
|
114
|
+
const isDocumentMode = activeDisplayMode === 'page';
|
|
115
|
+
const isPageMode = activeDisplayMode === 'linear';
|
|
116
|
+
|
|
117
|
+
// Document mode renders directly from markdown — it doesn't depend on the
|
|
118
|
+
// parsed Doc tree or the player preview build, so let it fall through even
|
|
119
|
+
// when those aren't ready yet.
|
|
120
|
+
if (!previewDoc && !isDocumentMode) {
|
|
115
121
|
return (
|
|
116
122
|
<div className={`squisq-preview-status ${className || ''}`} data-testid="preview-panel">
|
|
117
123
|
<p>No content to preview. Start typing in the editor.</p>
|
|
@@ -119,8 +125,7 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
|
|
|
119
125
|
);
|
|
120
126
|
}
|
|
121
127
|
|
|
122
|
-
const fillsContainer =
|
|
123
|
-
activeDisplayMode === 'linear' || activeDisplayMode === 'page' ? 'stretch' : 'center';
|
|
128
|
+
const fillsContainer = isDocumentMode || isPageMode ? 'stretch' : 'center';
|
|
124
129
|
|
|
125
130
|
return (
|
|
126
131
|
<div
|
|
@@ -147,7 +152,7 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
|
|
|
147
152
|
minHeight: 0,
|
|
148
153
|
}}
|
|
149
154
|
>
|
|
150
|
-
{
|
|
155
|
+
{isDocumentMode ? (
|
|
151
156
|
<PlainHtmlPreview
|
|
152
157
|
markdown={markdownSource}
|
|
153
158
|
title={(doc?.frontmatter?.title as string | undefined) ?? undefined}
|
|
@@ -155,7 +160,7 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
|
|
|
155
160
|
mediaRevision={mediaRevision}
|
|
156
161
|
theme={activeTheme}
|
|
157
162
|
/>
|
|
158
|
-
) :
|
|
163
|
+
) : isPageMode ? (
|
|
159
164
|
<LinearDocView
|
|
160
165
|
doc={doc!}
|
|
161
166
|
basePath={basePath}
|
|
@@ -164,7 +169,7 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
|
|
|
164
169
|
/>
|
|
165
170
|
) : (
|
|
166
171
|
<DocPlayer
|
|
167
|
-
|
|
172
|
+
doc={previewDoc!}
|
|
168
173
|
basePath={basePath}
|
|
169
174
|
showControls
|
|
170
175
|
muted
|
|
@@ -172,6 +177,7 @@ export function PreviewPanel({ basePath = '/', className, workspaceContainer }:
|
|
|
172
177
|
displayMode={activeDisplayMode}
|
|
173
178
|
theme={activeTheme}
|
|
174
179
|
captionStyle={activeCaptionStyle}
|
|
180
|
+
captionsEnabled={activeCaptionsEnabled}
|
|
175
181
|
/>
|
|
176
182
|
)}
|
|
177
183
|
</div>
|
package/src/RawEditor.tsx
CHANGED
|
@@ -56,8 +56,14 @@ const SQUISQ_THEMES: Record<string, string> = {
|
|
|
56
56
|
};
|
|
57
57
|
|
|
58
58
|
export interface RawEditorProps {
|
|
59
|
-
/**
|
|
60
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Monaco editor theme name (default: `'vs'`). Accepts Monaco's built-in
|
|
61
|
+
* theme ids (`'vs'`, `'vs-dark'`, `'hc-black'`) — which are transparently
|
|
62
|
+
* mapped to the Squisq-tinted variants — or any custom theme registered
|
|
63
|
+
* via `monaco.editor.defineTheme`. This is the *code editor* color
|
|
64
|
+
* theme, distinct from the shell's light/dark `colorScheme`.
|
|
65
|
+
*/
|
|
66
|
+
monacoTheme?: string;
|
|
61
67
|
/** Show minimap (default: false) */
|
|
62
68
|
minimap?: boolean;
|
|
63
69
|
/** Font size in pixels (default: 14) */
|
|
@@ -80,7 +86,7 @@ export interface RawEditorProps {
|
|
|
80
86
|
* Binds to the shared EditorContext for source synchronization.
|
|
81
87
|
*/
|
|
82
88
|
export function RawEditor({
|
|
83
|
-
|
|
89
|
+
monacoTheme = 'vs',
|
|
84
90
|
minimap = false,
|
|
85
91
|
fontSize = 14,
|
|
86
92
|
wordWrap = 'on',
|
|
@@ -640,7 +646,7 @@ export function RawEditor({
|
|
|
640
646
|
}
|
|
641
647
|
}, [editorSource, language, monacoNs]);
|
|
642
648
|
|
|
643
|
-
const effectiveTheme = SQUISQ_THEMES[
|
|
649
|
+
const effectiveTheme = SQUISQ_THEMES[monacoTheme] ?? monacoTheme;
|
|
644
650
|
|
|
645
651
|
// Wait for the lazy monaco namespace + `loader.config()` to settle
|
|
646
652
|
// before mounting `<Editor>`. Without this gate, the @monaco-editor/
|
package/src/Toolbar.tsx
CHANGED
|
@@ -52,6 +52,10 @@ export interface ToolbarProps {
|
|
|
52
52
|
onToggleFiles?: () => void;
|
|
53
53
|
/** Content rendered at the left edge of the toolbar, before the view tabs. */
|
|
54
54
|
slotLeft?: ReactNode;
|
|
55
|
+
/** Content rendered immediately after the view tabs, on the left side of the
|
|
56
|
+
* toolbar (before the formatting controls). Used for the preview mode
|
|
57
|
+
* switch in Play view. */
|
|
58
|
+
slotAfterTabs?: ReactNode;
|
|
55
59
|
/** Content rendered after the formatting controls (in the middle area). */
|
|
56
60
|
slotAfterActions?: ReactNode;
|
|
57
61
|
/** Content rendered at the rightmost end of the toolbar, after all other elements. */
|
|
@@ -371,6 +375,7 @@ export function Toolbar({
|
|
|
371
375
|
showFiles,
|
|
372
376
|
onToggleFiles,
|
|
373
377
|
slotLeft,
|
|
378
|
+
slotAfterTabs,
|
|
374
379
|
slotAfterActions,
|
|
375
380
|
slotRight,
|
|
376
381
|
showPlayTab = true,
|
|
@@ -388,7 +393,7 @@ export function Toolbar({
|
|
|
388
393
|
versioning,
|
|
389
394
|
allowRecording,
|
|
390
395
|
documentLinkProvider,
|
|
391
|
-
|
|
396
|
+
colorScheme,
|
|
392
397
|
} = useEditorContext();
|
|
393
398
|
// When a canvas textbox is being edited, its Tiptap instance takes over
|
|
394
399
|
// the formatting buttons; otherwise they drive the document editor. The
|
|
@@ -826,18 +831,18 @@ export function Toolbar({
|
|
|
826
831
|
// A diagram is just a heading with the `{[diagram]}` template
|
|
827
832
|
// annotation; the WYSIWYG view renders its editable canvas.
|
|
828
833
|
replacement = '\n## Diagram {[diagram]}\n';
|
|
829
|
-
newCursorOffset =
|
|
834
|
+
newCursorOffset = replacement.length;
|
|
830
835
|
break;
|
|
831
836
|
}
|
|
832
837
|
case 'drawing': {
|
|
833
838
|
replacement = '\n## Drawing {[drawing]}\n';
|
|
834
|
-
newCursorOffset =
|
|
839
|
+
newCursorOffset = replacement.length;
|
|
835
840
|
break;
|
|
836
841
|
}
|
|
837
842
|
case 'layout': {
|
|
838
843
|
// Seed a starter text layer (a child sub-block) so it isn't blank.
|
|
839
844
|
replacement = LAYOUT_STARTER_MARKDOWN;
|
|
840
|
-
newCursorOffset =
|
|
845
|
+
newCursorOffset = replacement.length;
|
|
841
846
|
break;
|
|
842
847
|
}
|
|
843
848
|
}
|
|
@@ -846,13 +851,13 @@ export function Toolbar({
|
|
|
846
851
|
const range = selection;
|
|
847
852
|
monacoEditor.executeEdits('toolbar', [{ range, text: replacement }]);
|
|
848
853
|
|
|
849
|
-
// If no selection,
|
|
854
|
+
// If no selection, move the cursor to the command's preferred edit point.
|
|
850
855
|
if (!hasSelection && newCursorOffset > 0) {
|
|
851
856
|
const startPos = model.getPositionAt(
|
|
852
857
|
model.getOffsetAt(range.getStartPosition()) + newCursorOffset,
|
|
853
858
|
);
|
|
854
|
-
// Just place cursor after the prefix
|
|
855
859
|
monacoEditor.setPosition(startPos);
|
|
860
|
+
monacoEditor.revealPositionInCenterIfOutsideViewport(startPos);
|
|
856
861
|
}
|
|
857
862
|
|
|
858
863
|
monacoEditor.focus();
|
|
@@ -1436,6 +1441,8 @@ export function Toolbar({
|
|
|
1436
1441
|
))}
|
|
1437
1442
|
</div>
|
|
1438
1443
|
)}
|
|
1444
|
+
{/* After-tabs slot — left side, before formatting controls (preview mode switch) */}
|
|
1445
|
+
{slotAfterTabs}
|
|
1439
1446
|
{/* Formatting buttons — hidden in preview mode and code mode */}
|
|
1440
1447
|
{!isPreview && !isCodeMode && (
|
|
1441
1448
|
<div className="squisq-toolbar-actions" ref={actionsRef}>
|
|
@@ -1813,9 +1820,12 @@ export function Toolbar({
|
|
|
1813
1820
|
|
|
1814
1821
|
{/* After-actions slot — after formatting controls */}
|
|
1815
1822
|
{slotAfterActions}
|
|
1816
|
-
{/* Spacer —
|
|
1817
|
-
|
|
1818
|
-
|
|
1823
|
+
{/* Spacer — pushes right-side items to the end when the flex:1 actions
|
|
1824
|
+
container isn't rendered. In preview mode PreviewToolbarControls
|
|
1825
|
+
supplies its own flex:1 filler (and measures that leftover width to
|
|
1826
|
+
decide whether to collapse), so a second spacer here would split the
|
|
1827
|
+
slack and make the controls collapse too early. */}
|
|
1828
|
+
{isCodeMode && <div style={{ flex: 1 }} />}
|
|
1819
1829
|
{/* Version history — renders only when the host enabled versioning
|
|
1820
1830
|
and a container is wired up. The component owns its own button
|
|
1821
1831
|
and popover; we just give it a slot in the toolbar. */}
|
|
@@ -1897,7 +1907,7 @@ export function Toolbar({
|
|
|
1897
1907
|
<div
|
|
1898
1908
|
ref={insertMenuRef}
|
|
1899
1909
|
className="squisq-insert-menu"
|
|
1900
|
-
data-theme={
|
|
1910
|
+
data-theme={colorScheme}
|
|
1901
1911
|
style={{ position: 'fixed', top: insertMenuAnchor.top, left: insertMenuAnchor.left }}
|
|
1902
1912
|
role="menu"
|
|
1903
1913
|
>
|
|
@@ -1937,7 +1947,7 @@ export function Toolbar({
|
|
|
1937
1947
|
onSelect={handleEmojiSelect}
|
|
1938
1948
|
onClose={closeEmojiPicker}
|
|
1939
1949
|
anchorRef={emojiButtonRef as React.RefObject<HTMLElement>}
|
|
1940
|
-
theme={
|
|
1950
|
+
theme={colorScheme === 'dark' ? 'dark' : 'light'}
|
|
1941
1951
|
style={{
|
|
1942
1952
|
position: 'fixed',
|
|
1943
1953
|
top: emojiPickerAnchor.top,
|
|
@@ -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
|
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*
|
|
4
|
+
* Prop-contract tests for the v1.5 naming renames:
|
|
5
|
+
* - `<EditorShell>`'s light/dark chrome prop is `colorScheme` (was
|
|
6
|
+
* `theme`), and it drives the `data-theme` attribute on the shell root.
|
|
7
|
+
* - `<RawEditor>`'s Monaco theme-string prop is `monacoTheme` (was
|
|
8
|
+
* `theme`); the shell maps `colorScheme` → `monacoTheme` (`'dark'` →
|
|
9
|
+
* `'vs-dark'`, `'light'` → `'vs'`).
|
|
10
|
+
*
|
|
11
|
+
* The heavy editing surfaces are stubbed so the shell mounts under jsdom
|
|
12
|
+
* without dragging in monaco-editor or Tiptap. The RawEditor stub records
|
|
13
|
+
* the props it receives so we can assert `monacoTheme` reaches it.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
16
|
+
import { render, screen } from '@testing-library/react';
|
|
17
|
+
import type { RawEditorProps } from '../RawEditor';
|
|
18
|
+
|
|
19
|
+
// Records the props the shell passes to RawEditor on each render.
|
|
20
|
+
const rawEditorProps: RawEditorProps[] = [];
|
|
21
|
+
|
|
22
|
+
vi.mock('../RawEditor', () => ({
|
|
23
|
+
RawEditor: (props: RawEditorProps) => {
|
|
24
|
+
rawEditorProps.push(props);
|
|
25
|
+
return <div data-testid="raw-editor-stub" />;
|
|
26
|
+
},
|
|
27
|
+
}));
|
|
28
|
+
vi.mock('../WysiwygEditor', () => ({
|
|
29
|
+
WysiwygEditor: () => <div data-testid="wysiwyg-editor-stub" />,
|
|
30
|
+
}));
|
|
31
|
+
vi.mock('../PreviewPanel', () => ({
|
|
32
|
+
PreviewPanel: () => <div data-testid="preview-stub" />,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
import { EditorShell } from '../EditorShell';
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
rawEditorProps.length = 0;
|
|
39
|
+
// jsdom lacks matchMedia / ResizeObserver, which the Toolbar uses.
|
|
40
|
+
if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') {
|
|
41
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
42
|
+
configurable: true,
|
|
43
|
+
value: (query: string) => ({
|
|
44
|
+
matches: false,
|
|
45
|
+
media: query,
|
|
46
|
+
onchange: null,
|
|
47
|
+
addListener: vi.fn(),
|
|
48
|
+
removeListener: vi.fn(),
|
|
49
|
+
addEventListener: vi.fn(),
|
|
50
|
+
removeEventListener: vi.fn(),
|
|
51
|
+
dispatchEvent: vi.fn(() => false),
|
|
52
|
+
}),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (typeof globalThis.ResizeObserver === 'undefined') {
|
|
56
|
+
class ResizeObserverStub {
|
|
57
|
+
observe(): void {}
|
|
58
|
+
unobserve(): void {}
|
|
59
|
+
disconnect(): void {}
|
|
60
|
+
}
|
|
61
|
+
(globalThis as unknown as { ResizeObserver: typeof ResizeObserverStub }).ResizeObserver =
|
|
62
|
+
ResizeObserverStub;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('<EditorShell> colorScheme prop', () => {
|
|
67
|
+
it('applies dark chrome via data-theme when colorScheme="dark"', () => {
|
|
68
|
+
const { container } = render(
|
|
69
|
+
<EditorShell initialMarkdown="# hi" initialView="raw" colorScheme="dark" />,
|
|
70
|
+
);
|
|
71
|
+
const shell = container.querySelector('.squisq-editor-shell');
|
|
72
|
+
expect(shell?.getAttribute('data-theme')).toBe('dark');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('defaults to light chrome when colorScheme is omitted', () => {
|
|
76
|
+
const { container } = render(<EditorShell initialMarkdown="# hi" initialView="raw" />);
|
|
77
|
+
const shell = container.querySelector('.squisq-editor-shell');
|
|
78
|
+
expect(shell?.getAttribute('data-theme')).toBe('light');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('RawEditor monacoTheme prop', () => {
|
|
83
|
+
it('maps colorScheme="dark" to monacoTheme="vs-dark"', () => {
|
|
84
|
+
render(<EditorShell initialMarkdown="# hi" initialView="raw" colorScheme="dark" />);
|
|
85
|
+
expect(screen.getByTestId('raw-editor-stub')).toBeTruthy();
|
|
86
|
+
const last = rawEditorProps[rawEditorProps.length - 1];
|
|
87
|
+
expect(last?.monacoTheme).toBe('vs-dark');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('maps colorScheme="light" to monacoTheme="vs"', () => {
|
|
91
|
+
render(<EditorShell initialMarkdown="# hi" initialView="raw" colorScheme="light" />);
|
|
92
|
+
expect(screen.getByTestId('raw-editor-stub')).toBeTruthy();
|
|
93
|
+
const last = rawEditorProps[rawEditorProps.length - 1];
|
|
94
|
+
expect(last?.monacoTheme).toBe('vs');
|
|
95
|
+
});
|
|
96
|
+
});
|