@bendyline/squisq-editor-react 1.5.2 → 1.6.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 (165) hide show
  1. package/dist/index.d.ts +757 -20
  2. package/dist/index.js +16910 -6844
  3. package/dist/index.js.map +1 -1
  4. package/package.json +4 -4
  5. package/src/BlockCardView.tsx +121 -0
  6. package/src/BlockPreviewPanel.tsx +69 -0
  7. package/src/BlockPropertiesPopover.tsx +191 -0
  8. package/src/EditorContext.tsx +143 -0
  9. package/src/EditorShell.tsx +200 -120
  10. package/src/FolderView.tsx +131 -0
  11. package/src/Icon.tsx +26 -0
  12. package/src/ImageEditor.tsx +69 -22
  13. package/src/OutlinePanel.tsx +38 -3
  14. package/src/PlainHtmlPreview.tsx +30 -3
  15. package/src/PreviewControls.tsx +180 -8
  16. package/src/RawEditor.tsx +216 -29
  17. package/src/RecorderEntry.tsx +9 -16
  18. package/src/TemplateAnnotation.ts +44 -0
  19. package/src/TemplatePicker.tsx +329 -54
  20. package/src/ThemeCustomizerPanel.tsx +30 -336
  21. package/src/ThemePicker.tsx +112 -3
  22. package/src/TimelineBlockPreview.tsx +37 -0
  23. package/src/TimelineTrack.tsx +671 -0
  24. package/src/Toolbar.tsx +528 -174
  25. package/src/Tooltip.tsx +22 -4
  26. package/src/TransitionPicker.tsx +351 -0
  27. package/src/VersionHistoryPanel.tsx +61 -31
  28. package/src/ViewMenuPanel.tsx +17 -14
  29. package/src/WysiwygEditor.tsx +161 -65
  30. package/src/__tests__/blockProperties.test.ts +92 -0
  31. package/src/__tests__/blockRange.test.ts +105 -0
  32. package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
  33. package/src/__tests__/createShapeLayer.test.ts +46 -0
  34. package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
  35. package/src/__tests__/embeddedMedia.test.ts +48 -0
  36. package/src/__tests__/headingTransition.test.ts +138 -0
  37. package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
  38. package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
  39. package/src/__tests__/recorderMediaInsert.test.ts +86 -0
  40. package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
  41. package/src/__tests__/templatePickerMetadata.test.ts +32 -0
  42. package/src/__tests__/timelineSource.test.ts +134 -0
  43. package/src/__tests__/tiptapBridge.test.ts +92 -0
  44. package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
  45. package/src/__tests__/tooltip.test.tsx +72 -0
  46. package/src/__tests__/transitionCatalog.test.ts +64 -0
  47. package/src/__tests__/useBlockNavigator.test.tsx +67 -0
  48. package/src/__tests__/useMediaRecorder.test.ts +24 -0
  49. package/src/__tests__/useTimelineClock.test.ts +21 -0
  50. package/src/blockProperties.ts +88 -0
  51. package/src/blockRange.ts +132 -0
  52. package/src/buildPreviewDoc.ts +98 -9
  53. package/src/customTemplates/AddBin.tsx +126 -0
  54. package/src/customTemplates/CustomLayoutManager.tsx +233 -0
  55. package/src/customTemplates/CustomTemplateContext.tsx +182 -0
  56. package/src/customTemplates/LayerToolbar.tsx +580 -0
  57. package/src/customTemplates/ShapeGlyph.tsx +47 -0
  58. package/src/customTemplates/TemplateDesigner.tsx +430 -0
  59. package/src/customTemplates/__tests__/library.test.ts +88 -0
  60. package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
  61. package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
  62. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
  63. package/src/customTemplates/designer.css +673 -0
  64. package/src/customTemplates/index.ts +31 -0
  65. package/src/customTemplates/library.ts +97 -0
  66. package/src/customTemplates/normalizePositions.ts +75 -0
  67. package/src/customTemplates/shapeDefs.ts +131 -0
  68. package/src/customTemplates/thumbnail.tsx +63 -0
  69. package/src/customTemplates/tokenDefs.ts +60 -0
  70. package/src/customTemplates/useDocCustomTemplates.ts +52 -0
  71. package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
  72. package/src/customThemes/CustomThemeContext.tsx +179 -0
  73. package/src/customThemes/CustomThemeDialog.tsx +286 -0
  74. package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
  75. package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
  76. package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
  77. package/src/customThemes/customThemeLibrary.ts +97 -0
  78. package/src/customThemes/index.ts +31 -0
  79. package/src/customThemes/themeControls.tsx +229 -0
  80. package/src/customThemes/themeDraft.ts +272 -0
  81. package/src/customThemes/useDocCustomThemes.ts +49 -0
  82. package/src/diagram/DiagramCanvas.tsx +240 -0
  83. package/src/diagram/DiagramExtension.ts +209 -0
  84. package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
  85. package/src/diagram/DiagramWidget.tsx +270 -0
  86. package/src/diagram/diagramCommands.ts +604 -0
  87. package/src/diagram/diagramConstants.ts +17 -0
  88. package/src/diagram/useDiagramData.ts +126 -0
  89. package/src/embeddedMedia.ts +78 -0
  90. package/src/frontmatter.ts +29 -0
  91. package/src/headingTransition.ts +231 -0
  92. package/src/imageEditor/CanvasSurface.tsx +383 -88
  93. package/src/imageEditor/PropertiesPanel.tsx +47 -1
  94. package/src/imageEditor/Toolbar.tsx +229 -16
  95. package/src/imageEditor/createShapeLayer.ts +280 -0
  96. package/src/imageEditor/icons.tsx +34 -114
  97. package/src/imageEditor/image-editor.css +54 -5
  98. package/src/imageEditor/state.ts +23 -3
  99. package/src/index.ts +77 -0
  100. package/src/recorder/RecorderModal.tsx +120 -53
  101. package/src/recorder/RecorderPanel.tsx +2 -26
  102. package/src/recorder/hooks/useMediaRecorder.ts +17 -2
  103. package/src/recorder/insertMediaBlock.ts +30 -0
  104. package/src/resolveBlockVisual.ts +33 -0
  105. package/src/scene/Scene.tsx +540 -0
  106. package/src/scene/SceneBlockExtension.ts +198 -0
  107. package/src/scene/SceneBlockToolbar.tsx +201 -0
  108. package/src/scene/SceneBlockWidget.tsx +434 -0
  109. package/src/scene/ScenePropsBar.tsx +85 -0
  110. package/src/scene/SceneSelection.tsx +107 -0
  111. package/src/scene/SceneViewport.tsx +102 -0
  112. package/src/scene/ShapePalette.tsx +181 -0
  113. package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
  114. package/src/scene/__tests__/bezierEdit.test.ts +85 -0
  115. package/src/scene/__tests__/blockLayers.test.ts +57 -0
  116. package/src/scene/__tests__/shapeLayers.test.ts +106 -0
  117. package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
  118. package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
  119. package/src/scene/adapters/DiagramAdapter.ts +168 -0
  120. package/src/scene/adapters/DrawingAdapter.ts +415 -0
  121. package/src/scene/adapters/LayoutAdapter.ts +310 -0
  122. package/src/scene/adapters/blockLayers.ts +159 -0
  123. package/src/scene/commands/SceneCommand.ts +70 -0
  124. package/src/scene/commands/drawingCommands.ts +318 -0
  125. package/src/scene/commands/layoutCommands.ts +301 -0
  126. package/src/scene/hooks/useSceneHitTest.ts +105 -0
  127. package/src/scene/hooks/useScenePanZoom.ts +147 -0
  128. package/src/scene/hooks/useSceneSelection.ts +62 -0
  129. package/src/scene/index.ts +95 -0
  130. package/src/scene/layers/DiagramEdges.tsx +127 -0
  131. package/src/scene/layers/edgeGeometry.ts +77 -0
  132. package/src/scene/layers/nodeCard.tsx +145 -0
  133. package/src/scene/layers/renderLayer.tsx +70 -0
  134. package/src/scene/layers/shapeLayers.ts +201 -0
  135. package/src/scene/paths/bezierEdit.ts +208 -0
  136. package/src/scene/scene.css +649 -0
  137. package/src/scene/text/SceneTextOverlay.tsx +161 -0
  138. package/src/scene/text/sceneTextChannel.ts +40 -0
  139. package/src/scene/text/sceneTextConfig.ts +27 -0
  140. package/src/scene/text/sceneTiptap.ts +36 -0
  141. package/src/scene/text/useSceneTextEditing.ts +39 -0
  142. package/src/scene/tools/ConnectTool.ts +111 -0
  143. package/src/scene/tools/DrawingConnectTool.ts +161 -0
  144. package/src/scene/tools/PathTool.ts +158 -0
  145. package/src/scene/tools/PlaceTool.ts +47 -0
  146. package/src/scene/tools/SceneTool.ts +75 -0
  147. package/src/scene/tools/SelectTool.ts +284 -0
  148. package/src/scene/tools/ShapeTool.ts +144 -0
  149. package/src/scene/tools/TextTool.ts +72 -0
  150. package/src/scene/tools/TokenTool.ts +95 -0
  151. package/src/scene/tools/createDrawShapeTool.ts +82 -0
  152. package/src/styles/diagram.css +183 -0
  153. package/src/styles/editor.css +1615 -203
  154. package/src/styles/folder-view.css +210 -0
  155. package/src/styles/image-edit-affordance.css +2 -2
  156. package/src/styles/index.css +4 -0
  157. package/src/timelineSource.ts +244 -0
  158. package/src/tiptapBridge.ts +115 -34
  159. package/src/tooltipPlacement.ts +13 -0
  160. package/src/transitionCatalog.ts +159 -0
  161. package/src/types/monaco-shims.d.ts +10 -0
  162. package/src/useBlockNavigator.ts +153 -0
  163. package/src/useMonacoLoader.ts +105 -0
  164. package/src/useTimelineClock.ts +76 -0
  165. package/src/utils/dropUtils.ts +1 -1
@@ -121,6 +121,20 @@ describe('markdownToTiptap', () => {
121
121
  expect(html).toContain('First');
122
122
  });
123
123
 
124
+ it('converts task lists to taskList/taskItem markup', () => {
125
+ const md = '- [ ] buy milk\n- [x] walk dog';
126
+ const html = markdownToTiptap(md);
127
+ expect(html).toContain('data-type="taskList"');
128
+ // Unchecked item carries no data-checked; checked item is marked true.
129
+ expect(html).toContain('<li data-type="taskItem"><p>buy milk</p></li>');
130
+ expect(html).toContain('<li data-type="taskItem" data-checked="true"><p>walk dog</p></li>');
131
+ });
132
+
133
+ it('treats [X] (uppercase) as checked', () => {
134
+ const html = markdownToTiptap('- [X] done');
135
+ expect(html).toContain('data-checked="true"');
136
+ });
137
+
124
138
  it('converts blockquotes', () => {
125
139
  const md = '> This is a quote';
126
140
  const html = markdownToTiptap(md);
@@ -260,6 +274,30 @@ describe('tiptapToMarkdown', () => {
260
274
  expect(md).toContain('2. Second');
261
275
  });
262
276
 
277
+ it('converts task lists, preserving checked state and text', () => {
278
+ // Mirrors the structure Tiptap's getHTML() emits for TaskItem:
279
+ // a <label> holding the checkbox chrome, then a content <div>.
280
+ const html =
281
+ '<ul data-type="taskList">' +
282
+ '<li data-type="taskItem" data-checked="false"><label><input type="checkbox"><span></span></label><div><p>buy milk</p></div></li>' +
283
+ '<li data-type="taskItem" data-checked="true"><label><input type="checkbox" checked="checked"><span></span></label><div><p>walk dog</p></div></li>' +
284
+ '</ul>';
285
+ const md = tiptapToMarkdown(html);
286
+ expect(md).toContain('- [ ] buy milk');
287
+ expect(md).toContain('- [x] walk dog');
288
+ // The unchecked item must NOT be promoted to checked.
289
+ expect(md).not.toContain('- [x] buy milk');
290
+ });
291
+
292
+ it('preserves a still-empty task item', () => {
293
+ const html =
294
+ '<ul data-type="taskList">' +
295
+ '<li data-type="taskItem" data-checked="false"><label><input type="checkbox"><span></span></label><div><p></p></div></li>' +
296
+ '</ul>';
297
+ const md = tiptapToMarkdown(html);
298
+ expect(md).toContain('- [ ]');
299
+ });
300
+
263
301
  it('converts tables', () => {
264
302
  const html =
265
303
  '<table><thead><tr><th>Name</th><th>Age</th></tr></thead>' +
@@ -303,6 +341,30 @@ describe('tiptapToMarkdown', () => {
303
341
  expect(md).toContain('- First line ');
304
342
  expect(md).toContain(' Second line');
305
343
  });
344
+
345
+ it('keeps a <video> nested in a list item (no silent drop)', () => {
346
+ const md = tiptapToMarkdown(
347
+ '<ol><li><p>Step</p><video src="video/clip.webm" width="480" controls=""></video></li></ol>',
348
+ );
349
+ expect(md).toContain('1. Step');
350
+ // The media survives, indented as a continuation of the list item.
351
+ expect(md).toContain(' <video src="video/clip.webm" controls width="480"></video>');
352
+ });
353
+
354
+ it('keeps an <audio> nested in a list item', () => {
355
+ const md = tiptapToMarkdown(
356
+ '<ul><li><p>Note</p><audio src="audio/take.webm" controls=""></audio></li></ul>',
357
+ );
358
+ expect(md).toContain('- Note');
359
+ expect(md).toContain(' <audio src="audio/take.webm" controls></audio>');
360
+ });
361
+
362
+ it('keeps a media-only list item (first tag takes the bullet)', () => {
363
+ const md = tiptapToMarkdown(
364
+ '<ul><li><video src="video/only.webm" controls=""></video></li></ul>',
365
+ );
366
+ expect(md).toContain('- <video src="video/only.webm" controls></video>');
367
+ });
306
368
  });
307
369
 
308
370
  // ---------------------------------------------------------------------------
@@ -360,4 +422,34 @@ describe('round-trip: markdownToTiptap → tiptapToMarkdown', () => {
360
422
  expect(result).toContain('1. First');
361
423
  expect(result).toContain('2. Second');
362
424
  });
425
+
426
+ it('preserves task lists with mixed checked state', () => {
427
+ const result = roundTrip('- [ ] buy milk\n- [x] walk dog');
428
+ expect(result).toContain('- [ ] buy milk');
429
+ expect(result).toContain('- [x] walk dog');
430
+ expect(result).not.toContain('- [x] buy milk');
431
+ });
432
+
433
+ it('preserves quoted template params with spaces', () => {
434
+ const result = roundTrip(
435
+ '## Gallery {[imageWithCaption src=photo.jpg caption="Beach at sunset"]}',
436
+ );
437
+ expect(result).toContain('{[imageWithCaption src=photo.jpg caption="Beach at sunset"]}');
438
+ });
439
+
440
+ it('preserves single-quoted template params', () => {
441
+ const result = roundTrip("## X {[quote text='A long caption']}");
442
+ expect(result).toContain("text='A long caption'");
443
+ });
444
+
445
+ it('preserves quoted Pandoc attribute values with spaces', () => {
446
+ const result = roundTrip('## X {#intro caption="hello world"}');
447
+ expect(result).toContain('{#intro caption="hello world"}');
448
+ });
449
+
450
+ it('preserves both annotation forms with quoted values on one heading', () => {
451
+ const result = roundTrip('## X {#a label="big plan"} {[quote text="She left"]}');
452
+ expect(result).toContain('{#a label="big plan"}');
453
+ expect(result).toContain('{[quote text="She left"]}');
454
+ });
363
455
  });
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseMarkdown } from '@bendyline/squisq/markdown';
3
+ import { markdownToTiptap, tiptapToMarkdown } from '../tiptapBridge';
4
+
5
+ /**
6
+ * tiptapBridge maintains a SECOND, regex-based markdown parser/serializer that
7
+ * must agree with core's `parseMarkdown` (see the "must stay in sync" note in
8
+ * tiptapBridge.ts). Comment-enforced parallel parsers drift silently; this test
9
+ * makes the agreement mechanical.
10
+ *
11
+ * The comparable artifact across the two representations (ProseMirror HTML vs.
12
+ * mdast) is the block-type sequence core's parser sees. If a bridge round-trip
13
+ * drops or mangles a construct core understands, the sequence changes and this
14
+ * test fails — surfacing drift in CI instead of in the editor.
15
+ */
16
+
17
+ const blockSeq = (md: string): string[] => parseMarkdown(md).children.map((n) => n.type);
18
+ const bridgeRoundTrip = (md: string): string => tiptapToMarkdown(markdownToTiptap(md));
19
+
20
+ const CORPUS: Record<string, string> = {
21
+ heading: '# Title\n\n## Subtitle',
22
+ paragraph: 'Just a paragraph of text.',
23
+ bulletList: '- one\n- two\n- three',
24
+ orderedList: '1. first\n2. second\n3. third',
25
+ blockquote: '> a quote',
26
+ codeBlock: '```js\nconst x = 1;\n```',
27
+ table: '| a | b |\n| --- | --- |\n| 1 | 2 |',
28
+ inlineEmphasis: 'Some **bold** and *italic* and `code` text.',
29
+ link: 'A [link](https://example.com) inline.',
30
+ thematicBreak: 'before\n\n---\n\nafter',
31
+ mixed: '# Heading\n\nA paragraph.\n\n- a\n- b\n\n> quote\n\n```\ncode\n```',
32
+ };
33
+
34
+ describe('tiptapBridge ↔ core markdown parser conformance', () => {
35
+ for (const [name, md] of Object.entries(CORPUS)) {
36
+ it(`preserves the block-type sequence for: ${name}`, () => {
37
+ expect(blockSeq(bridgeRoundTrip(md))).toEqual(blockSeq(md));
38
+ });
39
+ }
40
+
41
+ it('round-trip is idempotent (a second pass changes nothing)', () => {
42
+ for (const md of Object.values(CORPUS)) {
43
+ const once = bridgeRoundTrip(md);
44
+ expect(bridgeRoundTrip(once)).toBe(once);
45
+ }
46
+ });
47
+ });
@@ -0,0 +1,72 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
3
+ import { TooltipLayer } from '../Tooltip';
4
+ import { clampTooltipLeft } from '../tooltipPlacement';
5
+
6
+ function rect(left: number, top: number, width: number, height: number): DOMRect {
7
+ return DOMRect.fromRect({ x: left, y: top, width, height });
8
+ }
9
+
10
+ const originalInnerWidth = Object.getOwnPropertyDescriptor(window, 'innerWidth');
11
+
12
+ function setViewportWidth(width: number) {
13
+ Object.defineProperty(window, 'innerWidth', {
14
+ configurable: true,
15
+ value: width,
16
+ });
17
+ }
18
+
19
+ afterEach(() => {
20
+ cleanup();
21
+ vi.useRealTimers();
22
+ vi.restoreAllMocks();
23
+ if (originalInnerWidth) {
24
+ Object.defineProperty(window, 'innerWidth', originalInnerWidth);
25
+ }
26
+ });
27
+
28
+ describe('TooltipLayer', () => {
29
+ it('clamps centered tooltip placement inside the viewport', () => {
30
+ expect(clampTooltipLeft(380, 120, 400)).toBe(272);
31
+ expect(clampTooltipLeft(20, 120, 400)).toBe(8);
32
+ expect(clampTooltipLeft(200, 120, 400)).toBe(140);
33
+ });
34
+
35
+ it('keeps a tooltip from spilling off the right edge', () => {
36
+ vi.useFakeTimers();
37
+ setViewportWidth(400);
38
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
39
+ this: HTMLElement,
40
+ ) {
41
+ if (this.classList.contains('squisq-tooltip')) {
42
+ return rect(0, 0, 120, 28);
43
+ }
44
+ return rect(0, 0, 0, 0);
45
+ });
46
+
47
+ render(
48
+ <div>
49
+ <button type="button" data-tooltip="View options">
50
+ View
51
+ </button>
52
+ <TooltipLayer />
53
+ </div>,
54
+ );
55
+
56
+ const button = screen.getByRole('button', { name: 'View' });
57
+ Object.defineProperty(button, 'getBoundingClientRect', {
58
+ configurable: true,
59
+ value: () => rect(360, 10, 40, 32),
60
+ });
61
+
62
+ act(() => {
63
+ fireEvent.mouseOver(button);
64
+ vi.advanceTimersByTime(180);
65
+ });
66
+
67
+ const tooltip = screen.getByRole('tooltip');
68
+ expect(tooltip.textContent).toBe('View options');
69
+ expect(tooltip.style.left).toBe('272px');
70
+ expect(tooltip.style.visibility).toBe('visible');
71
+ });
72
+ });
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { isTransitionType } from '@bendyline/squisq/schemas';
3
+ import {
4
+ TRANSITION_GROUPS,
5
+ TRANSITION_ENTRIES,
6
+ DIRECTION_OPTIONS,
7
+ findTransitionEntry,
8
+ transitionLabel,
9
+ } from '../transitionCatalog';
10
+
11
+ describe('transition catalog', () => {
12
+ it('every curated value is a real core transition type', () => {
13
+ for (const entry of TRANSITION_ENTRIES) {
14
+ expect(isTransitionType(entry.value), entry.value).toBe(true);
15
+ }
16
+ });
17
+
18
+ it('has no duplicate values across groups', () => {
19
+ const values = TRANSITION_ENTRIES.map((e) => e.value);
20
+ expect(new Set(values).size).toBe(values.length);
21
+ });
22
+
23
+ it('gives every entry a non-empty label', () => {
24
+ for (const entry of TRANSITION_ENTRIES) {
25
+ expect(entry.label.length).toBeGreaterThan(0);
26
+ }
27
+ });
28
+
29
+ it('only uses known direction models', () => {
30
+ for (const entry of TRANSITION_ENTRIES) {
31
+ if (entry.direction) {
32
+ expect(DIRECTION_OPTIONS[entry.direction]).toBeDefined();
33
+ }
34
+ }
35
+ });
36
+
37
+ it('flat list matches the groups', () => {
38
+ expect(TRANSITION_ENTRIES).toEqual(TRANSITION_GROUPS.flatMap((g) => g.entries));
39
+ });
40
+ });
41
+
42
+ describe('transitionLabel', () => {
43
+ it('maps the empty value to None', () => {
44
+ expect(transitionLabel('')).toBe('None');
45
+ });
46
+
47
+ it('uses the curated label for a known value', () => {
48
+ expect(transitionLabel('pageCurl')).toBe('Page Curl');
49
+ });
50
+
51
+ it('humanizes an uncurated (but valid) alias', () => {
52
+ expect(transitionLabel('ferris')).toBe('Ferris');
53
+ });
54
+ });
55
+
56
+ describe('findTransitionEntry', () => {
57
+ it('finds a curated entry', () => {
58
+ expect(findTransitionEntry('push')?.direction).toBe('lrud');
59
+ });
60
+
61
+ it('returns undefined for an uncurated value', () => {
62
+ expect(findTransitionEntry('ferris')).toBeUndefined();
63
+ });
64
+ });
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { renderHook, act } from '@testing-library/react';
3
+ import { useState } from 'react';
4
+ import { useBlockNavigator } from '../useBlockNavigator';
5
+
6
+ /** Drive the hook against a piece of React-managed source state. */
7
+ function useHarness(initial: string, enabled: boolean) {
8
+ const [source, setSource] = useState(initial);
9
+ const nav = useBlockNavigator(source, setSource, { enabled });
10
+ return { source, nav };
11
+ }
12
+
13
+ const DOC = '# One\n\nalpha\n\n# Two\n\nbeta\n';
14
+
15
+ describe('useBlockNavigator', () => {
16
+ it('passes through to the full source when disabled', () => {
17
+ const { result } = renderHook(() => useHarness(DOC, false));
18
+ expect(result.current.nav.editorSource).toBe(DOC);
19
+ act(() => result.current.nav.setEditorSource('changed\n'));
20
+ expect(result.current.source).toBe('changed\n');
21
+ });
22
+
23
+ it('scopes editorSource to the active block when enabled', () => {
24
+ const { result } = renderHook(() => useHarness(DOC, true));
25
+ expect(result.current.nav.blockCount).toBe(2);
26
+ expect(result.current.nav.activeBlockKey).toBe(0);
27
+ expect(result.current.nav.editorSource).toBe('# One\n\nalpha\n\n');
28
+
29
+ act(() => result.current.nav.nextBlock());
30
+ expect(result.current.nav.activeBlockKey).toBe(1);
31
+ expect(result.current.nav.editorSource).toBe('# Two\n\nbeta\n');
32
+ });
33
+
34
+ it('splices an edit to the active block back into the full source', () => {
35
+ const { result } = renderHook(() => useHarness(DOC, true));
36
+ act(() => result.current.nav.setEditorSource('# One\n\nalpha edited'));
37
+ // A blank line is guaranteed before the following heading.
38
+ expect(result.current.source).toBe('# One\n\nalpha edited\n\n# Two\n\nbeta\n');
39
+ });
40
+
41
+ it('clamps navigation at the document ends', () => {
42
+ const { result } = renderHook(() => useHarness(DOC, true));
43
+ act(() => result.current.nav.prevBlock());
44
+ expect(result.current.nav.activeBlockKey).toBe(0);
45
+ act(() => result.current.nav.nextBlock());
46
+ act(() => result.current.nav.nextBlock());
47
+ expect(result.current.nav.activeBlockKey).toBe(1);
48
+ });
49
+
50
+ it('adds a new block after the active one and moves to it', () => {
51
+ const { result } = renderHook(() => useHarness(DOC, true));
52
+ act(() => result.current.nav.addBlock());
53
+ expect(result.current.nav.blockCount).toBe(3);
54
+ expect(result.current.nav.activeBlockKey).toBe(1);
55
+ expect(result.current.nav.editorSource).toContain('## New section');
56
+ // The original second block is preserved and now sits last.
57
+ expect(result.current.source).toContain('# Two\n\nbeta\n');
58
+ });
59
+
60
+ it('selects a block by source line for the outline', () => {
61
+ const { result } = renderHook(() => useHarness(DOC, true));
62
+ // Line 5 is the "# Two" heading.
63
+ act(() => result.current.nav.goToBlockByLine(5));
64
+ expect(result.current.nav.activeBlockKey).toBe(1);
65
+ expect(result.current.nav.activeBlockStartLine).toBe(5);
66
+ });
67
+ });
@@ -165,6 +165,30 @@ describe('useMediaRecorder lifecycle', () => {
165
165
  expect(result.current.stream).not.toBeNull();
166
166
  });
167
167
 
168
+ it('camera includes the mic by default', async () => {
169
+ const { result } = renderHook(() => useMediaRecorder({ source: 'camera' }));
170
+ await act(async () => {
171
+ await result.current.request();
172
+ });
173
+ const getUserMedia = navigator.mediaDevices.getUserMedia as ReturnType<typeof vi.fn>;
174
+ expect(getUserMedia).toHaveBeenCalledWith(
175
+ expect.objectContaining({ video: true, audio: true }),
176
+ );
177
+ });
178
+
179
+ it('camera omits the mic when includeMicrophone is false', async () => {
180
+ const { result } = renderHook(() =>
181
+ useMediaRecorder({ source: 'camera', includeMicrophone: false }),
182
+ );
183
+ await act(async () => {
184
+ await result.current.request();
185
+ });
186
+ const getUserMedia = navigator.mediaDevices.getUserMedia as ReturnType<typeof vi.fn>;
187
+ expect(getUserMedia).toHaveBeenCalledWith(
188
+ expect.objectContaining({ video: true, audio: false }),
189
+ );
190
+ });
191
+
168
192
  it('cancel() tears down state and stops the stream tracks', async () => {
169
193
  const { result } = renderHook(() => useMediaRecorder({ source: 'mic' }));
170
194
 
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { advanceTime } from '../useTimelineClock';
3
+
4
+ describe('advanceTime', () => {
5
+ it('advances by dt within range', () => {
6
+ expect(advanceTime(2, 1.5, 30)).toBe(3.5);
7
+ });
8
+
9
+ it('clamps to total at the end', () => {
10
+ expect(advanceTime(29.5, 1, 30)).toBe(30);
11
+ expect(advanceTime(30, 5, 30)).toBe(30);
12
+ });
13
+
14
+ it('never goes below 0', () => {
15
+ expect(advanceTime(0.2, -1, 30)).toBe(0);
16
+ });
17
+
18
+ it('returns 0 for an empty timeline', () => {
19
+ expect(advanceTime(5, 1, 0)).toBe(0);
20
+ });
21
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * blockProperties
3
+ *
4
+ * Generic read/write of a single block-meta key on a heading's Pandoc `{…}`
5
+ * attribute block (stored as the `dataBlockAttrs` inner string in the WYSIWYG
6
+ * heading node — no braces, matching `tiptapBridge`).
7
+ *
8
+ * The transition family (which spans three coupled keys) has its own helpers
9
+ * in `headingTransition.ts`; this module covers the standalone scalar keys the
10
+ * block-properties palette edits — `duration`, `startTime`, `x`, `y`, … — all
11
+ * of which are plain `key=value` params. Parse/serialize is delegated to the
12
+ * shared core helpers so quoting and ordering match the parser exactly.
13
+ */
14
+
15
+ import {
16
+ parsePandocAttrTokens,
17
+ serializePandocAttributes,
18
+ parseTimeSeconds,
19
+ type HeadingAttributes,
20
+ } from '@bendyline/squisq/markdown';
21
+ import { normalizeTransitionType } from '@bendyline/squisq/schemas';
22
+ import { readBlockAttrsTransition } from './headingTransition';
23
+ import { transitionLabel } from './transitionCatalog';
24
+
25
+ /** Parse a `dataBlockAttrs` inner string into its flat `key → value` map. */
26
+ export function readBlockAttrsParams(inner: string | null | undefined): Record<string, string> {
27
+ return inner ? (parsePandocAttrTokens(inner).params ?? {}) : {};
28
+ }
29
+
30
+ /** Read a single block-meta param, or '' when unset. */
31
+ export function readBlockAttrsValue(inner: string | null | undefined, key: string): string {
32
+ return readBlockAttrsParams(inner)[key] ?? '';
33
+ }
34
+
35
+ /**
36
+ * Set (or, when `value` is empty, remove) a single param in a `dataBlockAttrs`
37
+ * inner string. Returns the new inner (no braces), or null when the block is
38
+ * left with no attributes at all — matching how `tiptapBridge` stores an
39
+ * absent attribute (null, not `{}`).
40
+ */
41
+ export function setBlockAttrsValue(
42
+ inner: string | null | undefined,
43
+ key: string,
44
+ value: string,
45
+ ): string | null {
46
+ const attrs: HeadingAttributes = inner ? parsePandocAttrTokens(inner) : {};
47
+ const params: Record<string, string> = { ...(attrs.params ?? {}) };
48
+ const trimmed = value.trim();
49
+ if (trimmed === '') delete params[key];
50
+ else params[key] = trimmed;
51
+ attrs.params = params;
52
+ const raw = serializePandocAttributes(attrs);
53
+ return raw == null || raw === '{}' ? null : raw.slice(1, -1);
54
+ }
55
+
56
+ /**
57
+ * A concise, human-readable summary of a block's authored properties for the
58
+ * on-canvas badge — e.g. `Doors · 1:30 start · 3:20 long`. Returns '' when no
59
+ * properties are set (the badge then shows just its icon). Reads transition
60
+ * from both the Pandoc block and the `{[…]}` params; timing from the block.
61
+ */
62
+ export function summarizeBlockProps(
63
+ blockAttrs: string | null | undefined,
64
+ templateParams: string | null | undefined,
65
+ ): string {
66
+ const parts: string[] = [];
67
+
68
+ const transition = readBlockAttrsTransition(blockAttrs, templateParams);
69
+ if (transition.type) {
70
+ parts.push(transitionLabel(normalizeTransitionType(transition.type) ?? transition.type));
71
+ }
72
+
73
+ const params = readBlockAttrsParams(blockAttrs);
74
+ if (params.startTime) parts.push(`${formatClock(params.startTime)} start`);
75
+ if (params.duration) parts.push(`${formatClock(params.duration)} long`);
76
+
77
+ return parts.join(' · ');
78
+ }
79
+
80
+ /** Format a raw time value (`90`, `1:30`, `1500ms`) as `m:ss`, or pass through. */
81
+ function formatClock(raw: string): string {
82
+ const seconds = parseTimeSeconds(raw);
83
+ if (seconds == null) return raw;
84
+ const total = Math.round(seconds);
85
+ const minutes = Math.floor(total / 60);
86
+ const secs = total % 60;
87
+ return `${minutes}:${String(secs).padStart(2, '0')}`;
88
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * blockRange
3
+ *
4
+ * Source-text-range slicing for the block-at-a-time editing view. Splits a
5
+ * full markdown document into ordered, contiguous slices — one per
6
+ * heading-defined block plus an optional leading preamble — so a single
7
+ * block can be shown in isolation and edits spliced back into the parent.
8
+ *
9
+ * A block runs from its heading line through the character just before the
10
+ * next heading at ANY depth (or EOF). Sub-headings therefore start their own
11
+ * slices — they are NOT folded into their parent — which matches the
12
+ * "don't see child blocks" requirement and the `slicePastHeading` boundary
13
+ * in `blockSlice.ts`. Ranges are half-open `[startOffset, endOffset)` and
14
+ * line-aligned, so trailing blank lines stay with the current block and
15
+ * `spliceBlock(src, range, getBlockSlices(src)[i].text) === src` for every i.
16
+ *
17
+ * These are pure functions over a markdown string — no React, no editor
18
+ * coupling — so any host can reuse them.
19
+ */
20
+
21
+ import { parseMarkdown } from '@bendyline/squisq/markdown';
22
+ import type { MarkdownDocument } from '@bendyline/squisq/markdown';
23
+ import { frontmatterEndOffset } from './frontmatter';
24
+
25
+ /** Half-open character range into the full source: `[startOffset, endOffset)`. */
26
+ export interface BlockRange {
27
+ startOffset: number;
28
+ endOffset: number;
29
+ }
30
+
31
+ /** One block's source text plus the range it occupies in the full document. */
32
+ export interface BlockSlice {
33
+ text: string;
34
+ range: BlockRange;
35
+ }
36
+
37
+ /** Map every line start to its character offset (`lineStarts[line - 1]`). */
38
+ function computeLineStarts(source: string): number[] {
39
+ const starts = [0];
40
+ for (let i = 0; i < source.length; i++) {
41
+ if (source[i] === '\n') starts.push(i + 1);
42
+ }
43
+ return starts;
44
+ }
45
+
46
+ function makeSlice(source: string, startOffset: number, endOffset: number): BlockSlice {
47
+ return { text: source.slice(startOffset, endOffset), range: { startOffset, endOffset } };
48
+ }
49
+
50
+ /**
51
+ * Split `fullSource` into ordered block slices.
52
+ *
53
+ * - With no headings, the entire post-frontmatter body is a single slice
54
+ * (so an empty or heading-less document still shows one editable card).
55
+ * - With headings, a leading preamble slice is included only when the text
56
+ * before the first heading has non-whitespace content. Each heading then
57
+ * yields one slice spanning up to the next heading (any depth) or EOF.
58
+ *
59
+ * Frontmatter is never part of any slice — slices start at the body offset.
60
+ */
61
+ export function getBlockSlices(fullSource: string): BlockSlice[] {
62
+ const bodyStart = frontmatterEndOffset(fullSource);
63
+
64
+ let doc: MarkdownDocument;
65
+ try {
66
+ doc = parseMarkdown(fullSource);
67
+ } catch {
68
+ // Unparseable mid-edit — treat the whole body as one slice rather than
69
+ // dropping the user into an empty card.
70
+ return [makeSlice(fullSource, bodyStart, fullSource.length)];
71
+ }
72
+
73
+ const lineStarts = computeLineStarts(fullSource);
74
+ const headingOffsets: number[] = [];
75
+ for (const node of doc.children) {
76
+ if (node.type !== 'heading') continue;
77
+ const line = node.position?.start.line;
78
+ if (typeof line !== 'number') continue;
79
+ const off = lineStarts[line - 1];
80
+ if (typeof off === 'number') headingOffsets.push(off);
81
+ }
82
+
83
+ if (headingOffsets.length === 0) {
84
+ return [makeSlice(fullSource, bodyStart, fullSource.length)];
85
+ }
86
+
87
+ const slices: BlockSlice[] = [];
88
+ const firstHeading = headingOffsets[0];
89
+ if (fullSource.slice(bodyStart, firstHeading).trim().length > 0) {
90
+ slices.push(makeSlice(fullSource, bodyStart, firstHeading));
91
+ }
92
+ for (let i = 0; i < headingOffsets.length; i++) {
93
+ const start = headingOffsets[i];
94
+ const end = i + 1 < headingOffsets.length ? headingOffsets[i + 1] : fullSource.length;
95
+ slices.push(makeSlice(fullSource, start, end));
96
+ }
97
+ return slices;
98
+ }
99
+
100
+ /** Replace the text in `range` with `newText`, returning the new full source. */
101
+ export function spliceBlock(fullSource: string, range: BlockRange, newText: string): string {
102
+ return fullSource.slice(0, range.startOffset) + newText + fullSource.slice(range.endOffset);
103
+ }
104
+
105
+ /** Character offset of the start of 1-based `line` (clamps past EOF). */
106
+ export function lineToOffset(source: string, line: number): number {
107
+ const starts = computeLineStarts(source);
108
+ return starts[Math.max(0, line - 1)] ?? source.length;
109
+ }
110
+
111
+ /** 1-based line number containing `offset`. */
112
+ export function offsetToLine(source: string, offset: number): number {
113
+ const starts = computeLineStarts(source);
114
+ let lo = 0;
115
+ let hi = starts.length - 1;
116
+ let ans = 0;
117
+ while (lo <= hi) {
118
+ const mid = (lo + hi) >> 1;
119
+ if (starts[mid] <= offset) {
120
+ ans = mid;
121
+ lo = mid + 1;
122
+ } else {
123
+ hi = mid - 1;
124
+ }
125
+ }
126
+ return ans + 1;
127
+ }
128
+
129
+ /** Index of the slice whose range contains `offset`, or -1. */
130
+ export function sliceIndexAtOffset(slices: BlockSlice[], offset: number): number {
131
+ return slices.findIndex((s) => offset >= s.range.startOffset && offset < s.range.endOffset);
132
+ }