@bendyline/squisq-editor-react 1.6.2 → 2.0.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 (218) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1275 -183
  3. package/dist/index.js +20345 -12777
  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 +1607 -241
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +39 -33
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +349 -261
  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 +362 -89
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/RecorderEntry.tsx +2 -0
  23. package/src/StatusBar.tsx +5 -2
  24. package/src/TemplateAnnotation.ts +12 -3
  25. package/src/TemplatePicker.tsx +75 -21
  26. package/src/ThemeCustomizerPanel.tsx +8 -0
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +357 -51
  30. package/src/TransitionPicker.tsx +36 -4
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/WysiwygEditor.tsx +124 -91
  33. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  34. package/src/__tests__/blockTagActivity.test.ts +183 -0
  35. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  36. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  37. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  38. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  39. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +51 -1
  42. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  43. package/src/__tests__/editorScrollSync.test.ts +65 -0
  44. package/src/__tests__/editorShellProps.test.tsx +123 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +0 -12
  47. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  48. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  49. package/src/__tests__/imageEditorShell.test.tsx +15 -1
  50. package/src/__tests__/jsonEditor.test.tsx +56 -1
  51. package/src/__tests__/layersPanel.test.tsx +75 -5
  52. package/src/__tests__/majorApiTypes.test.ts +26 -0
  53. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  54. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  55. package/src/__tests__/narrationSave.test.ts +148 -0
  56. package/src/__tests__/outlinePanel.test.tsx +93 -3
  57. package/src/__tests__/outlineSource.test.ts +217 -0
  58. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  59. package/src/__tests__/presentationMode.test.tsx +290 -0
  60. package/src/__tests__/previewControls.test.tsx +382 -5
  61. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  62. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  63. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  64. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  65. package/src/__tests__/recorderTheme.test.tsx +42 -0
  66. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  67. package/src/__tests__/selectionConversions.test.ts +80 -0
  68. package/src/__tests__/statusBar.test.tsx +27 -0
  69. package/src/__tests__/teleprompter.test.tsx +190 -0
  70. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  71. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  72. package/src/__tests__/tiptapBridge.test.ts +71 -7
  73. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  74. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  75. package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
  76. package/src/__tests__/transformStyleId.test.ts +13 -0
  77. package/src/__tests__/useImageEditor.test.tsx +67 -0
  78. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  79. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  80. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  81. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  82. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  83. package/src/asciiDiagram/AsciiDiagramWidget.tsx +254 -0
  84. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  85. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  86. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  87. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +284 -0
  88. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  89. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  90. package/src/asciiDiagram/asciiDiagramCommands.ts +174 -0
  91. package/src/asciiDiagram/asciiDiagramData.ts +149 -0
  92. package/src/asciiDiagram/asciiDiagramOps.ts +238 -0
  93. package/src/asciiDiagram/asciiPaste.ts +19 -0
  94. package/src/blockTagActivity.ts +233 -0
  95. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  96. package/src/buildPreviewDoc.ts +19 -12
  97. package/src/codeContext/types.ts +1 -1
  98. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  99. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  100. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  101. package/src/customTemplates/__tests__/library.test.ts +8 -0
  102. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  103. package/src/customTemplates/designer.css +158 -99
  104. package/src/customTemplates/library.ts +16 -3
  105. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  106. package/src/customThemes/CustomThemeContext.tsx +6 -0
  107. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  108. package/src/customThemes/ImportThemeSection.tsx +178 -0
  109. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  110. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  111. package/src/customThemes/customThemeLibrary.ts +7 -4
  112. package/src/customThemes/index.ts +7 -1
  113. package/src/customThemes/themeDraft.ts +23 -7
  114. package/src/diagram/DiagramCanvas.tsx +25 -6
  115. package/src/diagram/types.ts +35 -0
  116. package/src/editorScrollSync.ts +73 -0
  117. package/src/emojiData.ts +3 -23
  118. package/src/frontmatterSettings.ts +23 -0
  119. package/src/headingTransition.ts +0 -15
  120. package/src/imageEditor/CanvasSurface.tsx +29 -23
  121. package/src/imageEditor/LayersPanel.tsx +78 -3
  122. package/src/imageEditor/Toolbar.tsx +5 -1
  123. package/src/imageEditor/icons.tsx +4 -0
  124. package/src/imageEditor/image-editor.css +75 -0
  125. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  126. package/src/imageEditor/useImageEditor.ts +71 -14
  127. package/src/index.ts +198 -20
  128. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  129. package/src/jsonEditor/RenderNode.tsx +24 -3
  130. package/src/jsonEditor/editors.tsx +86 -19
  131. package/src/mediaEntries.ts +12 -0
  132. package/src/monaco.ts +35 -0
  133. package/src/monacoWorkers.ts +89 -0
  134. package/src/outlineSource.ts +171 -0
  135. package/src/presentation/PresentationMode.tsx +596 -0
  136. package/src/rawEditorIsolation.ts +18 -0
  137. package/src/recorder/RecorderButton.tsx +9 -1
  138. package/src/recorder/RecorderModal.tsx +84 -41
  139. package/src/recorder/RecorderPanel.tsx +9 -1
  140. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  141. package/src/resolveBlockVisual.ts +10 -4
  142. package/src/scene/Scene.tsx +48 -18
  143. package/src/scene/SceneBlockExtension.ts +17 -10
  144. package/src/scene/SceneBlockWidget.tsx +5 -1
  145. package/src/scene/SceneViewport.tsx +7 -3
  146. package/src/scene/ShapePalette.tsx +17 -2
  147. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  148. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  149. package/src/scene/__tests__/sceneIsolation.test.tsx +119 -0
  150. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  151. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  152. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  153. package/src/scene/commands/SceneCommand.ts +17 -3
  154. package/src/scene/index.ts +1 -6
  155. package/src/scene/layers/DiagramEdges.tsx +21 -7
  156. package/src/scene/layers/edgeGeometry.ts +9 -1
  157. package/src/scene/layers/nodeCard.tsx +27 -8
  158. package/src/scene/scene.css +5 -2
  159. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  160. package/src/scene/text/sceneTextChannel.ts +26 -20
  161. package/src/scene/text/sceneTextConfig.ts +4 -0
  162. package/src/scene/tools/ConnectTool.ts +13 -4
  163. package/src/scene/tools/SceneTool.ts +8 -0
  164. package/src/scene/tools/SelectTool.ts +39 -23
  165. package/src/scene/tools/ShapeTool.ts +2 -3
  166. package/src/selectionConversions.ts +155 -0
  167. package/src/styles/ascii-diagram.css +79 -0
  168. package/src/styles/ascii-timeline.css +596 -0
  169. package/src/styles/editor.css +736 -120
  170. package/src/styles/index.css +3 -0
  171. package/src/styles/tree-view.css +189 -0
  172. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  173. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  174. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  175. package/src/teleprompter/TeleprompterView.tsx +338 -0
  176. package/src/teleprompter/canvasRenderer.ts +161 -0
  177. package/src/teleprompter/floatingWindow.ts +352 -0
  178. package/src/teleprompter/index.ts +61 -0
  179. package/src/teleprompter/pcmWorklet.ts +62 -0
  180. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  181. package/src/teleprompter/recording/narrationSave.ts +134 -0
  182. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  183. package/src/teleprompter/scrollModel.ts +85 -0
  184. package/src/teleprompter/teleprompterTheme.ts +303 -0
  185. package/src/teleprompter/types.ts +38 -0
  186. package/src/teleprompter/useFloatingWindow.ts +74 -0
  187. package/src/teleprompter/useMicAnalysis.ts +211 -0
  188. package/src/teleprompter/useTeleprompter.ts +421 -0
  189. package/src/templateContentPreviewResolver.ts +12 -5
  190. package/src/timeline/TimelineEditorWidget.tsx +861 -0
  191. package/src/timeline/TimelineViewExtension.ts +252 -0
  192. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +414 -0
  193. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  194. package/src/timeline/__tests__/timelineCommands.test.ts +359 -0
  195. package/src/timeline/__tests__/timelineOps.test.ts +277 -0
  196. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  197. package/src/timeline/timelineCommands.ts +421 -0
  198. package/src/timeline/timelineData.ts +54 -0
  199. package/src/timeline/timelineOps.ts +381 -0
  200. package/src/timeline/timelinePaste.ts +8 -0
  201. package/src/tiptapBridge.ts +47 -21
  202. package/src/transformStyleId.ts +17 -0
  203. package/src/treeview/TreeOutlineWidget.tsx +390 -0
  204. package/src/treeview/TreeViewExtension.ts +250 -0
  205. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  206. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  207. package/src/treeview/__tests__/treeOps.test.ts +177 -0
  208. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  209. package/src/treeview/__tests__/treeViewCommands.test.ts +179 -0
  210. package/src/treeview/treeOps.ts +231 -0
  211. package/src/treeview/treePaste.ts +13 -0
  212. package/src/treeview/treeViewCommands.ts +99 -0
  213. package/src/treeview/treeViewData.ts +41 -0
  214. package/src/useMonacoLoader.ts +11 -34
  215. package/src/wysiwygImageUpload.ts +113 -0
  216. package/src/diagram/DiagramExtension.ts +0 -209
  217. package/src/diagram/DiagramWidget.tsx +0 -272
  218. package/src/diagram/useDiagramData.ts +0 -126
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { Block, Doc } from '@bendyline/squisq/schemas';
3
+ import { buildDocumentPreviewMarkdown } from '../buildDocumentPreviewMarkdown';
4
+
5
+ function docWith(blocks: Block[]): Doc {
6
+ return {
7
+ articleId: 'summary-doc',
8
+ duration: 10,
9
+ blocks,
10
+ audio: { segments: [] },
11
+ frontmatter: { title: 'Quarterly Report' },
12
+ };
13
+ }
14
+
15
+ describe('buildDocumentPreviewMarkdown', () => {
16
+ it('serializes programmatic transform blocks as readable document content', () => {
17
+ const markdown = buildDocumentPreviewMarkdown(
18
+ docWith([
19
+ {
20
+ id: 'transform-stat',
21
+ template: 'statHighlight',
22
+ startTime: 0,
23
+ duration: 5,
24
+ audioSegment: 0,
25
+ stat: '42%',
26
+ description: 'Year-over-year growth',
27
+ } as Block,
28
+ {
29
+ id: 'transform-list',
30
+ template: 'list',
31
+ startTime: 5,
32
+ duration: 5,
33
+ audioSegment: 0,
34
+ title: 'Priorities',
35
+ items: ['Reliability', 'Speed'],
36
+ } as Block,
37
+ ]),
38
+ );
39
+
40
+ expect(markdown).toContain('# Quarterly Report');
41
+ expect(markdown).toContain('## 42%');
42
+ expect(markdown).toContain('Year-over-year growth');
43
+ expect(markdown).toContain('## Priorities');
44
+ expect(markdown).toContain('Reliability');
45
+ expect(markdown).toContain('Speed');
46
+ expect(markdown).not.toContain('{[statHighlight');
47
+ });
48
+
49
+ it('preserves authored blocks that a conservative transform kept', () => {
50
+ const markdown = buildDocumentPreviewMarkdown(
51
+ docWith([
52
+ {
53
+ id: 'authored',
54
+ startTime: 0,
55
+ duration: 5,
56
+ audioSegment: 0,
57
+ sourceHeading: {
58
+ type: 'heading',
59
+ depth: 2,
60
+ children: [{ type: 'text', value: 'Context' }],
61
+ },
62
+ contents: [
63
+ {
64
+ type: 'paragraph',
65
+ children: [{ type: 'text', value: 'Original supporting detail.' }],
66
+ },
67
+ ],
68
+ },
69
+ ]),
70
+ );
71
+
72
+ expect(markdown).toContain('## Context');
73
+ expect(markdown).toContain('Original supporting detail.');
74
+ });
75
+ });
@@ -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
+ });
@@ -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
  });
@@ -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';
@@ -109,6 +123,28 @@ describe('DocumentSettingsDialog', () => {
109
123
  expect(next).not.toContain('squisq-theme');
110
124
  });
111
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
+
112
148
  it('writes squisq-transform when a transform is picked', () => {
113
149
  const onSave = vi.fn();
114
150
  open('# Doc\n', onSave);
@@ -122,6 +158,20 @@ describe('DocumentSettingsDialog', () => {
122
158
  expect(next).toContain(`squisq-transform: ${first.value}`);
123
159
  });
124
160
 
161
+ it('canonicalizes a stored dataDriven transform id', () => {
162
+ const onSave = vi.fn();
163
+ open('---\ntransform-style: dataDriven\n---\n\n# Doc\n', onSave);
164
+
165
+ const select = screen.getByLabelText('Transform') as HTMLSelectElement;
166
+ expect(select.value).toBe('data-driven');
167
+ clickSave();
168
+
169
+ const next = onSave.mock.calls[0][0] as string;
170
+ expect(next).toContain('squisq-transform: data-driven');
171
+ expect(next).not.toContain('dataDriven');
172
+ expect(next).not.toContain('transform-style');
173
+ });
174
+
125
175
  it('writes squisq-captions when picked', () => {
126
176
  const onSave = vi.fn();
127
177
  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
+ });