@bendyline/squisq-editor-react 1.5.3 → 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.
- package/dist/index.d.ts +757 -20
- package/dist/index.js +16875 -6883
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/BlockCardView.tsx +121 -0
- package/src/BlockPreviewPanel.tsx +69 -0
- package/src/BlockPropertiesPopover.tsx +191 -0
- package/src/EditorContext.tsx +143 -0
- package/src/EditorShell.tsx +200 -120
- package/src/FolderView.tsx +131 -0
- package/src/Icon.tsx +26 -0
- package/src/ImageEditor.tsx +69 -22
- package/src/OutlinePanel.tsx +38 -3
- package/src/PlainHtmlPreview.tsx +30 -3
- package/src/PreviewControls.tsx +180 -8
- package/src/RawEditor.tsx +169 -13
- package/src/RecorderEntry.tsx +9 -16
- package/src/TemplateAnnotation.ts +44 -0
- package/src/TemplatePicker.tsx +329 -54
- package/src/ThemeCustomizerPanel.tsx +30 -336
- package/src/ThemePicker.tsx +112 -3
- package/src/TimelineBlockPreview.tsx +37 -0
- package/src/TimelineTrack.tsx +671 -0
- package/src/Toolbar.tsx +528 -174
- package/src/Tooltip.tsx +22 -4
- package/src/TransitionPicker.tsx +351 -0
- package/src/VersionHistoryPanel.tsx +2 -14
- package/src/ViewMenuPanel.tsx +17 -14
- package/src/WysiwygEditor.tsx +161 -65
- package/src/__tests__/blockProperties.test.ts +92 -0
- package/src/__tests__/blockRange.test.ts +105 -0
- package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
- package/src/__tests__/createShapeLayer.test.ts +46 -0
- package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
- package/src/__tests__/embeddedMedia.test.ts +48 -0
- package/src/__tests__/headingTransition.test.ts +138 -0
- package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
- package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
- package/src/__tests__/recorderMediaInsert.test.ts +86 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
- package/src/__tests__/templatePickerMetadata.test.ts +32 -0
- package/src/__tests__/timelineSource.test.ts +134 -0
- package/src/__tests__/tiptapBridge.test.ts +92 -0
- package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
- package/src/__tests__/tooltip.test.tsx +72 -0
- package/src/__tests__/transitionCatalog.test.ts +64 -0
- package/src/__tests__/useBlockNavigator.test.tsx +67 -0
- package/src/__tests__/useMediaRecorder.test.ts +24 -0
- package/src/__tests__/useTimelineClock.test.ts +21 -0
- package/src/blockProperties.ts +88 -0
- package/src/blockRange.ts +132 -0
- package/src/buildPreviewDoc.ts +98 -9
- package/src/customTemplates/AddBin.tsx +126 -0
- package/src/customTemplates/CustomLayoutManager.tsx +233 -0
- package/src/customTemplates/CustomTemplateContext.tsx +182 -0
- package/src/customTemplates/LayerToolbar.tsx +580 -0
- package/src/customTemplates/ShapeGlyph.tsx +47 -0
- package/src/customTemplates/TemplateDesigner.tsx +430 -0
- package/src/customTemplates/__tests__/library.test.ts +88 -0
- package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
- package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
- package/src/customTemplates/designer.css +673 -0
- package/src/customTemplates/index.ts +31 -0
- package/src/customTemplates/library.ts +97 -0
- package/src/customTemplates/normalizePositions.ts +75 -0
- package/src/customTemplates/shapeDefs.ts +131 -0
- package/src/customTemplates/thumbnail.tsx +63 -0
- package/src/customTemplates/tokenDefs.ts +60 -0
- package/src/customTemplates/useDocCustomTemplates.ts +52 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
- package/src/customThemes/CustomThemeContext.tsx +179 -0
- package/src/customThemes/CustomThemeDialog.tsx +286 -0
- package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
- package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
- package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
- package/src/customThemes/customThemeLibrary.ts +97 -0
- package/src/customThemes/index.ts +31 -0
- package/src/customThemes/themeControls.tsx +229 -0
- package/src/customThemes/themeDraft.ts +272 -0
- package/src/customThemes/useDocCustomThemes.ts +49 -0
- package/src/diagram/DiagramCanvas.tsx +240 -0
- package/src/diagram/DiagramExtension.ts +209 -0
- package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
- package/src/diagram/DiagramWidget.tsx +270 -0
- package/src/diagram/diagramCommands.ts +604 -0
- package/src/diagram/diagramConstants.ts +17 -0
- package/src/diagram/useDiagramData.ts +126 -0
- package/src/embeddedMedia.ts +78 -0
- package/src/frontmatter.ts +29 -0
- package/src/headingTransition.ts +231 -0
- package/src/imageEditor/CanvasSurface.tsx +383 -88
- package/src/imageEditor/PropertiesPanel.tsx +47 -1
- package/src/imageEditor/Toolbar.tsx +229 -16
- package/src/imageEditor/createShapeLayer.ts +280 -0
- package/src/imageEditor/icons.tsx +34 -114
- package/src/imageEditor/image-editor.css +54 -5
- package/src/imageEditor/state.ts +23 -3
- package/src/index.ts +77 -0
- package/src/recorder/RecorderModal.tsx +120 -53
- package/src/recorder/RecorderPanel.tsx +2 -26
- package/src/recorder/hooks/useMediaRecorder.ts +8 -1
- package/src/recorder/insertMediaBlock.ts +30 -0
- package/src/resolveBlockVisual.ts +33 -0
- package/src/scene/Scene.tsx +540 -0
- package/src/scene/SceneBlockExtension.ts +198 -0
- package/src/scene/SceneBlockToolbar.tsx +201 -0
- package/src/scene/SceneBlockWidget.tsx +434 -0
- package/src/scene/ScenePropsBar.tsx +85 -0
- package/src/scene/SceneSelection.tsx +107 -0
- package/src/scene/SceneViewport.tsx +102 -0
- package/src/scene/ShapePalette.tsx +181 -0
- package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
- package/src/scene/__tests__/bezierEdit.test.ts +85 -0
- package/src/scene/__tests__/blockLayers.test.ts +57 -0
- package/src/scene/__tests__/shapeLayers.test.ts +106 -0
- package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
- package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
- package/src/scene/adapters/DiagramAdapter.ts +168 -0
- package/src/scene/adapters/DrawingAdapter.ts +415 -0
- package/src/scene/adapters/LayoutAdapter.ts +310 -0
- package/src/scene/adapters/blockLayers.ts +159 -0
- package/src/scene/commands/SceneCommand.ts +70 -0
- package/src/scene/commands/drawingCommands.ts +318 -0
- package/src/scene/commands/layoutCommands.ts +301 -0
- package/src/scene/hooks/useSceneHitTest.ts +105 -0
- package/src/scene/hooks/useScenePanZoom.ts +147 -0
- package/src/scene/hooks/useSceneSelection.ts +62 -0
- package/src/scene/index.ts +95 -0
- package/src/scene/layers/DiagramEdges.tsx +127 -0
- package/src/scene/layers/edgeGeometry.ts +77 -0
- package/src/scene/layers/nodeCard.tsx +145 -0
- package/src/scene/layers/renderLayer.tsx +70 -0
- package/src/scene/layers/shapeLayers.ts +201 -0
- package/src/scene/paths/bezierEdit.ts +208 -0
- package/src/scene/scene.css +649 -0
- package/src/scene/text/SceneTextOverlay.tsx +161 -0
- package/src/scene/text/sceneTextChannel.ts +40 -0
- package/src/scene/text/sceneTextConfig.ts +27 -0
- package/src/scene/text/sceneTiptap.ts +36 -0
- package/src/scene/text/useSceneTextEditing.ts +39 -0
- package/src/scene/tools/ConnectTool.ts +111 -0
- package/src/scene/tools/DrawingConnectTool.ts +161 -0
- package/src/scene/tools/PathTool.ts +158 -0
- package/src/scene/tools/PlaceTool.ts +47 -0
- package/src/scene/tools/SceneTool.ts +75 -0
- package/src/scene/tools/SelectTool.ts +284 -0
- package/src/scene/tools/ShapeTool.ts +144 -0
- package/src/scene/tools/TextTool.ts +72 -0
- package/src/scene/tools/TokenTool.ts +95 -0
- package/src/scene/tools/createDrawShapeTool.ts +82 -0
- package/src/styles/diagram.css +183 -0
- package/src/styles/editor.css +1615 -203
- package/src/styles/folder-view.css +210 -0
- package/src/styles/image-edit-affordance.css +2 -2
- package/src/styles/index.css +4 -0
- package/src/timelineSource.ts +244 -0
- package/src/tiptapBridge.ts +115 -34
- package/src/tooltipPlacement.ts +13 -0
- package/src/transitionCatalog.ts +159 -0
- package/src/types/monaco-shims.d.ts +10 -0
- package/src/useBlockNavigator.ts +153 -0
- package/src/useMonacoLoader.ts +23 -1
- package/src/useTimelineClock.ts +76 -0
- 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
|
+
}
|