@bendyline/squisq-editor-react 1.6.2 → 2.0.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 (200) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1229 -179
  3. package/dist/index.js +18017 -11231
  4. package/dist/index.js.map +1 -1
  5. package/dist/monaco.d.ts +1 -0
  6. package/dist/monaco.js +4 -0
  7. package/dist/monaco.js.map +1 -0
  8. package/dist/styles/index.css +1471 -245
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +13 -21
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +348 -260
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +107 -14
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +309 -69
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/StatusBar.tsx +5 -2
  23. package/src/TemplateAnnotation.ts +12 -3
  24. package/src/TemplatePicker.tsx +75 -21
  25. package/src/ThemeCustomizerPanel.tsx +8 -0
  26. package/src/ThemePicker.tsx +10 -5
  27. package/src/TimelineBlockPreview.tsx +1 -1
  28. package/src/Toolbar.tsx +202 -41
  29. package/src/TransitionPicker.tsx +36 -4
  30. package/src/ViewMenuPanel.tsx +12 -14
  31. package/src/WysiwygEditor.tsx +124 -91
  32. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  33. package/src/__tests__/blockTagActivity.test.ts +183 -0
  34. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  35. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  36. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  37. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  38. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  39. package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
  40. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  41. package/src/__tests__/editorScrollSync.test.ts +65 -0
  42. package/src/__tests__/editorShellProps.test.tsx +123 -1
  43. package/src/__tests__/emojiPicker.test.tsx +2 -42
  44. package/src/__tests__/headingTransition.test.ts +0 -12
  45. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  46. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  47. package/src/__tests__/imageEditorShell.test.tsx +15 -1
  48. package/src/__tests__/jsonEditor.test.tsx +56 -1
  49. package/src/__tests__/layersPanel.test.tsx +75 -5
  50. package/src/__tests__/majorApiTypes.test.ts +26 -0
  51. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  52. package/src/__tests__/narrationSave.test.ts +148 -0
  53. package/src/__tests__/outlinePanel.test.tsx +93 -3
  54. package/src/__tests__/outlineSource.test.ts +217 -0
  55. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  56. package/src/__tests__/presentationMode.test.tsx +290 -0
  57. package/src/__tests__/previewControls.test.tsx +218 -5
  58. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  59. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  60. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  61. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  62. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  63. package/src/__tests__/statusBar.test.tsx +27 -0
  64. package/src/__tests__/teleprompter.test.tsx +190 -0
  65. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  66. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  67. package/src/__tests__/tiptapBridge.test.ts +23 -0
  68. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  69. package/src/__tests__/transformStyleId.test.ts +13 -0
  70. package/src/__tests__/useImageEditor.test.tsx +67 -0
  71. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  72. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  73. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  74. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  75. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  76. package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
  77. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  78. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  79. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  80. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
  81. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  82. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  83. package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
  84. package/src/asciiDiagram/asciiDiagramData.ts +116 -0
  85. package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
  86. package/src/asciiDiagram/asciiPaste.ts +19 -0
  87. package/src/blockTagActivity.ts +233 -0
  88. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  89. package/src/buildPreviewDoc.ts +19 -12
  90. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  91. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  92. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  93. package/src/customTemplates/__tests__/library.test.ts +8 -0
  94. package/src/customTemplates/designer.css +158 -99
  95. package/src/customTemplates/library.ts +16 -3
  96. package/src/customThemes/CustomThemeContext.tsx +6 -0
  97. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  98. package/src/customThemes/ImportThemeSection.tsx +178 -0
  99. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  100. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  101. package/src/customThemes/customThemeLibrary.ts +7 -4
  102. package/src/customThemes/index.ts +7 -1
  103. package/src/customThemes/themeDraft.ts +23 -7
  104. package/src/diagram/DiagramCanvas.tsx +9 -4
  105. package/src/diagram/types.ts +35 -0
  106. package/src/editorScrollSync.ts +73 -0
  107. package/src/emojiData.ts +3 -23
  108. package/src/headingTransition.ts +0 -15
  109. package/src/imageEditor/CanvasSurface.tsx +29 -23
  110. package/src/imageEditor/LayersPanel.tsx +78 -3
  111. package/src/imageEditor/Toolbar.tsx +5 -1
  112. package/src/imageEditor/icons.tsx +4 -0
  113. package/src/imageEditor/image-editor.css +75 -0
  114. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  115. package/src/imageEditor/useImageEditor.ts +71 -14
  116. package/src/index.ts +191 -19
  117. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  118. package/src/jsonEditor/RenderNode.tsx +24 -3
  119. package/src/jsonEditor/editors.tsx +86 -19
  120. package/src/mediaEntries.ts +12 -0
  121. package/src/monaco.ts +35 -0
  122. package/src/monacoWorkers.ts +89 -0
  123. package/src/outlineSource.ts +171 -0
  124. package/src/presentation/PresentationMode.tsx +596 -0
  125. package/src/rawEditorIsolation.ts +18 -0
  126. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  127. package/src/resolveBlockVisual.ts +10 -4
  128. package/src/scene/Scene.tsx +48 -18
  129. package/src/scene/SceneBlockExtension.ts +17 -10
  130. package/src/scene/SceneBlockWidget.tsx +5 -1
  131. package/src/scene/SceneViewport.tsx +7 -3
  132. package/src/scene/ShapePalette.tsx +17 -2
  133. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  134. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  135. package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
  136. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  137. package/src/scene/commands/SceneCommand.ts +4 -1
  138. package/src/scene/index.ts +1 -6
  139. package/src/scene/layers/DiagramEdges.tsx +21 -7
  140. package/src/scene/layers/edgeGeometry.ts +9 -1
  141. package/src/scene/layers/nodeCard.tsx +27 -8
  142. package/src/scene/scene.css +5 -2
  143. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  144. package/src/scene/text/sceneTextChannel.ts +26 -20
  145. package/src/scene/text/sceneTextConfig.ts +4 -0
  146. package/src/scene/tools/ConnectTool.ts +13 -4
  147. package/src/scene/tools/SceneTool.ts +8 -0
  148. package/src/scene/tools/SelectTool.ts +34 -18
  149. package/src/scene/tools/ShapeTool.ts +2 -3
  150. package/src/styles/ascii-diagram.css +79 -0
  151. package/src/styles/ascii-timeline.css +499 -0
  152. package/src/styles/editor.css +706 -120
  153. package/src/styles/index.css +3 -0
  154. package/src/styles/tree-view.css +139 -0
  155. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  156. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  157. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  158. package/src/teleprompter/TeleprompterView.tsx +338 -0
  159. package/src/teleprompter/canvasRenderer.ts +161 -0
  160. package/src/teleprompter/floatingWindow.ts +352 -0
  161. package/src/teleprompter/index.ts +61 -0
  162. package/src/teleprompter/pcmWorklet.ts +62 -0
  163. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  164. package/src/teleprompter/recording/narrationSave.ts +134 -0
  165. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  166. package/src/teleprompter/scrollModel.ts +85 -0
  167. package/src/teleprompter/teleprompterTheme.ts +303 -0
  168. package/src/teleprompter/types.ts +38 -0
  169. package/src/teleprompter/useFloatingWindow.ts +74 -0
  170. package/src/teleprompter/useMicAnalysis.ts +211 -0
  171. package/src/teleprompter/useTeleprompter.ts +421 -0
  172. package/src/templateContentPreviewResolver.ts +12 -5
  173. package/src/timeline/TimelineEditorWidget.tsx +702 -0
  174. package/src/timeline/TimelineViewExtension.ts +252 -0
  175. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
  176. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  177. package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
  178. package/src/timeline/__tests__/timelineOps.test.ts +222 -0
  179. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  180. package/src/timeline/timelineCommands.ts +367 -0
  181. package/src/timeline/timelineData.ts +54 -0
  182. package/src/timeline/timelineOps.ts +277 -0
  183. package/src/timeline/timelinePaste.ts +8 -0
  184. package/src/tiptapBridge.ts +24 -16
  185. package/src/transformStyleId.ts +17 -0
  186. package/src/treeview/TreeOutlineWidget.tsx +240 -0
  187. package/src/treeview/TreeViewExtension.ts +250 -0
  188. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  189. package/src/treeview/__tests__/treeOps.test.ts +125 -0
  190. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  191. package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
  192. package/src/treeview/treeOps.ts +172 -0
  193. package/src/treeview/treePaste.ts +13 -0
  194. package/src/treeview/treeViewCommands.ts +94 -0
  195. package/src/treeview/treeViewData.ts +41 -0
  196. package/src/useMonacoLoader.ts +11 -34
  197. package/src/wysiwygImageUpload.ts +113 -0
  198. package/src/diagram/DiagramExtension.ts +0 -209
  199. package/src/diagram/DiagramWidget.tsx +0 -272
  200. package/src/diagram/useDiagramData.ts +0 -126
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { markdownToDoc, materializeBlockLayers } from '@bendyline/squisq/doc';
3
+ import { parseMarkdown } from '@bendyline/squisq/markdown';
4
+ import type { DocBlock, TextLayer } from '@bendyline/squisq/schemas';
5
+ import { buildPreviewDoc } from '../buildPreviewDoc';
6
+
7
+ function firstPreviewSlide(markdown: string): Record<string, unknown> {
8
+ const source = markdownToDoc(parseMarkdown(markdown), {
9
+ articleId: 'stat-content-test',
10
+ generateCoverBlock: false,
11
+ });
12
+ return buildPreviewDoc(source).blocks[0] as unknown as Record<string, unknown>;
13
+ }
14
+
15
+ describe('buildPreviewDoc content mapping', () => {
16
+ it('maps the leading bold metric to the large stat layer', () => {
17
+ const slide = firstPreviewSlide(`### The Big Number {[statHighlight colorScheme=green]}
18
+
19
+ **42%** of teams prefer visual blocks over raw slides.`);
20
+
21
+ expect(slide).toMatchObject({
22
+ title: 'The Big Number',
23
+ stat: '42%',
24
+ description: 'of teams prefer visual blocks over raw slides.',
25
+ colorScheme: 'green',
26
+ });
27
+
28
+ const { layers } = materializeBlockLayers(slide as unknown as DocBlock);
29
+ const stat = layers.find((layer) => layer.id === 'stat') as TextLayer;
30
+ const description = layers.find((layer) => layer.id === 'description') as TextLayer;
31
+
32
+ expect(stat.content.text).toBe('42%');
33
+ expect(description.content.text).toBe('of teams prefer visual blocks over raw slides.');
34
+ expect(stat.content.style.fontSize).toBeGreaterThan(description.content.style.fontSize);
35
+ });
36
+
37
+ it('lets explicit annotation inputs override Markdown-derived defaults', () => {
38
+ const slide = firstPreviewSlide(
39
+ '### The Big Number {[statHighlight stat="99%" description="Pinned description"]}\n\n' +
40
+ '**42%** derived description.',
41
+ );
42
+
43
+ expect(slide).toMatchObject({
44
+ stat: '99%',
45
+ description: 'Pinned description',
46
+ });
47
+ });
48
+ });
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { parseMarkdown } from '@bendyline/squisq/markdown';
3
3
  import { markdownToDoc } from '@bendyline/squisq/doc';
4
- import type { Transition } from '@bendyline/squisq/schemas';
4
+ import type { Doc, Transition } from '@bendyline/squisq/schemas';
5
5
  import { buildPreviewDoc } from '../buildPreviewDoc';
6
6
 
7
7
  function previewSlides(md: string) {
@@ -69,4 +69,24 @@ describe('buildPreviewDoc transition mapping', () => {
69
69
  expect(slides[1].transition).toEqual({ type: 'push', direction: 'left', duration: 0.8 });
70
70
  });
71
71
  });
72
+
73
+ it('preserves document-wide fields and mapped audio', () => {
74
+ const source = markdownToDoc(parseMarkdown('# Intro'), { articleId: 'full' }) as Doc;
75
+ const enriched = {
76
+ ...source,
77
+ frontmatter: { owner: 'team' },
78
+ customThemes: [{ id: 'custom-theme' }],
79
+ persistentLayers: { layers: [] },
80
+ documentMedia: [{ id: 'bed', kind: 'audio', src: 'bed.mp3', startTime: 0 }],
81
+ audio: {
82
+ segments: [{ src: 'narration.mp3', name: 'narration', duration: 4, startTime: 0 }],
83
+ },
84
+ } as unknown as Doc;
85
+ const preview = buildPreviewDoc(enriched);
86
+ expect(preview.frontmatter).toEqual({ owner: 'team' });
87
+ expect(preview.customThemes).toBe(enriched.customThemes);
88
+ expect(preview.persistentLayers).toBe(enriched.persistentLayers);
89
+ expect(preview.documentMedia).toBe(enriched.documentMedia);
90
+ expect(preview.audio).toBe(enriched.audio);
91
+ });
72
92
  });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { render, screen, waitFor } from '@testing-library/react';
6
+ import type { ImageEditDoc } from '@bendyline/squisq/schemas';
7
+ import { CanvasSurface } from '../imageEditor/CanvasSurface.js';
8
+ import type { ImageEditorAction } from '../imageEditor/state.js';
9
+
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ });
13
+
14
+ describe('CanvasSurface text editing', () => {
15
+ it('uses one tightly measured edit box and applies SVG zoom only once', async () => {
16
+ const context = {
17
+ font: '',
18
+ measureText: (text: string) => ({ width: text.length * 10 }),
19
+ } as unknown as CanvasRenderingContext2D;
20
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(() => context);
21
+
22
+ const doc: ImageEditDoc = {
23
+ version: 1,
24
+ canvas: { width: 640, height: 480 },
25
+ layers: [
26
+ {
27
+ id: 'text-1',
28
+ type: 'text',
29
+ name: 'Text',
30
+ position: { x: 100, y: 80, width: 240, height: 48 },
31
+ content: {
32
+ text: 'hello',
33
+ style: { fontSize: 20, lineHeight: 1.4, color: '#111111' },
34
+ },
35
+ },
36
+ ],
37
+ };
38
+
39
+ const { container } = render(
40
+ <CanvasSurface
41
+ doc={doc}
42
+ selectedLayerId="text-1"
43
+ tool="select"
44
+ resolveAssetUrl={async (path) => path}
45
+ dispatch={vi.fn<(action: ImageEditorAction) => void>()}
46
+ requestEditLayerId="text-1"
47
+ zoom={2}
48
+ />,
49
+ );
50
+
51
+ const textarea = (await screen.findByRole('textbox')) as HTMLTextAreaElement;
52
+ const editBox = container.querySelector('foreignObject');
53
+
54
+ expect(editBox?.getAttribute('width')).toBe('50');
55
+ expect(textarea.style.fontSize).toBe('20px');
56
+ await waitFor(() => {
57
+ expect(container.querySelector('.squisq-image-editor-selection-handles')).toBeNull();
58
+ });
59
+ });
60
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { render, screen } from '@testing-library/react';
5
+ import { describe, expect, it, vi } from 'vitest';
6
+
7
+ vi.mock('../EditorContext', () => ({
8
+ useEditorContext: () => ({
9
+ colorScheme: 'dark',
10
+ mediaProvider: null,
11
+ doc: null,
12
+ markdownSource: '',
13
+ setMarkdownSource: vi.fn(),
14
+ }),
15
+ }));
16
+
17
+ // The regression is at the manager's portaled theme boundary; the designer's
18
+ // interaction surface is unrelated and expensive to mount for this assertion.
19
+ vi.mock('../customTemplates/TemplateDesigner', () => ({
20
+ TemplateDesigner: () => <div data-testid="template-designer" />,
21
+ }));
22
+
23
+ import { CustomLayoutManager } from '../customTemplates/CustomLayoutManager';
24
+
25
+ describe('CustomLayoutManager theme propagation', () => {
26
+ it('copies the editor dark scheme onto its portaled theme scope', () => {
27
+ render(<CustomLayoutManager onClose={vi.fn()} />);
28
+
29
+ const dialog = screen.getByRole('dialog', { name: 'Custom layouts' });
30
+ expect(dialog.getAttribute('data-theme')).toBe('dark');
31
+ expect(dialog.classList.contains('squisq-editor-shell')).toBe(true);
32
+ expect(screen.getByTestId('template-designer')).toBeTruthy();
33
+ });
34
+ });
@@ -26,7 +26,7 @@ function pickTheme(name: string) {
26
26
  // Scope the option lookup to the popover — the Transform <select> in
27
27
  // the same dialog also exposes role="option" entries (one of which is
28
28
  // literally "Documentary"), so a global query would collide.
29
- const popover = document.getElementById('squisq-theme-picker-popover')!;
29
+ const popover = document.querySelector<HTMLElement>('.squisq-theme-picker-popover')!;
30
30
  fireEvent.click(within(popover).getByRole('option', { name }));
31
31
  }
32
32
 
@@ -99,6 +99,20 @@ describe('DocumentSettingsDialog', () => {
99
99
  expect(next).not.toMatch(/^theme:/m);
100
100
  });
101
101
 
102
+ it('reads themeId and canonicalizes both legacy theme keys on save', () => {
103
+ const onSave = vi.fn();
104
+ const src = '---\nthemeId: bold\ntheme: cinematic\n---\n\n# Doc\n';
105
+ open(src, onSave);
106
+
107
+ expect(screen.getByLabelText('Theme').textContent).toContain('Bold');
108
+ clickSave();
109
+
110
+ const next = onSave.mock.calls[0][0] as string;
111
+ expect(next).toContain('squisq-theme: bold');
112
+ expect(next).not.toMatch(/^themeId:/m);
113
+ expect(next).not.toMatch(/^theme:/m);
114
+ });
115
+
102
116
  it('removes squisq-theme when reverting to default', () => {
103
117
  const onSave = vi.fn();
104
118
  const src = '---\nsquisq-theme: documentary\n---\n\n# Doc\n';
@@ -122,6 +136,20 @@ describe('DocumentSettingsDialog', () => {
122
136
  expect(next).toContain(`squisq-transform: ${first.value}`);
123
137
  });
124
138
 
139
+ it('canonicalizes a stored dataDriven transform id', () => {
140
+ const onSave = vi.fn();
141
+ open('---\ntransform-style: dataDriven\n---\n\n# Doc\n', onSave);
142
+
143
+ const select = screen.getByLabelText('Transform') as HTMLSelectElement;
144
+ expect(select.value).toBe('data-driven');
145
+ clickSave();
146
+
147
+ const next = onSave.mock.calls[0][0] as string;
148
+ expect(next).toContain('squisq-transform: data-driven');
149
+ expect(next).not.toContain('dataDriven');
150
+ expect(next).not.toContain('transform-style');
151
+ });
152
+
125
153
  it('writes squisq-captions when picked', () => {
126
154
  const onSave = vi.fn();
127
155
  open('# Doc\n', onSave);
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { act, cleanup, render } from '@testing-library/react';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { EditorProvider, useEditorContext, type EditorContextValue } from '../EditorContext';
7
+
8
+ let currentContext: EditorContextValue | null = null;
9
+ let animationFrames: FrameRequestCallback[] = [];
10
+
11
+ function ContextProbe() {
12
+ currentContext = useEditorContext();
13
+ return null;
14
+ }
15
+
16
+ function context(): EditorContextValue {
17
+ if (!currentContext) throw new Error('EditorContext has not mounted');
18
+ return currentContext;
19
+ }
20
+
21
+ function wysiwygEditor(
22
+ scrollTop: number,
23
+ scrollHeight: number,
24
+ clientHeight: number,
25
+ ): { editor: unknown; container: HTMLElement } {
26
+ const container = document.createElement('div');
27
+ container.className = 'squisq-wysiwyg-container';
28
+ Object.defineProperties(container, {
29
+ scrollTop: { value: scrollTop, writable: true },
30
+ scrollHeight: { value: scrollHeight },
31
+ clientHeight: { value: clientHeight },
32
+ });
33
+ const dom = document.createElement('div');
34
+ container.append(dom);
35
+ return { editor: { view: { dom } }, container };
36
+ }
37
+
38
+ function flushAnimationFrame(): void {
39
+ const callback = animationFrames.shift();
40
+ if (callback) act(() => callback(0));
41
+ }
42
+
43
+ beforeEach(() => {
44
+ currentContext = null;
45
+ animationFrames = [];
46
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
47
+ animationFrames.push(callback);
48
+ return animationFrames.length;
49
+ });
50
+ vi.stubGlobal('cancelAnimationFrame', vi.fn());
51
+ });
52
+
53
+ afterEach(() => {
54
+ cleanup();
55
+ vi.unstubAllGlobals();
56
+ });
57
+
58
+ describe('EditorProvider scroll handoff', () => {
59
+ it('restores proportional positions from Write to Source and back', () => {
60
+ render(
61
+ <EditorProvider initialMarkdown="# One\n\nBody" initialView="wysiwyg">
62
+ <ContextProbe />
63
+ </EditorProvider>,
64
+ );
65
+
66
+ const write = wysiwygEditor(300, 1100, 100);
67
+ act(() => context().setTiptapEditor(write.editor as never));
68
+
69
+ act(() => {
70
+ context().setActiveView('raw');
71
+ context().setTiptapEditor(null);
72
+ });
73
+
74
+ let sourceScrollTop = 1500;
75
+ const setSourceScrollTop = vi.fn();
76
+ const source = {
77
+ getScrollTop: () => sourceScrollTop,
78
+ getScrollHeight: () => 2100,
79
+ getLayoutInfo: () => ({ height: 100 }),
80
+ setScrollTop: setSourceScrollTop,
81
+ };
82
+ act(() => context().setMonacoEditor(source as never));
83
+ flushAnimationFrame();
84
+
85
+ // Write was 30% through its scrollable range, so Source lands at 30%.
86
+ expect(setSourceScrollTop).toHaveBeenCalledWith(600);
87
+
88
+ // A real Monaco instance reports the new position after setScrollTop.
89
+ sourceScrollTop = 1500;
90
+ act(() => {
91
+ context().setActiveView('wysiwyg');
92
+ context().setMonacoEditor(null);
93
+ });
94
+
95
+ const nextWrite = wysiwygEditor(0, 1300, 100);
96
+ act(() => context().setTiptapEditor(nextWrite.editor as never));
97
+ flushAnimationFrame();
98
+
99
+ // Source was 75% through; the newly mounted Write surface lands at 75%.
100
+ expect(nextWrite.container.scrollTop).toBe(900);
101
+ });
102
+ });
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ readMonacoScrollRatio,
4
+ readWysiwygScrollRatio,
5
+ restoreMonacoScrollRatio,
6
+ restoreWysiwygScrollRatio,
7
+ scrollRatio,
8
+ scrollTopForRatio,
9
+ } from '../editorScrollSync';
10
+
11
+ function scrollElement(scrollTop: number, scrollHeight: number, clientHeight: number): HTMLElement {
12
+ const container = document.createElement('div');
13
+ container.className = 'squisq-wysiwyg-container';
14
+ Object.defineProperties(container, {
15
+ scrollTop: { value: scrollTop, writable: true },
16
+ scrollHeight: { value: scrollHeight },
17
+ clientHeight: { value: clientHeight },
18
+ });
19
+ return container;
20
+ }
21
+
22
+ describe('editor scroll synchronization', () => {
23
+ it('converts pixel offsets to a normalized position and back', () => {
24
+ expect(scrollRatio(450, 1000, 100)).toBe(0.5);
25
+ expect(scrollTopForRatio(0.5, 2100, 100)).toBe(1000);
26
+ });
27
+
28
+ it('clamps positions and handles documents that do not scroll', () => {
29
+ expect(scrollRatio(-20, 1000, 100)).toBe(0);
30
+ expect(scrollRatio(1200, 1000, 100)).toBe(1);
31
+ expect(scrollRatio(20, 100, 100)).toBe(0);
32
+ expect(scrollTopForRatio(Number.NaN, 1000, 100)).toBe(0);
33
+ });
34
+
35
+ it('reads and restores Monaco using its scrollable height', () => {
36
+ const setScrollTop = vi.fn();
37
+ const editor = {
38
+ getScrollTop: () => 450,
39
+ getScrollHeight: () => 1000,
40
+ getLayoutInfo: () => ({ height: 100 }),
41
+ setScrollTop,
42
+ };
43
+
44
+ expect(readMonacoScrollRatio(editor)).toBe(0.5);
45
+ restoreMonacoScrollRatio(editor, 0.25);
46
+ expect(setScrollTop).toHaveBeenCalledWith(225);
47
+ });
48
+
49
+ it('reads and restores the WYSIWYG scroll container', () => {
50
+ const container = scrollElement(300, 1100, 100);
51
+ const editorDom = document.createElement('div');
52
+ container.append(editorDom);
53
+ const editor = { view: { dom: editorDom } };
54
+
55
+ expect(readWysiwygScrollRatio(editor)).toBe(0.3);
56
+ expect(restoreWysiwygScrollRatio(editor, 0.8)).toBe(true);
57
+ expect(container.scrollTop).toBe(800);
58
+ });
59
+
60
+ it('declines WYSIWYG restoration when the editor is detached', () => {
61
+ const editor = { view: { dom: document.createElement('div') } };
62
+ expect(readWysiwygScrollRatio(editor)).toBeNull();
63
+ expect(restoreWysiwygScrollRatio(editor, 0.5)).toBe(false);
64
+ });
65
+ });
@@ -13,9 +13,10 @@
13
13
  * the props it receives so we can assert `monacoTheme` reaches it.
14
14
  */
15
15
  import { describe, it, expect, vi, beforeEach } from 'vitest';
16
- import { fireEvent, render, screen, waitFor } from '@testing-library/react';
16
+ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
17
17
  import type { RawEditorProps } from '../RawEditor';
18
18
  import type { MediaEntry, MediaProvider } from '@bendyline/squisq/schemas';
19
+ import { useMediaProvider } from '@bendyline/squisq-react';
19
20
 
20
21
  // Records the props the shell passes to RawEditor on each render.
21
22
  const rawEditorProps: RawEditorProps[] = [];
@@ -42,6 +43,13 @@ function MarkdownSourceProbe() {
42
43
  return <pre data-testid="markdown-source">{markdownSource}</pre>;
43
44
  }
44
45
 
46
+ function MediaProviderProbe({ expected }: { expected: MediaProvider }) {
47
+ const provider = useMediaProvider();
48
+ return (
49
+ <span data-testid="media-context-probe">{provider === expected ? 'same' : 'missing'}</span>
50
+ );
51
+ }
52
+
45
53
  function mediaProviderWith(count: number): MediaProvider {
46
54
  const entries: MediaEntry[] = Array.from({ length: count }, (_, i) => ({
47
55
  name: `file-${i + 1}.png`,
@@ -184,6 +192,61 @@ describe('RawEditor monacoTheme prop', () => {
184
192
  });
185
193
  });
186
194
 
195
+ describe('<EditorShell> instance boundaries', () => {
196
+ it('provides its mediaProvider to React preview/layer consumers', async () => {
197
+ const provider = mediaProviderWith(0);
198
+ await act(async () => {
199
+ render(
200
+ <EditorShell
201
+ initialMarkdown="# hi"
202
+ initialView="raw"
203
+ mediaProvider={provider}
204
+ toolbarSlotLeft={<MediaProviderProbe expected={provider} />}
205
+ />,
206
+ );
207
+ });
208
+ expect(screen.getByTestId('media-context-probe').textContent).toBe('same');
209
+ });
210
+
211
+ it('keeps view shortcuts inside the editor that received the key', async () => {
212
+ const { container } = render(
213
+ <>
214
+ <EditorShell initialMarkdown="# first" initialView="raw" />
215
+ <EditorShell initialMarkdown="# second" initialView="raw" />
216
+ </>,
217
+ );
218
+ const shells = container.querySelectorAll<HTMLElement>('.squisq-editor-shell');
219
+ fireEvent.keyDown(within(shells[1]).getByTestId('raw-editor-stub'), {
220
+ key: '1',
221
+ ctrlKey: true,
222
+ });
223
+ await waitFor(() => expect(within(shells[1]).getByTestId('wysiwyg-editor-stub')).toBeTruthy());
224
+ expect(within(shells[0]).getByTestId('raw-editor-stub')).toBeTruthy();
225
+ });
226
+
227
+ it('ignores file drops in readOnly mode', async () => {
228
+ const { container } = render(
229
+ <EditorShell
230
+ initialMarkdown="# original"
231
+ initialView="raw"
232
+ readOnly
233
+ toolbarSlotRight={<MarkdownSourceProbe />}
234
+ />,
235
+ );
236
+ const shell = container.querySelector<HTMLElement>('.squisq-editor-shell')!;
237
+ const file = new File(['# replacement'], 'replacement.md', { type: 'text/markdown' });
238
+ fireEvent.drop(shell, {
239
+ dataTransfer: {
240
+ types: ['Files'],
241
+ files: [file],
242
+ items: [{ kind: 'file', type: 'text/markdown', getAsFile: () => file }],
243
+ },
244
+ });
245
+ await act(async () => Promise.resolve());
246
+ expect(screen.getByTestId('markdown-source').textContent).toBe('# original');
247
+ });
248
+ });
249
+
187
250
  describe('<EditorShell> Files badge', () => {
188
251
  it('shows the mediaProvider file count on the paperclip button', async () => {
189
252
  const { container } = render(
@@ -231,6 +294,25 @@ describe('<EditorShell> Files badge', () => {
231
294
  expect(container.querySelector('.squisq-toolbar-files-badge')?.textContent).toBe('1');
232
295
  });
233
296
 
297
+ it('hides .gitignore entries from the Files panel and its count', async () => {
298
+ const { provider } = mutableMediaProviderWith([
299
+ { name: '.gitignore', mimeType: 'text/plain', size: 11 },
300
+ { name: 'notes_files/.gitignore', mimeType: 'text/plain', size: 11 },
301
+ { name: 'notes_files/hero.png', mimeType: 'image/png', size: 1024 },
302
+ ]);
303
+
304
+ render(<EditorShell initialMarkdown="# hi" initialView="raw" mediaProvider={provider} />);
305
+
306
+ await waitFor(() => {
307
+ expect(screen.getByLabelText('Toggle Files panel, 1 file')).toBeTruthy();
308
+ });
309
+ fireEvent.click(screen.getByLabelText('Toggle Files panel, 1 file'));
310
+
311
+ expect(await screen.findByText('hero.png')).toBeTruthy();
312
+ expect(screen.queryByText('.gitignore')).toBeNull();
313
+ expect(screen.getByText('Files (1)')).toBeTruthy();
314
+ });
315
+
234
316
  it('removes a media file and matching markdown refs from the files context menu', async () => {
235
317
  const { provider, removed } = mutableMediaProviderWith([
236
318
  {
@@ -338,6 +420,28 @@ describe('<Toolbar> Files badge', () => {
338
420
  });
339
421
  expect(container.querySelector('.squisq-toolbar-files-badge')?.textContent).toBe('2');
340
422
  });
423
+
424
+ it('excludes .gitignore from a self-scanned count', async () => {
425
+ const { provider } = mutableMediaProviderWith([
426
+ { name: '.gitignore', mimeType: 'text/plain', size: 11 },
427
+ { name: 'hero.png', mimeType: 'image/png', size: 1024 },
428
+ ]);
429
+ const { container } = render(
430
+ <EditorProvider
431
+ initialMarkdown="# hi"
432
+ initialView="raw"
433
+ mediaProvider={provider}
434
+ allowRecording={false}
435
+ >
436
+ <Toolbar onToggleFiles={() => {}} />
437
+ </EditorProvider>,
438
+ );
439
+
440
+ await waitFor(() => {
441
+ expect(screen.getByLabelText('Toggle Files panel, 1 file')).toBeTruthy();
442
+ });
443
+ expect(container.querySelector('.squisq-toolbar-files-badge')?.textContent).toBe('1');
444
+ });
341
445
  });
342
446
 
343
447
  describe('<Toolbar> Insert menu', () => {
@@ -360,4 +464,22 @@ describe('<Toolbar> Insert menu', () => {
360
464
  );
361
465
  });
362
466
  });
467
+
468
+ it('adds an explicit authored timeline fence from the Insert menu', async () => {
469
+ render(
470
+ <EditorProvider initialMarkdown="Intro" initialView="raw" allowRecording={false}>
471
+ <Toolbar />
472
+ <MarkdownSourceProbe />
473
+ </EditorProvider>,
474
+ );
475
+
476
+ fireEvent.click(screen.getByLabelText('Insert'));
477
+ fireEvent.click(await screen.findByRole('menuitem', { name: /Timeline/i }));
478
+
479
+ await waitFor(() => {
480
+ expect(screen.getByTestId('markdown-source').textContent).toContain(
481
+ '```timeline\nMilestones: ● Start',
482
+ );
483
+ });
484
+ });
363
485
  });
@@ -1,43 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { fireEvent, render, screen } from '@testing-library/react';
3
- import { EmojiPicker } from '../EmojiPicker';
4
- import {
5
- EMOJI_CATEGORIES,
6
- ALL_EMOJIS,
7
- searchEmojis,
8
- PICKER_CATEGORIES,
9
- ALL_PICKER_ENTRIES,
10
- searchPickerEntries,
11
- } from '../emojiData';
12
-
13
- describe('emojiData — legacy emoji exports', () => {
14
- it('exposes the standard CLDR category buckets', () => {
15
- const ids = EMOJI_CATEGORIES.map((c) => c.id);
16
- expect(ids).toEqual([
17
- 'smileys',
18
- 'people',
19
- 'nature',
20
- 'food',
21
- 'travel',
22
- 'activities',
23
- 'objects',
24
- 'symbols',
25
- 'flags',
26
- ]);
27
- });
28
-
29
- it('each entry has a non-empty char and name', () => {
30
- for (const entry of ALL_EMOJIS) {
31
- expect(entry.char.length).toBeGreaterThan(0);
32
- expect(entry.name.length).toBeGreaterThan(0);
33
- }
34
- });
35
-
36
- it('searchEmojis matches by name (case-insensitive)', () => {
37
- const results = searchEmojis('HEART');
38
- expect(results.some((e) => e.char === '❤️')).toBe(true);
39
- });
40
- });
3
+ import { EmojiPicker, PICKER_CATEGORIES, ALL_PICKER_ENTRIES, searchPickerEntries } from '../index';
41
4
 
42
5
  describe('emojiData — unified picker entries', () => {
43
6
  it('appends FA Brands, Solid, and Regular categories after the emoji buckets', () => {
@@ -78,12 +41,9 @@ describe('EmojiPicker', () => {
78
41
 
79
42
  it('renders all emoji + FA tabs when open', () => {
80
43
  render(<EmojiPicker open onSelect={() => {}} onClose={() => {}} />);
81
- for (const cat of EMOJI_CATEGORIES) {
44
+ for (const cat of PICKER_CATEGORIES) {
82
45
  expect(screen.getByRole('tab', { name: cat.label })).toBeTruthy();
83
46
  }
84
- expect(screen.getByRole('tab', { name: 'Brands' })).toBeTruthy();
85
- expect(screen.getByRole('tab', { name: 'Solid' })).toBeTruthy();
86
- expect(screen.getByRole('tab', { name: 'Regular' })).toBeTruthy();
87
47
  });
88
48
 
89
49
  it('fires onSelect with an emoji PickerEntry when an emoji tile is clicked', () => {
@@ -4,7 +4,6 @@ import {
4
4
  setHeadingLineTransition,
5
5
  readBlockAttrsTransition,
6
6
  setHeadingAttrsTransition,
7
- setBlockAttrsTransition,
8
7
  EMPTY_TRANSITION,
9
8
  } from '../headingTransition';
10
9
 
@@ -174,15 +173,4 @@ describe('block-attrs (WYSIWYG) helpers', () => {
174
173
  templateParams: null,
175
174
  });
176
175
  });
177
-
178
- it('legacy single-channel writer still writes a fresh inner when none exists', () => {
179
- expect(setBlockAttrsTransition(null, { type: 'fade', direction: '', duration: '' })).toBe(
180
- 'transition=fade',
181
- );
182
- });
183
-
184
- it('legacy single-channel writer preserves the id and clears to null when emptied', () => {
185
- expect(setBlockAttrsTransition('transition=fade', EMPTY_TRANSITION)).toBeNull();
186
- expect(setBlockAttrsTransition('#intro transition=fade', EMPTY_TRANSITION)).toBe('#intro');
187
- });
188
176
  });
@@ -182,8 +182,10 @@ describe('<EditorShell> image-edit modal wiring', () => {
182
182
 
183
183
  expect(screen.queryByTestId('image-edit-modal')).toBeNull();
184
184
 
185
+ const trigger = screen.getByTestId('trigger');
186
+ trigger.focus();
185
187
  await act(async () => {
186
- fireEvent.click(screen.getByTestId('trigger'));
188
+ fireEvent.click(trigger);
187
189
  });
188
190
 
189
191
  await waitFor(() => {
@@ -191,6 +193,7 @@ describe('<EditorShell> image-edit modal wiring', () => {
191
193
  });
192
194
  // Shows the relative path in the header.
193
195
  expect(screen.getByText('hero.png')).toBeTruthy();
196
+ expect(document.activeElement).toBe(screen.getByTestId('image-edit-modal-close'));
194
197
 
195
198
  await act(async () => {
196
199
  fireEvent.click(screen.getByTestId('image-edit-modal-close'));
@@ -198,6 +201,7 @@ describe('<EditorShell> image-edit modal wiring', () => {
198
201
  await waitFor(() => {
199
202
  expect(screen.queryByTestId('image-edit-modal')).toBeNull();
200
203
  });
204
+ expect(document.activeElement).toBe(screen.getByTestId('trigger'));
201
205
  });
202
206
 
203
207
  it('opens the modal with a transient sidecar when only a mediaProvider is wired (no host container)', async () => {