@bendyline/squisq-editor-react 1.5.3 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/README.md +57 -10
  2. package/dist/index.d.ts +1114 -52
  3. package/dist/index.js +18446 -8050
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
  6. package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
  7. package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
  8. package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
  9. package/dist/styles/index.css +13867 -0
  10. package/package.json +15 -7
  11. package/src/BlockCardView.tsx +121 -0
  12. package/src/BlockPreviewPanel.tsx +69 -0
  13. package/src/BlockPropertiesPopover.tsx +191 -0
  14. package/src/EditorContext.tsx +165 -16
  15. package/src/EditorShell.tsx +265 -144
  16. package/src/FolderView.tsx +131 -0
  17. package/src/Icon.tsx +26 -0
  18. package/src/ImageEditor.tsx +69 -22
  19. package/src/OutlinePanel.tsx +64 -7
  20. package/src/PlainHtmlPreview.tsx +30 -3
  21. package/src/PreviewControls.tsx +491 -122
  22. package/src/PreviewPanel.tsx +15 -9
  23. package/src/RawEditor.tsx +179 -17
  24. package/src/RecorderEntry.tsx +9 -16
  25. package/src/TemplateAnnotation.ts +44 -0
  26. package/src/TemplatePicker.tsx +329 -54
  27. package/src/ThemeCustomizerPanel.tsx +30 -336
  28. package/src/ThemePicker.tsx +112 -3
  29. package/src/TimelineBlockPreview.tsx +37 -0
  30. package/src/TimelineTrack.tsx +671 -0
  31. package/src/Toolbar.tsx +544 -180
  32. package/src/Tooltip.tsx +22 -4
  33. package/src/TransitionPicker.tsx +351 -0
  34. package/src/VersionHistoryPanel.tsx +4 -16
  35. package/src/ViewMenuPanel.tsx +17 -14
  36. package/src/WysiwygEditor.tsx +161 -65
  37. package/src/__tests__/blockProperties.test.ts +92 -0
  38. package/src/__tests__/blockRange.test.ts +105 -0
  39. package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
  40. package/src/__tests__/codeContextSectionView.test.tsx +95 -0
  41. package/src/__tests__/codeContextZoneManager.test.ts +127 -0
  42. package/src/__tests__/createShapeLayer.test.ts +46 -0
  43. package/src/__tests__/diffContextSections.test.ts +39 -0
  44. package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
  45. package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
  46. package/src/__tests__/editorShellProps.test.tsx +96 -0
  47. package/src/__tests__/embeddedMedia.test.ts +48 -0
  48. package/src/__tests__/headingTransition.test.ts +138 -0
  49. package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
  50. package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
  51. package/src/__tests__/previewControls.test.tsx +70 -0
  52. package/src/__tests__/recorderMediaInsert.test.ts +86 -0
  53. package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
  54. package/src/__tests__/templatePickerMetadata.test.ts +32 -0
  55. package/src/__tests__/timelineSource.test.ts +134 -0
  56. package/src/__tests__/tiptapBridge.test.ts +92 -0
  57. package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
  58. package/src/__tests__/tooltip.test.tsx +72 -0
  59. package/src/__tests__/transitionCatalog.test.ts +64 -0
  60. package/src/__tests__/useBlockNavigator.test.tsx +67 -0
  61. package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
  62. package/src/__tests__/useMediaRecorder.test.ts +41 -0
  63. package/src/__tests__/useTimelineClock.test.ts +21 -0
  64. package/src/blockProperties.ts +88 -0
  65. package/src/blockRange.ts +132 -0
  66. package/src/buildPreviewDoc.ts +98 -9
  67. package/src/codeContext/CodeContextSectionView.tsx +124 -0
  68. package/src/codeContext/CodeContextZoneManager.ts +149 -0
  69. package/src/codeContext/CodeContextZones.tsx +121 -0
  70. package/src/codeContext/diffContextSections.ts +38 -0
  71. package/src/codeContext/types.ts +75 -0
  72. package/src/customTemplates/AddBin.tsx +126 -0
  73. package/src/customTemplates/CustomLayoutManager.tsx +233 -0
  74. package/src/customTemplates/CustomTemplateContext.tsx +182 -0
  75. package/src/customTemplates/LayerToolbar.tsx +580 -0
  76. package/src/customTemplates/ShapeGlyph.tsx +47 -0
  77. package/src/customTemplates/TemplateDesigner.tsx +430 -0
  78. package/src/customTemplates/__tests__/library.test.ts +88 -0
  79. package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
  80. package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
  81. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
  82. package/src/customTemplates/designer.css +673 -0
  83. package/src/customTemplates/index.ts +31 -0
  84. package/src/customTemplates/library.ts +97 -0
  85. package/src/customTemplates/normalizePositions.ts +75 -0
  86. package/src/customTemplates/shapeDefs.ts +131 -0
  87. package/src/customTemplates/thumbnail.tsx +63 -0
  88. package/src/customTemplates/tokenDefs.ts +60 -0
  89. package/src/customTemplates/useDocCustomTemplates.ts +52 -0
  90. package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
  91. package/src/customThemes/CustomThemeContext.tsx +179 -0
  92. package/src/customThemes/CustomThemeDialog.tsx +286 -0
  93. package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
  94. package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
  95. package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
  96. package/src/customThemes/customThemeLibrary.ts +97 -0
  97. package/src/customThemes/index.ts +31 -0
  98. package/src/customThemes/themeControls.tsx +229 -0
  99. package/src/customThemes/themeDraft.ts +272 -0
  100. package/src/customThemes/useDocCustomThemes.ts +49 -0
  101. package/src/diagram/DiagramCanvas.tsx +240 -0
  102. package/src/diagram/DiagramExtension.ts +209 -0
  103. package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
  104. package/src/diagram/DiagramWidget.tsx +270 -0
  105. package/src/diagram/diagramCommands.ts +604 -0
  106. package/src/diagram/diagramConstants.ts +17 -0
  107. package/src/diagram/useDiagramData.ts +126 -0
  108. package/src/embeddedMedia.ts +78 -0
  109. package/src/frontmatter.ts +29 -0
  110. package/src/headingTransition.ts +231 -0
  111. package/src/imageEditor/CanvasSurface.tsx +383 -88
  112. package/src/imageEditor/PropertiesPanel.tsx +47 -1
  113. package/src/imageEditor/Toolbar.tsx +229 -16
  114. package/src/imageEditor/createShapeLayer.ts +280 -0
  115. package/src/imageEditor/icons.tsx +34 -114
  116. package/src/imageEditor/image-editor.css +54 -5
  117. package/src/imageEditor/state.ts +23 -3
  118. package/src/index.ts +109 -1
  119. package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
  120. package/src/recorder/RecorderModal.tsx +120 -53
  121. package/src/recorder/RecorderPanel.tsx +2 -26
  122. package/src/recorder/hooks/useMediaRecorder.ts +17 -11
  123. package/src/recorder/insertMediaBlock.ts +30 -0
  124. package/src/resolveBlockVisual.ts +33 -0
  125. package/src/scene/Scene.tsx +540 -0
  126. package/src/scene/SceneBlockExtension.ts +198 -0
  127. package/src/scene/SceneBlockToolbar.tsx +201 -0
  128. package/src/scene/SceneBlockWidget.tsx +434 -0
  129. package/src/scene/ScenePropsBar.tsx +85 -0
  130. package/src/scene/SceneSelection.tsx +107 -0
  131. package/src/scene/SceneViewport.tsx +102 -0
  132. package/src/scene/ShapePalette.tsx +181 -0
  133. package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
  134. package/src/scene/__tests__/bezierEdit.test.ts +85 -0
  135. package/src/scene/__tests__/blockLayers.test.ts +57 -0
  136. package/src/scene/__tests__/shapeLayers.test.ts +106 -0
  137. package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
  138. package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
  139. package/src/scene/adapters/DiagramAdapter.ts +168 -0
  140. package/src/scene/adapters/DrawingAdapter.ts +415 -0
  141. package/src/scene/adapters/LayoutAdapter.ts +310 -0
  142. package/src/scene/adapters/blockLayers.ts +159 -0
  143. package/src/scene/commands/SceneCommand.ts +70 -0
  144. package/src/scene/commands/drawingCommands.ts +318 -0
  145. package/src/scene/commands/layoutCommands.ts +301 -0
  146. package/src/scene/hooks/useSceneHitTest.ts +105 -0
  147. package/src/scene/hooks/useScenePanZoom.ts +147 -0
  148. package/src/scene/hooks/useSceneSelection.ts +62 -0
  149. package/src/scene/index.ts +95 -0
  150. package/src/scene/layers/DiagramEdges.tsx +127 -0
  151. package/src/scene/layers/edgeGeometry.ts +77 -0
  152. package/src/scene/layers/nodeCard.tsx +145 -0
  153. package/src/scene/layers/renderLayer.tsx +70 -0
  154. package/src/scene/layers/shapeLayers.ts +201 -0
  155. package/src/scene/paths/bezierEdit.ts +208 -0
  156. package/src/scene/scene.css +649 -0
  157. package/src/scene/text/SceneTextOverlay.tsx +161 -0
  158. package/src/scene/text/sceneTextChannel.ts +40 -0
  159. package/src/scene/text/sceneTextConfig.ts +27 -0
  160. package/src/scene/text/sceneTiptap.ts +36 -0
  161. package/src/scene/text/useSceneTextEditing.ts +39 -0
  162. package/src/scene/tools/ConnectTool.ts +111 -0
  163. package/src/scene/tools/DrawingConnectTool.ts +161 -0
  164. package/src/scene/tools/PathTool.ts +158 -0
  165. package/src/scene/tools/PlaceTool.ts +47 -0
  166. package/src/scene/tools/SceneTool.ts +75 -0
  167. package/src/scene/tools/SelectTool.ts +284 -0
  168. package/src/scene/tools/ShapeTool.ts +144 -0
  169. package/src/scene/tools/TextTool.ts +72 -0
  170. package/src/scene/tools/TokenTool.ts +95 -0
  171. package/src/scene/tools/createDrawShapeTool.ts +82 -0
  172. package/src/styles/code-context.css +155 -0
  173. package/src/styles/diagram.css +183 -0
  174. package/src/styles/editor.css +1708 -150
  175. package/src/styles/folder-view.css +210 -0
  176. package/src/styles/image-edit-affordance.css +2 -2
  177. package/src/styles/index.css +5 -0
  178. package/src/timelineSource.ts +244 -0
  179. package/src/tiptapBridge.ts +115 -34
  180. package/src/tooltipPlacement.ts +13 -0
  181. package/src/transitionCatalog.ts +159 -0
  182. package/src/types/monaco-shims.d.ts +10 -0
  183. package/src/useBlockNavigator.ts +153 -0
  184. package/src/useMonacoLoader.ts +23 -1
  185. package/src/useTimelineClock.ts +76 -0
  186. package/src/utils/dropUtils.ts +1 -1
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { fireEvent, render } from '@testing-library/react';
6
+ import { CodeContextSectionView } from '../codeContext/CodeContextSectionView';
7
+
8
+ const noop = () => {};
9
+
10
+ const baseSection = {
11
+ id: 'foo@10',
12
+ summaryMarkdown: '**foo** — does things · ↓2 imported-by',
13
+ markdown: 'Body text with [`a.ts`](gezel-nav:src%2Fa.ts) and [line 4](#L4).',
14
+ };
15
+
16
+ function renderView(over: Partial<Parameters<typeof CodeContextSectionView>[0]> = {}) {
17
+ const props = {
18
+ section: baseSection,
19
+ expanded: false,
20
+ onToggle: vi.fn(),
21
+ linkSchemes: ['gezel-nav'] as const,
22
+ onLinkClick: vi.fn(),
23
+ onRevealLine: vi.fn(),
24
+ onMeasure: noop,
25
+ ...over,
26
+ };
27
+ const utils = render(<CodeContextSectionView {...props} />);
28
+ return { ...utils, props };
29
+ }
30
+
31
+ describe('<CodeContextSectionView>', () => {
32
+ it('renders the strip collapsed, body absent (lazy)', () => {
33
+ const { container } = renderView();
34
+ expect(container.querySelector('.squisq-ccx-strip')).toBeTruthy();
35
+ expect(container.textContent).toContain('foo');
36
+ expect(container.querySelector('.squisq-ccx-body')).toBeNull();
37
+ });
38
+
39
+ it('strip click calls onToggle with the section id', () => {
40
+ const { container, props } = renderView();
41
+ fireEvent.click(container.querySelector('.squisq-ccx-strip')!);
42
+ expect(props.onToggle).toHaveBeenCalledWith('foo@10');
43
+ });
44
+
45
+ it('expanded body renders markdown with host-scheme links as real anchors', () => {
46
+ const { container } = renderView({ expanded: true });
47
+ const body = container.querySelector('.squisq-ccx-body')!;
48
+ expect(body.textContent).toContain('Body text');
49
+ const anchors = [...body.querySelectorAll('a')].map((a) => a.getAttribute('href'));
50
+ expect(anchors).toContain('gezel-nav:src%2Fa.ts');
51
+ expect(anchors).toContain('#L4');
52
+ });
53
+
54
+ it('expanded without markdown shows the loading row', () => {
55
+ const { container } = renderView({
56
+ expanded: true,
57
+ section: { ...baseSection, markdown: undefined },
58
+ });
59
+ expect(container.querySelector('.squisq-ccx-body--loading')?.textContent).toBe('Loading…');
60
+ });
61
+
62
+ it('link clicks are intercepted: handled by default, defaulted on false', () => {
63
+ const onLinkClick = vi.fn(() => undefined);
64
+ const { container } = renderView({ expanded: true, onLinkClick });
65
+ const nav = [...container.querySelectorAll('a')].find(
66
+ (a) => a.getAttribute('href') === 'gezel-nav:src%2Fa.ts',
67
+ )!;
68
+ const first = fireEvent.click(nav);
69
+ expect(onLinkClick).toHaveBeenCalledWith('gezel-nav:src%2Fa.ts', { sectionId: 'foo@10' });
70
+ expect(first).toBe(false); // preventDefault was called
71
+
72
+ onLinkClick.mockReturnValue(false as unknown as undefined);
73
+ const second = fireEvent.click(nav);
74
+ expect(second).toBe(true); // host declined — default navigation allowed
75
+ });
76
+
77
+ it('#L links reveal natively and never reach onLinkClick', () => {
78
+ const onLinkClick = vi.fn();
79
+ const onRevealLine = vi.fn();
80
+ const { container } = renderView({ expanded: true, onLinkClick, onRevealLine });
81
+ const line = [...container.querySelectorAll('a')].find(
82
+ (a) => a.getAttribute('href') === '#L4',
83
+ )!;
84
+ fireEvent.click(line);
85
+ expect(onRevealLine).toHaveBeenCalledWith(4);
86
+ expect(onLinkClick).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it('without linkSchemes, custom-scheme links render blocked (no anchor)', () => {
90
+ const { container } = renderView({ expanded: true, linkSchemes: undefined });
91
+ const anchors = [...container.querySelectorAll('a')].map((a) => a.getAttribute('href'));
92
+ expect(anchors).not.toContain('gezel-nav:src%2Fa.ts');
93
+ expect(container.querySelector('.squisq-md-link--blocked')).toBeTruthy();
94
+ });
95
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ *
4
+ * Exercises the zone manager against a fake Monaco editor with a recording
5
+ * changeViewZones accessor — batch semantics, delegate mutation on move and
6
+ * height changes, model-change rebuild, and dispose cleanup.
7
+ */
8
+ import { describe, expect, it, vi } from 'vitest';
9
+ import type { editor as MonacoEditorNs } from 'monaco-editor';
10
+ import { CodeContextZoneManager } from '../codeContext/CodeContextZoneManager';
11
+
12
+ type Zone = MonacoEditorNs.IViewZone & { ordinal?: number };
13
+
14
+ function fakeEditor() {
15
+ const zones = new Map<string, Zone>();
16
+ const layoutCalls: string[] = [];
17
+ let counter = 0;
18
+ let batches = 0;
19
+ let modelCb: (() => void) | null = null;
20
+ const accessor = {
21
+ addZone: (zone: Zone) => {
22
+ const id = `z${++counter}`;
23
+ zones.set(id, zone);
24
+ return id;
25
+ },
26
+ removeZone: (id: string) => {
27
+ zones.delete(id);
28
+ },
29
+ layoutZone: (id: string) => {
30
+ layoutCalls.push(id);
31
+ },
32
+ };
33
+ const editor = {
34
+ changeViewZones: (cb: (a: typeof accessor) => void) => {
35
+ batches++;
36
+ cb(accessor);
37
+ },
38
+ onDidChangeModel: (cb: () => void) => {
39
+ modelCb = cb;
40
+ return { dispose: vi.fn() };
41
+ },
42
+ } as unknown as MonacoEditorNs.IStandaloneCodeEditor;
43
+ return {
44
+ editor,
45
+ zones,
46
+ layoutCalls,
47
+ get batches() {
48
+ return batches;
49
+ },
50
+ fireModelChange: () => modelCb?.(),
51
+ };
52
+ }
53
+
54
+ describe('CodeContextZoneManager', () => {
55
+ it('creates zones above the anchor line in one batch, with dom nodes', () => {
56
+ const fake = fakeEditor();
57
+ const mgr = new CodeContextZoneManager(fake.editor);
58
+ mgr.sync([
59
+ { id: 'file', line: 0, ordinal: 0 },
60
+ { id: 'foo@10', line: 10, ordinal: 1 },
61
+ ]);
62
+ expect(fake.batches).toBe(1);
63
+ expect(fake.zones.size).toBe(2);
64
+ const list = [...fake.zones.values()];
65
+ expect(list.map((z) => z.afterLineNumber)).toEqual([0, 9]);
66
+ expect(list.map((z) => z.ordinal)).toEqual([0, 1]);
67
+ expect(list.every((z) => z.suppressMouseDown)).toBe(true);
68
+ expect(mgr.getDomNode('foo@10')?.className).toBe('squisq-ccx-zone');
69
+ });
70
+
71
+ it('sync with no changes performs no batch', () => {
72
+ const fake = fakeEditor();
73
+ const mgr = new CodeContextZoneManager(fake.editor);
74
+ mgr.sync([{ id: 'a', line: 3, ordinal: 1 }]);
75
+ mgr.sync([{ id: 'a', line: 3, ordinal: 1 }]);
76
+ expect(fake.batches).toBe(1);
77
+ });
78
+
79
+ it('moves mutate the delegate and layoutZone — dom node survives', () => {
80
+ const fake = fakeEditor();
81
+ const mgr = new CodeContextZoneManager(fake.editor);
82
+ mgr.sync([{ id: 'a', line: 3, ordinal: 1 }]);
83
+ const nodeBefore = mgr.getDomNode('a');
84
+ mgr.sync([{ id: 'a', line: 8, ordinal: 1 }]);
85
+ expect(fake.zones.size).toBe(1);
86
+ expect([...fake.zones.values()][0]!.afterLineNumber).toBe(7);
87
+ expect(fake.layoutCalls.length).toBe(1);
88
+ expect(mgr.getDomNode('a')).toBe(nodeBefore);
89
+ });
90
+
91
+ it('setHeight mutates heightInPx and lays out; sub-pixel deltas are ignored', () => {
92
+ const fake = fakeEditor();
93
+ const mgr = new CodeContextZoneManager(fake.editor);
94
+ mgr.sync([{ id: 'a', line: 3, ordinal: 1 }]);
95
+ mgr.setHeight('a', 120);
96
+ expect([...fake.zones.values()][0]!.heightInPx).toBe(120);
97
+ expect(fake.layoutCalls.length).toBe(1);
98
+ mgr.setHeight('a', 119.5); // ceils back to 120 — no relayout
99
+ expect(fake.layoutCalls.length).toBe(1);
100
+ });
101
+
102
+ it('a model change drops bookkeeping; the next sync recreates zones', () => {
103
+ const fake = fakeEditor();
104
+ const mgr = new CodeContextZoneManager(fake.editor);
105
+ const onChange = vi.fn();
106
+ mgr.onDidChangeZones(onChange);
107
+ mgr.sync([{ id: 'a', line: 3, ordinal: 1 }]);
108
+ fake.fireModelChange();
109
+ expect(onChange).toHaveBeenCalledTimes(2); // sync + model change
110
+ expect(mgr.getDomNode('a')).toBeUndefined();
111
+ mgr.sync([{ id: 'a', line: 3, ordinal: 1 }]);
112
+ expect(mgr.getDomNode('a')).toBeDefined();
113
+ });
114
+
115
+ it('dispose removes every zone and goes inert', () => {
116
+ const fake = fakeEditor();
117
+ const mgr = new CodeContextZoneManager(fake.editor);
118
+ mgr.sync([
119
+ { id: 'a', line: 1, ordinal: 1 },
120
+ { id: 'b', line: 2, ordinal: 2 },
121
+ ]);
122
+ mgr.dispose();
123
+ expect(fake.zones.size).toBe(0);
124
+ mgr.sync([{ id: 'c', line: 3, ordinal: 1 }]);
125
+ expect(fake.zones.size).toBe(0);
126
+ });
127
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createShapeLayer } from '../imageEditor/createShapeLayer';
3
+
4
+ describe('createShapeLayer', () => {
5
+ it('maps native primitives to ShapeLayer, centered on the drop point', () => {
6
+ const rect = createShapeLayer('rectangle', 100, 100);
7
+ expect(rect.type).toBe('shape');
8
+ if (rect.type === 'shape') expect(rect.content.shape).toBe('rect');
9
+ // 120×80 box centered at (100,100) → top-left (40, 60).
10
+ expect(rect.position).toMatchObject({ x: 40, y: 60, width: 120, height: 80 });
11
+
12
+ expect(createShapeLayer('circle', 0, 0).type).toBe('shape');
13
+ const line = createShapeLayer('line', 0, 0);
14
+ if (line.type === 'shape') expect(line.content.shape).toBe('line');
15
+ });
16
+
17
+ it('maps named shapes to a PathLayer carrying shapeKind + derived d', () => {
18
+ const diamond = createShapeLayer('diamond', 100, 100);
19
+ expect(diamond.type).toBe('path');
20
+ if (diamond.type === 'path') {
21
+ expect(diamond.content.shapeKind).toBe('diamond');
22
+ expect(diamond.content.d.length).toBeGreaterThan(0);
23
+ expect(diamond.content.fill).toBeTruthy();
24
+ }
25
+ });
26
+
27
+ it('maps the line arrow to a PathLayer with an end marker (no shapeKind)', () => {
28
+ const arrow = createShapeLayer('arrow', 50, 50);
29
+ expect(arrow.type).toBe('path');
30
+ if (arrow.type === 'path') {
31
+ expect(arrow.content.endMarker).toBe('arrow');
32
+ expect(arrow.content.shapeKind).toBeUndefined();
33
+ expect(arrow.content.fill).toBe('none');
34
+ }
35
+ });
36
+
37
+ it('maps the text shape to a TextLayer', () => {
38
+ const text = createShapeLayer('text', 10, 10);
39
+ expect(text.type).toBe('text');
40
+ });
41
+
42
+ it('prettifies multi-word kinds for the layer name', () => {
43
+ expect(createShapeLayer('arrow-right', 0, 0).name).toBe('Arrow Right');
44
+ expect(createShapeLayer('right-triangle', 0, 0).name).toBe('Right Triangle');
45
+ });
46
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { diffContextSections, type ZoneSpec } from '../codeContext/diffContextSections';
3
+
4
+ const spec = (id: string, line: number, ordinal = 0): ZoneSpec => ({ id, line, ordinal });
5
+
6
+ describe('diffContextSections', () => {
7
+ it('adds everything on first sync', () => {
8
+ const d = diffContextSections([], [spec('a', 1), spec('b', 5)]);
9
+ expect(d.add.map((z) => z.id)).toEqual(['a', 'b']);
10
+ expect(d.remove).toEqual([]);
11
+ expect(d.move).toEqual([]);
12
+ });
13
+
14
+ it('is a no-op for identical specs', () => {
15
+ const specs = [spec('a', 1, 1), spec('b', 5, 2)];
16
+ const d = diffContextSections(
17
+ specs,
18
+ specs.map((s) => ({ ...s })),
19
+ );
20
+ expect(d.add).toEqual([]);
21
+ expect(d.remove).toEqual([]);
22
+ expect(d.move).toEqual([]);
23
+ });
24
+
25
+ it('moves when a line or ordinal changes, removes vanished ids', () => {
26
+ const d = diffContextSections(
27
+ [spec('a', 1, 1), spec('b', 5, 2), spec('c', 9, 3)],
28
+ [spec('a', 2, 1), spec('b', 5, 4)],
29
+ );
30
+ expect(d.move.map((z) => z.id)).toEqual(['a', 'b']);
31
+ expect(d.remove).toEqual(['c']);
32
+ expect(d.add).toEqual([]);
33
+ });
34
+
35
+ it('duplicate ids in next: first occurrence wins, no double-add', () => {
36
+ const d = diffContextSections([], [spec('a', 1), spec('a', 7)]);
37
+ expect(d.add).toEqual([spec('a', 1)]);
38
+ });
39
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Drawing shape headings round-trip through the markdown ↔ Tiptap bridge.
3
+ *
4
+ * The drawing editor writes shapes as `### label {#id} {[shape …]}` child
5
+ * headings; these tests pin the editor's serialization seam — the same
6
+ * `data-template` / `data-template-params` / `data-block-attrs` attributes
7
+ * the DrawingAdapter reads back — without standing up a full editor.
8
+ */
9
+
10
+ import { describe, it, expect } from 'vitest';
11
+ import { markdownToTiptap, tiptapToMarkdown } from '../tiptapBridge';
12
+
13
+ const DRAWING = [
14
+ '## Org chart {[drawing]}',
15
+ '',
16
+ '### CEO {#ceo} {[rectangle x=21 y=25 width=100 height=100]}',
17
+ '',
18
+ '### reports to {[arrow from=ceo to=dev]}',
19
+ '',
20
+ '### Developer {#dev} {[rectangle x=21 y=190 width=100 height=100]}',
21
+ ].join('\n');
22
+
23
+ describe('Drawing shape heading round-trip', () => {
24
+ it('extracts a shape annotation into the template + block-attr data attributes', () => {
25
+ const html = markdownToTiptap('### CEO {#ceo} {[rectangle x=21 y=25 width=100 height=100]}');
26
+ expect(html).toContain('data-template="rectangle"');
27
+ expect(html).toContain('data-template-params="x=21 y=25 width=100 height=100"');
28
+ expect(html).toContain('data-block-attrs="#ceo"');
29
+ expect(html).not.toContain('{[rectangle'); // raw annotation text stripped
30
+ });
31
+
32
+ it('extracts a connector annotation (from/to) into data-template-params', () => {
33
+ const html = markdownToTiptap('### reports to {[arrow from=ceo to=dev]}');
34
+ expect(html).toContain('data-template="arrow"');
35
+ expect(html).toContain('data-template-params="from=ceo to=dev"');
36
+ });
37
+
38
+ it('round-trips a full drawing (shapes + connector) losslessly and idempotently', () => {
39
+ const back = tiptapToMarkdown(markdownToTiptap(DRAWING));
40
+ expect(back).toContain('## Org chart {[drawing]}');
41
+ expect(back).toContain('### CEO {#ceo} {[rectangle x=21 y=25 width=100 height=100]}');
42
+ expect(back).toContain('### reports to {[arrow from=ceo to=dev]}');
43
+ expect(back).toContain('### Developer {#dev} {[rectangle x=21 y=190 width=100 height=100]}');
44
+ // No base64 layers blob anywhere.
45
+ expect(back).not.toContain('layers=');
46
+ // Stable: a second pass reproduces the first exactly.
47
+ expect(tiptapToMarkdown(markdownToTiptap(back))).toBe(back);
48
+ });
49
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ *
4
+ * Prop-contract test for `codeContext`: the shell threads it to the
5
+ * CodeContextZones bridge in code mode only (mirrors the submitOnEnter
6
+ * threading tests). Heavy surfaces are stubbed.
7
+ */
8
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
9
+ import { render } from '@testing-library/react';
10
+ import type { CodeContext } from '../codeContext/types';
11
+
12
+ const zoneOptions: CodeContext[] = [];
13
+
14
+ vi.mock('../RawEditor', () => ({
15
+ RawEditor: () => <div data-testid="raw-editor-stub" />,
16
+ }));
17
+ vi.mock('../WysiwygEditor', () => ({
18
+ WysiwygEditor: () => <div data-testid="wysiwyg-editor-stub" />,
19
+ }));
20
+ vi.mock('../PreviewPanel', () => ({
21
+ PreviewPanel: () => <div data-testid="preview-stub" />,
22
+ }));
23
+ vi.mock('../codeContext/CodeContextZones', () => ({
24
+ CodeContextZones: ({ options }: { options: CodeContext }) => {
25
+ zoneOptions.push(options);
26
+ return <div data-testid="code-context-stub" />;
27
+ },
28
+ }));
29
+
30
+ import { EditorShell } from '../EditorShell';
31
+
32
+ beforeEach(() => {
33
+ zoneOptions.length = 0;
34
+ if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') {
35
+ Object.defineProperty(window, 'matchMedia', {
36
+ configurable: true,
37
+ value: (query: string) => ({
38
+ matches: false,
39
+ media: query,
40
+ onchange: null,
41
+ addListener: vi.fn(),
42
+ removeListener: vi.fn(),
43
+ addEventListener: vi.fn(),
44
+ removeEventListener: vi.fn(),
45
+ dispatchEvent: vi.fn(() => false),
46
+ }),
47
+ });
48
+ }
49
+ if (typeof globalThis.ResizeObserver === 'undefined') {
50
+ class ResizeObserverStub {
51
+ observe(): void {}
52
+ unobserve(): void {}
53
+ disconnect(): void {}
54
+ }
55
+ (globalThis as unknown as { ResizeObserver: typeof ResizeObserverStub }).ResizeObserver =
56
+ ResizeObserverStub;
57
+ }
58
+ });
59
+
60
+ const codeContext: CodeContext = {
61
+ sections: [{ id: 'foo@2', line: 2, summaryMarkdown: '**foo**', markdown: 'body' }],
62
+ };
63
+
64
+ describe('<EditorShell> codeContext prop', () => {
65
+ it('mounts CodeContextZones with the options in code mode', () => {
66
+ const { queryByTestId } = render(
67
+ <EditorShell initialMarkdown="const x = 1;" fileName="a.ts" codeContext={codeContext} />,
68
+ );
69
+ expect(queryByTestId('code-context-stub')).toBeTruthy();
70
+ expect(zoneOptions[0]).toBe(codeContext);
71
+ });
72
+
73
+ it('does not mount without the prop', () => {
74
+ const { queryByTestId } = render(
75
+ <EditorShell initialMarkdown="const x = 1;" fileName="a.ts" />,
76
+ );
77
+ expect(queryByTestId('code-context-stub')).toBeNull();
78
+ });
79
+
80
+ it('ignores codeContext in markdown mode', () => {
81
+ const { queryByTestId } = render(
82
+ <EditorShell initialMarkdown="# hi" fileName="a.md" codeContext={codeContext} />,
83
+ );
84
+ expect(queryByTestId('code-context-stub')).toBeNull();
85
+ });
86
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ *
4
+ * Prop-contract tests for the v1.5 naming renames:
5
+ * - `<EditorShell>`'s light/dark chrome prop is `colorScheme` (was
6
+ * `theme`), and it drives the `data-theme` attribute on the shell root.
7
+ * - `<RawEditor>`'s Monaco theme-string prop is `monacoTheme` (was
8
+ * `theme`); the shell maps `colorScheme` → `monacoTheme` (`'dark'` →
9
+ * `'vs-dark'`, `'light'` → `'vs'`).
10
+ *
11
+ * The heavy editing surfaces are stubbed so the shell mounts under jsdom
12
+ * without dragging in monaco-editor or Tiptap. The RawEditor stub records
13
+ * the props it receives so we can assert `monacoTheme` reaches it.
14
+ */
15
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
16
+ import { render, screen } from '@testing-library/react';
17
+ import type { RawEditorProps } from '../RawEditor';
18
+
19
+ // Records the props the shell passes to RawEditor on each render.
20
+ const rawEditorProps: RawEditorProps[] = [];
21
+
22
+ vi.mock('../RawEditor', () => ({
23
+ RawEditor: (props: RawEditorProps) => {
24
+ rawEditorProps.push(props);
25
+ return <div data-testid="raw-editor-stub" />;
26
+ },
27
+ }));
28
+ vi.mock('../WysiwygEditor', () => ({
29
+ WysiwygEditor: () => <div data-testid="wysiwyg-editor-stub" />,
30
+ }));
31
+ vi.mock('../PreviewPanel', () => ({
32
+ PreviewPanel: () => <div data-testid="preview-stub" />,
33
+ }));
34
+
35
+ import { EditorShell } from '../EditorShell';
36
+
37
+ beforeEach(() => {
38
+ rawEditorProps.length = 0;
39
+ // jsdom lacks matchMedia / ResizeObserver, which the Toolbar uses.
40
+ if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') {
41
+ Object.defineProperty(window, 'matchMedia', {
42
+ configurable: true,
43
+ value: (query: string) => ({
44
+ matches: false,
45
+ media: query,
46
+ onchange: null,
47
+ addListener: vi.fn(),
48
+ removeListener: vi.fn(),
49
+ addEventListener: vi.fn(),
50
+ removeEventListener: vi.fn(),
51
+ dispatchEvent: vi.fn(() => false),
52
+ }),
53
+ });
54
+ }
55
+ if (typeof globalThis.ResizeObserver === 'undefined') {
56
+ class ResizeObserverStub {
57
+ observe(): void {}
58
+ unobserve(): void {}
59
+ disconnect(): void {}
60
+ }
61
+ (globalThis as unknown as { ResizeObserver: typeof ResizeObserverStub }).ResizeObserver =
62
+ ResizeObserverStub;
63
+ }
64
+ });
65
+
66
+ describe('<EditorShell> colorScheme prop', () => {
67
+ it('applies dark chrome via data-theme when colorScheme="dark"', () => {
68
+ const { container } = render(
69
+ <EditorShell initialMarkdown="# hi" initialView="raw" colorScheme="dark" />,
70
+ );
71
+ const shell = container.querySelector('.squisq-editor-shell');
72
+ expect(shell?.getAttribute('data-theme')).toBe('dark');
73
+ });
74
+
75
+ it('defaults to light chrome when colorScheme is omitted', () => {
76
+ const { container } = render(<EditorShell initialMarkdown="# hi" initialView="raw" />);
77
+ const shell = container.querySelector('.squisq-editor-shell');
78
+ expect(shell?.getAttribute('data-theme')).toBe('light');
79
+ });
80
+ });
81
+
82
+ describe('RawEditor monacoTheme prop', () => {
83
+ it('maps colorScheme="dark" to monacoTheme="vs-dark"', () => {
84
+ render(<EditorShell initialMarkdown="# hi" initialView="raw" colorScheme="dark" />);
85
+ expect(screen.getByTestId('raw-editor-stub')).toBeTruthy();
86
+ const last = rawEditorProps[rawEditorProps.length - 1];
87
+ expect(last?.monacoTheme).toBe('vs-dark');
88
+ });
89
+
90
+ it('maps colorScheme="light" to monacoTheme="vs"', () => {
91
+ render(<EditorShell initialMarkdown="# hi" initialView="raw" colorScheme="light" />);
92
+ expect(screen.getByTestId('raw-editor-stub')).toBeTruthy();
93
+ const last = rawEditorProps[rawEditorProps.length - 1];
94
+ expect(last?.monacoTheme).toBe('vs');
95
+ });
96
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseMarkdown } from '@bendyline/squisq/markdown';
3
+ import { markdownToDoc } from '@bendyline/squisq/doc';
4
+ import { collectEmbeddedMedia, mediaKindFromUrl } from '../embeddedMedia';
5
+
6
+ function block(md: string) {
7
+ return markdownToDoc(parseMarkdown(md), { articleId: 't' }).blocks[0];
8
+ }
9
+
10
+ describe('mediaKindFromUrl', () => {
11
+ it('classifies by extension', () => {
12
+ expect(mediaKindFromUrl('a/clip.webm')).toBe('video');
13
+ expect(mediaKindFromUrl('take1.mp3')).toBe('audio');
14
+ expect(mediaKindFromUrl('photo.png')).toBeNull();
15
+ expect(mediaKindFromUrl('https://x.com/page')).toBeNull();
16
+ });
17
+ });
18
+
19
+ describe('collectEmbeddedMedia', () => {
20
+ it('finds a recorder <video> HTML tag with its source line', () => {
21
+ const found = collectEmbeddedMedia(
22
+ block('# Tips\n\n<video src="video/recording.webm" controls></video>\n'),
23
+ );
24
+ expect(found).toEqual([{ src: 'video/recording.webm', kind: 'video', sourceLine: 3 }]);
25
+ });
26
+
27
+ it('finds an <audio> tag', () => {
28
+ const found = collectEmbeddedMedia(block('# Intro\n\n<audio src="audio/take.mp3"></audio>\n'));
29
+ expect(found[0]).toMatchObject({ src: 'audio/take.mp3', kind: 'audio' });
30
+ });
31
+
32
+ it('finds a markdown link/image to media', () => {
33
+ expect(collectEmbeddedMedia(block('# B\n\n[clip](v/r.webm)\n'))[0]).toMatchObject({
34
+ src: 'v/r.webm',
35
+ kind: 'video',
36
+ });
37
+ expect(collectEmbeddedMedia(block('# B\n\n![v](clip.mp4)\n'))[0]).toMatchObject({
38
+ src: 'clip.mp4',
39
+ kind: 'video',
40
+ });
41
+ });
42
+
43
+ it('ignores plain images and non-media links', () => {
44
+ expect(
45
+ collectEmbeddedMedia(block('# B\n\n![p](pic.png)\n\n[docs](https://x.com/y)\n')),
46
+ ).toEqual([]);
47
+ });
48
+ });