@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.
Files changed (37) hide show
  1. package/README.md +57 -10
  2. package/dist/index.d.ts +401 -76
  3. package/dist/index.js +1334 -930
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
  6. package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
  7. package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
  8. package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
  9. package/dist/styles/index.css +13867 -0
  10. package/package.json +15 -7
  11. package/src/EditorContext.tsx +22 -16
  12. package/src/EditorShell.tsx +68 -27
  13. package/src/OutlinePanel.tsx +26 -4
  14. package/src/PreviewControls.tsx +338 -141
  15. package/src/PreviewPanel.tsx +15 -9
  16. package/src/RawEditor.tsx +10 -4
  17. package/src/Toolbar.tsx +21 -11
  18. package/src/VersionHistoryPanel.tsx +2 -2
  19. package/src/__tests__/codeContextSectionView.test.tsx +95 -0
  20. package/src/__tests__/codeContextZoneManager.test.ts +127 -0
  21. package/src/__tests__/diffContextSections.test.ts +39 -0
  22. package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
  23. package/src/__tests__/editorShellProps.test.tsx +96 -0
  24. package/src/__tests__/previewControls.test.tsx +70 -0
  25. package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
  26. package/src/__tests__/useMediaRecorder.test.ts +17 -0
  27. package/src/codeContext/CodeContextSectionView.tsx +124 -0
  28. package/src/codeContext/CodeContextZoneManager.ts +149 -0
  29. package/src/codeContext/CodeContextZones.tsx +121 -0
  30. package/src/codeContext/diffContextSections.ts +38 -0
  31. package/src/codeContext/types.ts +75 -0
  32. package/src/index.ts +32 -1
  33. package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
  34. package/src/recorder/hooks/useMediaRecorder.ts +9 -10
  35. package/src/styles/code-context.css +155 -0
  36. package/src/styles/editor.css +149 -3
  37. package/src/styles/index.css +1 -0
@@ -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
- // Page mode renders directly from markdown it doesn't depend on the
112
- // parsed Doc tree or the player preview build, so let it fall through
113
- // even when those aren't ready yet.
114
- if (!previewDoc && activeDisplayMode !== 'page') {
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
- {activeDisplayMode === 'page' ? (
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
- ) : activeDisplayMode === 'linear' ? (
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
- script={previewDoc!}
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
- /** Monaco editor theme (default: 'vs-dark') */
60
- theme?: string;
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
- theme = 'vs',
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[theme] ?? theme;
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
- theme,
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 = 4; // start of "Diagram" (after \n## )
834
+ newCursorOffset = replacement.length;
830
835
  break;
831
836
  }
832
837
  case 'drawing': {
833
838
  replacement = '\n## Drawing {[drawing]}\n';
834
- newCursorOffset = 4; // start of "Drawing"
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 = 4; // start of "Layout" in the parent heading
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, select the placeholder text so user can type over it
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 — only needed when the actions container (which has flex:1
1817
- and already pushes right-side items to the end) isn't rendered. */}
1818
- {(isPreview || isCodeMode) && <div style={{ flex: 1 }} />}
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={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={theme === 'dark' ? 'dark' : 'light'}
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, theme } = useEditorContext();
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 = theme === 'dark' ? 'vs-dark' : 'vs';
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
+ });