@bendyline/squisq-editor-react 2.0.0 → 2.1.0

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 (110) hide show
  1. package/README.md +10 -2
  2. package/dist/index.d.ts +579 -47
  3. package/dist/index.js +7477 -2943
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/index.css +1008 -38
  6. package/package.json +6 -5
  7. package/src/DocumentSettingsDialog.tsx +32 -18
  8. package/src/EditorContext.tsx +27 -0
  9. package/src/EditorShell.tsx +25 -1
  10. package/src/PreviewControls.tsx +77 -29
  11. package/src/PreviewPanel.tsx +5 -1
  12. package/src/RawEditor.tsx +12 -0
  13. package/src/RecorderEntry.tsx +5 -0
  14. package/src/Toolbar.tsx +479 -36
  15. package/src/WysiwygEditor.tsx +25 -11
  16. package/src/__tests__/buildPreviewDocContent.test.ts +17 -0
  17. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  18. package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
  19. package/src/__tests__/editorShellProps.test.tsx +65 -0
  20. package/src/__tests__/findMode.test.tsx +104 -0
  21. package/src/__tests__/findModel.test.ts +55 -0
  22. package/src/__tests__/markdownCodeFence.test.ts +45 -0
  23. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  24. package/src/__tests__/mediaReferences.test.ts +15 -0
  25. package/src/__tests__/previewControls.test.tsx +195 -0
  26. package/src/__tests__/recorderTheme.test.tsx +42 -0
  27. package/src/__tests__/selectionConversions.test.ts +80 -0
  28. package/src/__tests__/tiptapBridge.test.ts +57 -7
  29. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  30. package/src/__tests__/toolbarSelectionConversion.test.tsx +190 -0
  31. package/src/__tests__/writeCanvasSettings.test.ts +15 -0
  32. package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
  33. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
  34. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
  35. package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
  36. package/src/asciiDiagram/asciiDiagramData.ts +33 -0
  37. package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
  38. package/src/buildPreviewDoc.ts +9 -7
  39. package/src/codeContext/types.ts +1 -1
  40. package/src/codeSnippet/CodeSnippetExtension.ts +205 -0
  41. package/src/codeSnippet/CodeSnippetWidget.tsx +109 -0
  42. package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +95 -0
  43. package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +50 -0
  44. package/src/codeSnippet/codeSnippetCommands.ts +21 -0
  45. package/src/codeSnippet/codeSnippetData.ts +43 -0
  46. package/src/codeSnippet/codeSnippetLanguages.ts +216 -0
  47. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  48. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  49. package/src/diagram/DiagramCanvas.tsx +17 -2
  50. package/src/find/FindHighlightExtension.ts +81 -0
  51. package/src/find/FindToolbar.tsx +314 -0
  52. package/src/find/findModel.ts +77 -0
  53. package/src/frontmatterSettings.ts +23 -0
  54. package/src/index.ts +96 -1
  55. package/src/markdownCodeFence.ts +72 -0
  56. package/src/mediaReferences.ts +5 -3
  57. package/src/mermaid/MermaidDiagramCanvas.tsx +693 -0
  58. package/src/mermaid/MermaidDiagramExtension.ts +243 -0
  59. package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +184 -0
  60. package/src/mermaid/MermaidDiagramWidget.tsx +653 -0
  61. package/src/mermaid/MermaidShapePalette.tsx +245 -0
  62. package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +361 -0
  63. package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +35 -0
  64. package/src/mermaid/__tests__/mermaidRenderer.test.ts +49 -0
  65. package/src/mermaid/__tests__/mermaidSourceOps.test.ts +213 -0
  66. package/src/mermaid/__tests__/mermaidSyntax.test.ts +71 -0
  67. package/src/mermaid/mermaidCommands.ts +34 -0
  68. package/src/mermaid/mermaidData.ts +31 -0
  69. package/src/mermaid/mermaidDiagramTypes.ts +325 -0
  70. package/src/mermaid/mermaidModel.ts +31 -0
  71. package/src/mermaid/mermaidRenderer.ts +181 -0
  72. package/src/mermaid/mermaidShapes.ts +113 -0
  73. package/src/mermaid/mermaidSourceOps.ts +454 -0
  74. package/src/recorder/RecorderButton.tsx +9 -1
  75. package/src/recorder/RecorderModal.tsx +84 -41
  76. package/src/recorder/RecorderPanel.tsx +9 -1
  77. package/src/scene/Scene.tsx +46 -15
  78. package/src/scene/SceneBlockToolbar.tsx +29 -14
  79. package/src/scene/SceneViewControls.tsx +43 -0
  80. package/src/scene/__tests__/fitScale.test.ts +19 -0
  81. package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
  82. package/src/scene/__tests__/useScenePanZoom.test.ts +28 -1
  83. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  84. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  85. package/src/scene/commands/SceneCommand.ts +13 -2
  86. package/src/scene/fitScale.ts +17 -0
  87. package/src/scene/hooks/useScenePanZoom.ts +11 -4
  88. package/src/scene/scene.css +85 -3
  89. package/src/scene/tools/SelectTool.ts +5 -5
  90. package/src/selectionConversions.ts +155 -0
  91. package/src/styles/ascii-timeline.css +101 -4
  92. package/src/styles/code-snippet.css +76 -0
  93. package/src/styles/editor.css +352 -2
  94. package/src/styles/index.css +2 -0
  95. package/src/styles/mermaid-diagram.css +487 -0
  96. package/src/styles/tree-view.css +51 -1
  97. package/src/timeline/TimelineEditorWidget.tsx +200 -41
  98. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
  99. package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
  100. package/src/timeline/__tests__/timelineOps.test.ts +55 -0
  101. package/src/timeline/timelineCommands.ts +54 -0
  102. package/src/timeline/timelineOps.ts +107 -3
  103. package/src/tiptapBridge.ts +23 -5
  104. package/src/treeview/TreeOutlineWidget.tsx +153 -3
  105. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  106. package/src/treeview/__tests__/treeOps.test.ts +52 -0
  107. package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
  108. package/src/treeview/treeOps.ts +59 -0
  109. package/src/treeview/treeViewCommands.ts +5 -0
  110. package/src/writeCanvasSettings.ts +30 -0
@@ -32,6 +32,8 @@ import { AsciiDiagramExtension } from './asciiDiagram/AsciiDiagramExtension';
32
32
  import { RepairableDiagramExtension } from './asciiDiagram/RepairableDiagramExtension';
33
33
  import { applyRepairCommand } from './asciiDiagram/asciiDiagramCommands';
34
34
  import { shouldPasteAsAsciiFence } from './asciiDiagram/asciiPaste';
35
+ import { MermaidDiagramExtension } from './mermaid/MermaidDiagramExtension';
36
+ import { CodeSnippetExtension } from './codeSnippet/CodeSnippetExtension';
35
37
  import { TreeViewExtension } from './treeview/TreeViewExtension';
36
38
  import { shouldPasteAsTreeFence } from './treeview/treePaste';
37
39
  import { TimelineViewExtension } from './timeline/TimelineViewExtension';
@@ -62,6 +64,8 @@ import { looksLikeMarkdown } from './detectMarkdown';
62
64
  import { SQUISQ_MEDIA_MIME, parseSquisqMediaPayload } from './mediaDragMime';
63
65
  import { usePreviewSettingsOptional } from './PreviewControls';
64
66
  import { uploadAndInsertImages } from './wysiwygImageUpload';
67
+ import { writeCanvasSettingsStyle, type WriteCanvasSettings } from './writeCanvasSettings';
68
+ import { FindHighlightExtension } from './find/FindHighlightExtension';
65
69
 
66
70
  type ImageMutationView = Pick<ProseMirrorView, 'state' | 'dispatch'>;
67
71
 
@@ -71,16 +75,14 @@ type ImageMutationView = Pick<ProseMirrorView, 'state' | 'dispatch'>;
71
75
  * `placeholder` prop with a fixed string.
72
76
  */
73
77
  const EMPTY_PROMPTS = [
74
- 'Start typing your content, or drop images on top of me',
75
- 'Write anything paste markdown, drag in images, or just start typing',
76
- 'Type away. Markdown syntax works too',
77
- 'Chapter 1 begins here',
78
- 'Once upon a time',
79
- 'A blank page. Exciting, isn\u2019t it?',
80
- 'The first word is always the hardest',
81
- 'Plot twist: this is where it all starts',
82
- 'Write something the future you will thank you for…',
83
- 'Begin at the beginning…',
78
+ 'Start typing your content, or drop images on top of me...',
79
+ 'Write anything -- paste markdown, drag in images, or just start typing...',
80
+ 'Type away. Markdown syntax works too...',
81
+ 'Chapter 1 begins here...',
82
+ 'Once upon a time...',
83
+ "A blank page. Exciting, isn't it?",
84
+ 'The first word is always the hardest...',
85
+ 'Plot twist: this is where it all starts...',
84
86
  ];
85
87
 
86
88
  const BLOCK_TAG_DATA_VALUES = {
@@ -109,6 +111,8 @@ export interface WysiwygEditorProps {
109
111
  submitOnEnter?: () => void;
110
112
  /** Disable Tiptap editing — renders content but blocks input. */
111
113
  readOnly?: boolean;
114
+ /** Host-controlled base text size and line spacing for the Write canvas. */
115
+ writeCanvasSettings?: WriteCanvasSettings;
112
116
  }
113
117
 
114
118
  /**
@@ -120,6 +124,7 @@ export function WysiwygEditor({
120
124
  className,
121
125
  submitOnEnter,
122
126
  readOnly = false,
127
+ writeCanvasSettings,
123
128
  }: WysiwygEditorProps) {
124
129
  const {
125
130
  editorSource,
@@ -199,6 +204,8 @@ export function WysiwygEditor({
199
204
  HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
200
205
  BlockTagActivityExtension,
201
206
  AsciiDiagramExtension.configure({ textChannel: sceneTextChannel }),
207
+ MermaidDiagramExtension,
208
+ CodeSnippetExtension,
202
209
  RepairableDiagramExtension.configure({ onRepair: applyRepairCommand }),
203
210
  TimelineViewExtension,
204
211
  TreeViewExtension,
@@ -220,6 +227,7 @@ export function WysiwygEditor({
220
227
  Placeholder.configure({ placeholder: resolvedPlaceholder }),
221
228
  buildMentionExtension(() => mentionProviderRef.current),
222
229
  InlineIcon,
230
+ FindHighlightExtension,
223
231
  ],
224
232
  content: markdownToTiptap(stripFrontmatter(editorSource).body),
225
233
  onUpdate: ({ editor: ed }) => {
@@ -625,7 +633,13 @@ export function WysiwygEditor({
625
633
  <CustomTemplateProvider docTemplates={docTemplates} onDocTemplatesChange={onDocTemplatesChange}>
626
634
  <div
627
635
  className={`squisq-wysiwyg-container${className ? ` ${className}` : ''}`}
628
- style={{ width: '100%', height: '100%', overflow: 'auto', ...themeStyle }}
636
+ style={{
637
+ width: '100%',
638
+ height: '100%',
639
+ overflow: 'auto',
640
+ ...themeStyle,
641
+ ...writeCanvasSettingsStyle(writeCanvasSettings),
642
+ }}
629
643
  data-testid="wysiwyg-container"
630
644
  data-block-tags={BLOCK_TAG_DATA_VALUES[blockTagVisibility]}
631
645
  data-theme-inheritance={themeInheritance}
@@ -45,4 +45,21 @@ describe('buildPreviewDoc content mapping', () => {
45
45
  description: 'Pinned description',
46
46
  });
47
47
  });
48
+
49
+ it('keeps Mermaid source available to the slide materializer for every template', () => {
50
+ const slide = firstPreviewSlide(
51
+ '# Architecture\n\n```mermaid\nflowchart LR\n client --> server\n```',
52
+ );
53
+ expect(slide.contents).toEqual([expect.objectContaining({ type: 'code', lang: 'mermaid' })]);
54
+
55
+ const { layers } = materializeBlockLayers(slide as unknown as DocBlock, {
56
+ persistentLayers: false,
57
+ });
58
+ expect(layers.some((layer) => layer.type === 'mermaid')).toBe(true);
59
+ const narrativeText = layers
60
+ .filter((layer): layer is TextLayer => layer.type === 'text')
61
+ .map((layer) => layer.content.text)
62
+ .join('\n');
63
+ expect(narrativeText).not.toContain('flowchart LR');
64
+ });
48
65
  });
@@ -10,7 +10,7 @@ const noop = () => {};
10
10
  const baseSection = {
11
11
  id: 'foo@10',
12
12
  summaryMarkdown: '**foo** — does things · ↓2 imported-by',
13
- markdown: 'Body text with [`a.ts`](gezel-nav:src%2Fa.ts) and [line 4](#L4).',
13
+ markdown: 'Body text with [`a.ts`](workspace-nav:src%2Fa.ts) and [line 4](#L4).',
14
14
  };
15
15
 
16
16
  function renderView(over: Partial<Parameters<typeof CodeContextSectionView>[0]> = {}) {
@@ -18,7 +18,7 @@ function renderView(over: Partial<Parameters<typeof CodeContextSectionView>[0]>
18
18
  section: baseSection,
19
19
  expanded: false,
20
20
  onToggle: vi.fn(),
21
- linkSchemes: ['gezel-nav'] as const,
21
+ linkSchemes: ['workspace-nav'] as const,
22
22
  onLinkClick: vi.fn(),
23
23
  onRevealLine: vi.fn(),
24
24
  onMeasure: noop,
@@ -47,7 +47,7 @@ describe('<CodeContextSectionView>', () => {
47
47
  const body = container.querySelector('.squisq-ccx-body')!;
48
48
  expect(body.textContent).toContain('Body text');
49
49
  const anchors = [...body.querySelectorAll('a')].map((a) => a.getAttribute('href'));
50
- expect(anchors).toContain('gezel-nav:src%2Fa.ts');
50
+ expect(anchors).toContain('workspace-nav:src%2Fa.ts');
51
51
  expect(anchors).toContain('#L4');
52
52
  });
53
53
 
@@ -63,10 +63,12 @@ describe('<CodeContextSectionView>', () => {
63
63
  const onLinkClick = vi.fn(() => undefined);
64
64
  const { container } = renderView({ expanded: true, onLinkClick });
65
65
  const nav = [...container.querySelectorAll('a')].find(
66
- (a) => a.getAttribute('href') === 'gezel-nav:src%2Fa.ts',
66
+ (a) => a.getAttribute('href') === 'workspace-nav:src%2Fa.ts',
67
67
  )!;
68
68
  const first = fireEvent.click(nav);
69
- expect(onLinkClick).toHaveBeenCalledWith('gezel-nav:src%2Fa.ts', { sectionId: 'foo@10' });
69
+ expect(onLinkClick).toHaveBeenCalledWith('workspace-nav:src%2Fa.ts', {
70
+ sectionId: 'foo@10',
71
+ });
70
72
  expect(first).toBe(false); // preventDefault was called
71
73
 
72
74
  onLinkClick.mockReturnValue(false as unknown as undefined);
@@ -89,7 +91,7 @@ describe('<CodeContextSectionView>', () => {
89
91
  it('without linkSchemes, custom-scheme links render blocked (no anchor)', () => {
90
92
  const { container } = renderView({ expanded: true, linkSchemes: undefined });
91
93
  const anchors = [...container.querySelectorAll('a')].map((a) => a.getAttribute('href'));
92
- expect(anchors).not.toContain('gezel-nav:src%2Fa.ts');
94
+ expect(anchors).not.toContain('workspace-nav:src%2Fa.ts');
93
95
  expect(container.querySelector('.squisq-md-link--blocked')).toBeTruthy();
94
96
  });
95
97
  });
@@ -123,6 +123,28 @@ describe('DocumentSettingsDialog', () => {
123
123
  expect(next).not.toContain('squisq-theme');
124
124
  });
125
125
 
126
+ it('removes explicit managed defaults and their legacy aliases on save', () => {
127
+ const onSave = vi.fn();
128
+ const src = `---
129
+ squisq-theme: standard
130
+ themeId: standard
131
+ theme: standard
132
+ squisq-captions: standard
133
+ caption-style: standard
134
+ author: Keep
135
+ ---
136
+
137
+ # Doc
138
+ `;
139
+ open(src, onSave);
140
+ clickSave();
141
+
142
+ const next = onSave.mock.calls[0][0] as string;
143
+ expect(next).not.toMatch(/^(?:squisq-theme|themeId|theme):/m);
144
+ expect(next).not.toMatch(/^(?:squisq-captions|caption-style):/m);
145
+ expect(next).toContain('author: Keep');
146
+ });
147
+
126
148
  it('writes squisq-transform when a transform is picked', () => {
127
149
  const onSave = vi.fn();
128
150
  open('# Doc\n', onSave);
@@ -176,6 +176,29 @@ describe('<EditorShell> colorScheme prop', () => {
176
176
  });
177
177
  });
178
178
 
179
+ describe('<EditorShell> Write canvas settings', () => {
180
+ it('exposes host settings as live CSS variables on the shell', () => {
181
+ const { container, rerender } = render(
182
+ <EditorShell
183
+ initialMarkdown="Paragraph"
184
+ writeCanvasSettings={{ textSize: 18, lineSpacing: 1.9 }}
185
+ />,
186
+ );
187
+ const shell = container.querySelector<HTMLElement>('.squisq-editor-shell')!;
188
+ expect(shell.style.getPropertyValue('--squisq-write-text-size')).toBe('18px');
189
+ expect(shell.style.getPropertyValue('--squisq-write-line-spacing')).toBe('1.9');
190
+
191
+ rerender(
192
+ <EditorShell
193
+ initialMarkdown="Paragraph"
194
+ writeCanvasSettings={{ textSize: 20, lineSpacing: 2 }}
195
+ />,
196
+ );
197
+ expect(shell.style.getPropertyValue('--squisq-write-text-size')).toBe('20px');
198
+ expect(shell.style.getPropertyValue('--squisq-write-line-spacing')).toBe('2');
199
+ });
200
+ });
201
+
179
202
  describe('RawEditor monacoTheme prop', () => {
180
203
  it('maps colorScheme="dark" to monacoTheme="vs-dark"', () => {
181
204
  render(<EditorShell initialMarkdown="# hi" initialView="raw" colorScheme="dark" />);
@@ -482,4 +505,46 @@ describe('<Toolbar> Insert menu', () => {
482
505
  );
483
506
  });
484
507
  });
508
+
509
+ it('opens the Mermaid type gallery and inserts the selected diagram grammar', async () => {
510
+ render(
511
+ <EditorProvider initialMarkdown="Intro" initialView="raw" allowRecording={false}>
512
+ <Toolbar />
513
+ <MarkdownSourceProbe />
514
+ </EditorProvider>,
515
+ );
516
+
517
+ fireEvent.click(screen.getByLabelText('Insert'));
518
+ fireEvent.click(await screen.findByRole('menuitem', { name: 'Complex Diagram (Mermaid)' }));
519
+ expect(await screen.findByRole('menu', { name: 'Mermaid diagram type' })).toBeTruthy();
520
+ expect(screen.getByText(/Flow direction remains a separate edit/)).toBeTruthy();
521
+ fireEvent.click(await screen.findByRole('menuitem', { name: 'Insert Gantt Mermaid diagram' }));
522
+
523
+ await waitFor(() => {
524
+ expect(screen.getByTestId('markdown-source').textContent).toContain(
525
+ '```mermaid\ngantt\n title Project plan\n dateFormat YYYY-MM-DD',
526
+ );
527
+ });
528
+ });
529
+
530
+ it('adds a typed code fence from the Code Snippet submenu', async () => {
531
+ render(
532
+ <EditorProvider initialMarkdown="Intro" initialView="raw" allowRecording={false}>
533
+ <Toolbar />
534
+ <MarkdownSourceProbe />
535
+ </EditorProvider>,
536
+ );
537
+
538
+ fireEvent.click(screen.getByLabelText('Insert'));
539
+ fireEvent.click(await screen.findByRole('menuitem', { name: 'Insert Code Snippet' }));
540
+ fireEvent.click(
541
+ await screen.findByRole('menuitem', { name: 'Insert TypeScript code snippet' }),
542
+ );
543
+
544
+ await waitFor(() => {
545
+ expect(screen.getByTestId('markdown-source').textContent).toContain(
546
+ "```typescript\nconst message: string = 'Hello, world!';\n```",
547
+ );
548
+ });
549
+ });
485
550
  });
@@ -0,0 +1,104 @@
1
+ /** @vitest-environment jsdom */
2
+
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { fireEvent, render, screen } from '@testing-library/react';
5
+
6
+ vi.mock('../RawEditor', () => ({
7
+ RawEditor: () => <div data-testid="raw-editor-stub">Alpha beta alpha</div>,
8
+ }));
9
+ vi.mock('../WysiwygEditor', () => ({
10
+ WysiwygEditor: () => <div data-testid="wysiwyg-editor-stub" />,
11
+ }));
12
+ vi.mock('../PreviewPanel', () => ({
13
+ PreviewPanel: () => <div data-testid="preview-panel">Alpha beta alpha</div>,
14
+ }));
15
+
16
+ import { EditorShell } from '../EditorShell';
17
+
18
+ beforeEach(() => {
19
+ if (typeof window.matchMedia !== 'function') {
20
+ Object.defineProperty(window, 'matchMedia', {
21
+ configurable: true,
22
+ value: (query: string) => ({
23
+ matches: false,
24
+ media: query,
25
+ onchange: null,
26
+ addListener: vi.fn(),
27
+ removeListener: vi.fn(),
28
+ addEventListener: vi.fn(),
29
+ removeEventListener: vi.fn(),
30
+ dispatchEvent: vi.fn(() => false),
31
+ }),
32
+ });
33
+ }
34
+ if (typeof globalThis.ResizeObserver === 'undefined') {
35
+ class ResizeObserverStub {
36
+ observe(): void {}
37
+ unobserve(): void {}
38
+ disconnect(): void {}
39
+ }
40
+ (globalThis as unknown as { ResizeObserver: typeof ResizeObserverStub }).ResizeObserver =
41
+ ResizeObserverStub;
42
+ }
43
+ });
44
+
45
+ describe('<EditorShell> Find mode', () => {
46
+ it('does not render a Find trigger or textbox by default', () => {
47
+ render(<EditorShell initialMarkdown="Alpha" initialView="raw" />);
48
+ expect(screen.queryByRole('search', { name: 'Find in document' })).toBeNull();
49
+ expect(screen.queryByRole('button', { name: 'Close find' })).toBeNull();
50
+ });
51
+
52
+ it('shows search beside the view tabs, clears middle/left actions, and preserves right items', () => {
53
+ const onFindModeChange = vi.fn();
54
+ const { container } = render(
55
+ <EditorShell
56
+ initialMarkdown="Alpha beta alpha"
57
+ initialView="raw"
58
+ findMode
59
+ onFindModeChange={onFindModeChange}
60
+ toolbarSlotLeft={<span data-testid="left-slot">Left</span>}
61
+ toolbarSlotAfterActions={<span data-testid="middle-slot">Middle</span>}
62
+ toolbarSlotRight={<span data-testid="right-slot">Right</span>}
63
+ />,
64
+ );
65
+
66
+ const tabs = container.querySelector('.squisq-toolbar-view-tabs');
67
+ const search = screen.getByRole('search', { name: 'Find in document' });
68
+ expect(tabs?.nextElementSibling).toBe(search);
69
+ expect(screen.queryByTestId('left-slot')).toBeNull();
70
+ expect(screen.queryByTestId('middle-slot')).toBeNull();
71
+ expect(screen.getByTestId('right-slot')).toBeTruthy();
72
+ expect(screen.queryByRole('button', { name: 'Bold' })).toBeNull();
73
+ expect(screen.getByRole('button', { name: 'Document settings' })).toBeTruthy();
74
+
75
+ fireEvent.click(screen.getByRole('button', { name: 'Close find' }));
76
+ expect(onFindModeChange).toHaveBeenCalledWith(false);
77
+ });
78
+
79
+ it('responds to host-controlled mode changes and supports Escape to close', () => {
80
+ const onFindModeChange = vi.fn();
81
+ const { rerender } = render(
82
+ <EditorShell
83
+ initialMarkdown="Alpha"
84
+ initialView="raw"
85
+ findMode={false}
86
+ onFindModeChange={onFindModeChange}
87
+ />,
88
+ );
89
+ expect(screen.queryByRole('search', { name: 'Find in document' })).toBeNull();
90
+
91
+ rerender(
92
+ <EditorShell
93
+ initialMarkdown="Alpha"
94
+ initialView="raw"
95
+ findMode
96
+ onFindModeChange={onFindModeChange}
97
+ />,
98
+ );
99
+ const input = screen.getByRole('searchbox', { name: 'Find in document' });
100
+ expect(document.activeElement).toBe(input);
101
+ fireEvent.keyDown(input, { key: 'Escape' });
102
+ expect(onFindModeChange).toHaveBeenCalledWith(false);
103
+ });
104
+ });
@@ -0,0 +1,55 @@
1
+ /** @vitest-environment jsdom */
2
+
3
+ import { afterEach, describe, expect, it } from 'vitest';
4
+ import { Editor } from '@tiptap/core';
5
+ import StarterKit from '@tiptap/starter-kit';
6
+ import { FindHighlightExtension, updateTiptapFindHighlights } from '../find/FindHighlightExtension';
7
+ import { findProseMirrorMatches, findTextMatches, normalizeFindIndex } from '../find/findModel';
8
+
9
+ const editors: Editor[] = [];
10
+
11
+ afterEach(() => {
12
+ editors.splice(0).forEach((editor) => editor.destroy());
13
+ });
14
+
15
+ describe('Find model', () => {
16
+ it('finds case-insensitive literal matches and escapes punctuation', () => {
17
+ expect(findTextMatches('A+b a+B A-b', 'a+b')).toEqual([
18
+ { from: 0, to: 3 },
19
+ { from: 4, to: 7 },
20
+ ]);
21
+ });
22
+
23
+ it('wraps next and previous indexes', () => {
24
+ expect(normalizeFindIndex(3, 3)).toBe(0);
25
+ expect(normalizeFindIndex(-1, 3)).toBe(2);
26
+ expect(normalizeFindIndex(10, 0)).toBe(0);
27
+ });
28
+
29
+ it('matches across adjacent formatted spans but not across paragraphs', () => {
30
+ const editor = new Editor({
31
+ extensions: [StarterKit],
32
+ content: '<p>Hello <strong>wide</strong> world</p><p>Hello world</p>',
33
+ });
34
+ editors.push(editor);
35
+
36
+ expect(findProseMirrorMatches(editor.state.doc, 'hello wide world')).toHaveLength(1);
37
+ expect(findProseMirrorMatches(editor.state.doc, 'worldhello')).toHaveLength(0);
38
+ });
39
+
40
+ it('decorates every WYSIWYG match and marks the selected one separately', () => {
41
+ const element = document.createElement('div');
42
+ document.body.append(element);
43
+ const editor = new Editor({
44
+ element,
45
+ extensions: [StarterKit, FindHighlightExtension],
46
+ content: '<p>Alpha alpha ALPHA</p>',
47
+ });
48
+ editors.push(editor);
49
+
50
+ expect(updateTiptapFindHighlights(editor, 'alpha', 1)).toBe(3);
51
+ expect(element.querySelectorAll('.squisq-find-match')).toHaveLength(3);
52
+ expect(element.querySelectorAll('.squisq-find-match--selected')).toHaveLength(1);
53
+ expect(element.querySelector('.squisq-find-match--selected')?.textContent).toBe('alpha');
54
+ });
55
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ isMarkdownFencedCodeLine,
4
+ markdownFencedCodeLineMask,
5
+ maskMarkdownFencedCode,
6
+ } from '../markdownCodeFence';
7
+
8
+ describe('markdown fenced-code detection', () => {
9
+ it('marks opening, body, and closing lines without masking surrounding prose', () => {
10
+ const source = [
11
+ 'Before {[github]}',
12
+ '```md',
13
+ '## Example {[sectionHeader]}',
14
+ '```',
15
+ 'After',
16
+ ].join('\n');
17
+
18
+ expect(markdownFencedCodeLineMask(source)).toEqual([false, true, true, true, false]);
19
+ expect(isMarkdownFencedCodeLine(source, 3)).toBe(true);
20
+ expect(isMarkdownFencedCodeLine(source, 5)).toBe(false);
21
+ });
22
+
23
+ it('supports tilde fences, longer closers, indentation, and unclosed fences', () => {
24
+ const source = [
25
+ ' ~~~~ts',
26
+ '{[audio src=inside.mp3]}',
27
+ ' ~~~~~',
28
+ 'text',
29
+ '```',
30
+ '{[quote]}',
31
+ ].join('\r\n');
32
+
33
+ expect(markdownFencedCodeLineMask(source)).toEqual([true, true, true, false, true, true]);
34
+ });
35
+
36
+ it('masks fenced contents while preserving offsets and line endings', () => {
37
+ const source = 'Outside\r\n```md\r\n{[image src=inside.png]}\r\n```\r\nAfter';
38
+ const masked = maskMarkdownFencedCode(source);
39
+
40
+ expect(masked).toHaveLength(source.length);
41
+ expect(masked).toContain('Outside\r\n');
42
+ expect(masked).not.toContain('inside.png');
43
+ expect(masked.endsWith('\r\nAfter')).toBe(true);
44
+ });
45
+ });
@@ -7,7 +7,7 @@ import { markdownToTiptap, tiptapToMarkdown } from '../tiptapBridge';
7
7
  * uploaded files into the bin without inserting a markdown ref into
8
8
  * the editor body. A user would upload an image, hit Send in the
9
9
  * downstream chat composer, and the outgoing markdown would have no
10
- * image reference — the gezel would reply "nothing came through."
10
+ * image reference — the downstream consumer would reply "nothing came through."
11
11
  *
12
12
  * The fix: after `mediaProvider.addMedia(...)` succeeds, MediaBin
13
13
  * fires `onMediaUploaded(relativePath, name, mimeType)`. The
@@ -18,7 +18,7 @@ import { markdownToTiptap, tiptapToMarkdown } from '../tiptapBridge';
18
18
  * These tests exercise the contract directly: the markdown snippet
19
19
  * produced by the upload callback, once round-tripped through the
20
20
  * editor's markdown↔HTML bridge, must round-trip back to a form
21
- * the gezel service's image-extraction regex can see.
21
+ * the downstream service's image-extraction regex can see.
22
22
  */
23
23
 
24
24
  function fakeMediaProvider(records: string[]): MediaProvider {
@@ -39,6 +39,21 @@ describe('collectMediaReferencesFromMarkdown', () => {
39
39
  'video/clip with spaces.webm',
40
40
  ]);
41
41
  });
42
+
43
+ it('ignores annotations and other reference-shaped text inside code fences', () => {
44
+ const refs = collectMediaReferencesFromMarkdown(
45
+ [
46
+ '## Real {[image src=images/outside.png]}',
47
+ '```markdown',
48
+ '{[audio src=audio/inside.mp3]}',
49
+ '![Example](images/inside.png)',
50
+ '<video src="video/inside.webm"></video>',
51
+ '```',
52
+ ].join('\n'),
53
+ );
54
+
55
+ expect([...refs]).toEqual(['images/outside.png']);
56
+ });
42
57
  });
43
58
 
44
59
  describe('removeMediaReferencesFromMarkdown', () => {