@bendyline/squisq-editor-react 1.6.2 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -3
- package/dist/index.d.ts +1275 -183
- package/dist/index.js +20345 -12777
- package/dist/index.js.map +1 -1
- package/dist/monaco.d.ts +1 -0
- package/dist/monaco.js +4 -0
- package/dist/monaco.js.map +1 -0
- package/dist/styles/index.css +1607 -241
- package/package.json +9 -5
- package/src/BlockPropertiesPopover.tsx +30 -15
- package/src/DocumentSettingsDialog.tsx +39 -33
- package/src/EditorContext.tsx +199 -42
- package/src/EditorShell.tsx +349 -261
- package/src/ImageEditor.tsx +22 -1
- package/src/InlinePreviewGutter.tsx +16 -8
- package/src/MediaBin.tsx +107 -14
- package/src/OutlinePanel.tsx +202 -7
- package/src/PlainHtmlPreview.tsx +40 -1
- package/src/PreviewControls.tsx +362 -89
- package/src/PreviewPanel.tsx +312 -78
- package/src/RawEditor.tsx +13 -2
- package/src/RecorderEntry.tsx +2 -0
- package/src/StatusBar.tsx +5 -2
- package/src/TemplateAnnotation.ts +12 -3
- package/src/TemplatePicker.tsx +75 -21
- package/src/ThemeCustomizerPanel.tsx +8 -0
- package/src/ThemePicker.tsx +10 -5
- package/src/TimelineBlockPreview.tsx +1 -1
- package/src/Toolbar.tsx +357 -51
- package/src/TransitionPicker.tsx +36 -4
- package/src/ViewMenuPanel.tsx +12 -14
- package/src/WysiwygEditor.tsx +124 -91
- package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
- package/src/__tests__/blockTagActivity.test.ts +183 -0
- package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
- package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
- package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
- package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
- package/src/__tests__/codeContextSectionView.test.tsx +8 -6
- package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
- package/src/__tests__/documentSettingsDialog.test.tsx +51 -1
- package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
- package/src/__tests__/editorScrollSync.test.ts +65 -0
- package/src/__tests__/editorShellProps.test.tsx +123 -1
- package/src/__tests__/emojiPicker.test.tsx +2 -42
- package/src/__tests__/headingTransition.test.ts +0 -12
- package/src/__tests__/imageEditAffordance.test.tsx +5 -1
- package/src/__tests__/imageEditModalContract.test.tsx +119 -0
- package/src/__tests__/imageEditorShell.test.tsx +15 -1
- package/src/__tests__/jsonEditor.test.tsx +56 -1
- package/src/__tests__/layersPanel.test.tsx +75 -5
- package/src/__tests__/majorApiTypes.test.ts +26 -0
- package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
- package/src/__tests__/mediaBinDrop.test.tsx +90 -0
- package/src/__tests__/narrationSave.test.ts +148 -0
- package/src/__tests__/outlinePanel.test.tsx +93 -3
- package/src/__tests__/outlineSource.test.ts +217 -0
- package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
- package/src/__tests__/presentationMode.test.tsx +290 -0
- package/src/__tests__/previewControls.test.tsx +382 -5
- package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
- package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
- package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
- package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
- package/src/__tests__/recorderTheme.test.tsx +42 -0
- package/src/__tests__/resolveBlockVisual.test.ts +43 -0
- package/src/__tests__/selectionConversions.test.ts +80 -0
- package/src/__tests__/statusBar.test.tsx +27 -0
- package/src/__tests__/teleprompter.test.tsx +190 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
- package/src/__tests__/templatePickerPortal.test.tsx +62 -0
- package/src/__tests__/tiptapBridge.test.ts +71 -7
- package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
- package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
- package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
- package/src/__tests__/transformStyleId.test.ts +13 -0
- package/src/__tests__/useImageEditor.test.tsx +67 -0
- package/src/__tests__/useMediaRecorder.test.ts +75 -0
- package/src/__tests__/viewMenuPanel.test.tsx +130 -0
- package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
- package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
- package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
- package/src/asciiDiagram/AsciiDiagramWidget.tsx +254 -0
- package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
- package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
- package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
- package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +284 -0
- package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
- package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
- package/src/asciiDiagram/asciiDiagramCommands.ts +174 -0
- package/src/asciiDiagram/asciiDiagramData.ts +149 -0
- package/src/asciiDiagram/asciiDiagramOps.ts +238 -0
- package/src/asciiDiagram/asciiPaste.ts +19 -0
- package/src/blockTagActivity.ts +233 -0
- package/src/buildDocumentPreviewMarkdown.ts +168 -0
- package/src/buildPreviewDoc.ts +19 -12
- package/src/codeContext/types.ts +1 -1
- package/src/customTemplates/CustomLayoutManager.tsx +3 -2
- package/src/customTemplates/CustomTemplateContext.tsx +6 -0
- package/src/customTemplates/TemplateDesigner.tsx +7 -4
- package/src/customTemplates/__tests__/library.test.ts +8 -0
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
- package/src/customTemplates/designer.css +158 -99
- package/src/customTemplates/library.ts +16 -3
- package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
- package/src/customThemes/CustomThemeContext.tsx +6 -0
- package/src/customThemes/CustomThemeDialog.tsx +35 -5
- package/src/customThemes/ImportThemeSection.tsx +178 -0
- package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
- package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
- package/src/customThemes/customThemeLibrary.ts +7 -4
- package/src/customThemes/index.ts +7 -1
- package/src/customThemes/themeDraft.ts +23 -7
- package/src/diagram/DiagramCanvas.tsx +25 -6
- package/src/diagram/types.ts +35 -0
- package/src/editorScrollSync.ts +73 -0
- package/src/emojiData.ts +3 -23
- package/src/frontmatterSettings.ts +23 -0
- package/src/headingTransition.ts +0 -15
- package/src/imageEditor/CanvasSurface.tsx +29 -23
- package/src/imageEditor/LayersPanel.tsx +78 -3
- package/src/imageEditor/Toolbar.tsx +5 -1
- package/src/imageEditor/icons.tsx +4 -0
- package/src/imageEditor/image-editor.css +75 -0
- package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
- package/src/imageEditor/useImageEditor.ts +71 -14
- package/src/index.ts +198 -20
- package/src/jsonEditor/JsonEditorContext.tsx +12 -6
- package/src/jsonEditor/RenderNode.tsx +24 -3
- package/src/jsonEditor/editors.tsx +86 -19
- package/src/mediaEntries.ts +12 -0
- package/src/monaco.ts +35 -0
- package/src/monacoWorkers.ts +89 -0
- package/src/outlineSource.ts +171 -0
- package/src/presentation/PresentationMode.tsx +596 -0
- package/src/rawEditorIsolation.ts +18 -0
- package/src/recorder/RecorderButton.tsx +9 -1
- package/src/recorder/RecorderModal.tsx +84 -41
- package/src/recorder/RecorderPanel.tsx +9 -1
- package/src/recorder/hooks/useMediaRecorder.ts +97 -53
- package/src/resolveBlockVisual.ts +10 -4
- package/src/scene/Scene.tsx +48 -18
- package/src/scene/SceneBlockExtension.ts +17 -10
- package/src/scene/SceneBlockWidget.tsx +5 -1
- package/src/scene/SceneViewport.tsx +7 -3
- package/src/scene/ShapePalette.tsx +17 -2
- package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
- package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
- package/src/scene/__tests__/sceneIsolation.test.tsx +119 -0
- package/src/scene/adapters/DiagramAdapter.ts +12 -101
- package/src/scene/adapters/DrawingAdapter.ts +6 -1
- package/src/scene/adapters/LayoutAdapter.ts +3 -0
- package/src/scene/commands/SceneCommand.ts +17 -3
- package/src/scene/index.ts +1 -6
- package/src/scene/layers/DiagramEdges.tsx +21 -7
- package/src/scene/layers/edgeGeometry.ts +9 -1
- package/src/scene/layers/nodeCard.tsx +27 -8
- package/src/scene/scene.css +5 -2
- package/src/scene/text/SceneTextOverlay.tsx +5 -5
- package/src/scene/text/sceneTextChannel.ts +26 -20
- package/src/scene/text/sceneTextConfig.ts +4 -0
- package/src/scene/tools/ConnectTool.ts +13 -4
- package/src/scene/tools/SceneTool.ts +8 -0
- package/src/scene/tools/SelectTool.ts +39 -23
- package/src/scene/tools/ShapeTool.ts +2 -3
- package/src/selectionConversions.ts +155 -0
- package/src/styles/ascii-diagram.css +79 -0
- package/src/styles/ascii-timeline.css +596 -0
- package/src/styles/editor.css +736 -120
- package/src/styles/index.css +3 -0
- package/src/styles/tree-view.css +189 -0
- package/src/teleprompter/TeleprompterControls.tsx +218 -0
- package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
- package/src/teleprompter/TeleprompterSurface.tsx +267 -0
- package/src/teleprompter/TeleprompterView.tsx +338 -0
- package/src/teleprompter/canvasRenderer.ts +161 -0
- package/src/teleprompter/floatingWindow.ts +352 -0
- package/src/teleprompter/index.ts +61 -0
- package/src/teleprompter/pcmWorklet.ts +62 -0
- package/src/teleprompter/recording/insertPreamble.ts +80 -0
- package/src/teleprompter/recording/narrationSave.ts +134 -0
- package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
- package/src/teleprompter/scrollModel.ts +85 -0
- package/src/teleprompter/teleprompterTheme.ts +303 -0
- package/src/teleprompter/types.ts +38 -0
- package/src/teleprompter/useFloatingWindow.ts +74 -0
- package/src/teleprompter/useMicAnalysis.ts +211 -0
- package/src/teleprompter/useTeleprompter.ts +421 -0
- package/src/templateContentPreviewResolver.ts +12 -5
- package/src/timeline/TimelineEditorWidget.tsx +861 -0
- package/src/timeline/TimelineViewExtension.ts +252 -0
- package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +414 -0
- package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
- package/src/timeline/__tests__/timelineCommands.test.ts +359 -0
- package/src/timeline/__tests__/timelineOps.test.ts +277 -0
- package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
- package/src/timeline/timelineCommands.ts +421 -0
- package/src/timeline/timelineData.ts +54 -0
- package/src/timeline/timelineOps.ts +381 -0
- package/src/timeline/timelinePaste.ts +8 -0
- package/src/tiptapBridge.ts +47 -21
- package/src/transformStyleId.ts +17 -0
- package/src/treeview/TreeOutlineWidget.tsx +390 -0
- package/src/treeview/TreeViewExtension.ts +250 -0
- package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
- package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
- package/src/treeview/__tests__/treeOps.test.ts +177 -0
- package/src/treeview/__tests__/treePaste.test.ts +40 -0
- package/src/treeview/__tests__/treeViewCommands.test.ts +179 -0
- package/src/treeview/treeOps.ts +231 -0
- package/src/treeview/treePaste.ts +13 -0
- package/src/treeview/treeViewCommands.ts +99 -0
- package/src/treeview/treeViewData.ts +41 -0
- package/src/useMonacoLoader.ts +11 -34
- package/src/wysiwygImageUpload.ts +113 -0
- package/src/diagram/DiagramExtension.ts +0 -209
- package/src/diagram/DiagramWidget.tsx +0 -272
- package/src/diagram/useDiagramData.ts +0 -126
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry and ownership behavior for TimelineViewExtension. These tests
|
|
3
|
+
* exercise the stable position registry that keeps the mounted editor alive
|
|
4
|
+
* across unrelated edits and its own canonical fence rewrites.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Editor } from '@tiptap/core';
|
|
8
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
9
|
+
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
10
|
+
import { markdownToTiptap } from '../../tiptapBridge';
|
|
11
|
+
import { HeadingWithTemplate } from '../../TemplateAnnotation';
|
|
12
|
+
import {
|
|
13
|
+
TimelineViewExtension,
|
|
14
|
+
TIMELINE_VIEW_KEY,
|
|
15
|
+
findTimelineBlockPos,
|
|
16
|
+
} from '../TimelineViewExtension';
|
|
17
|
+
import { AsciiDiagramExtension, ASCII_DIAGRAM_KEY } from '../../asciiDiagram/AsciiDiagramExtension';
|
|
18
|
+
import { replaceAsciiFenceText } from '../../asciiDiagram/asciiDiagramCommands';
|
|
19
|
+
import { TreeViewExtension, TREEVIEW_KEY } from '../../treeview/TreeViewExtension';
|
|
20
|
+
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
if (typeof globalThis.ResizeObserver === 'undefined') {
|
|
23
|
+
class ResizeObserverStub {
|
|
24
|
+
observe(): void {}
|
|
25
|
+
unobserve(): void {}
|
|
26
|
+
disconnect(): void {}
|
|
27
|
+
}
|
|
28
|
+
globalThis.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const TIMELINE = 'Milestones: ● Start {#start} ─────● Review {#review} ─────● Ship {#ship} ───►';
|
|
33
|
+
const TIMELINE_B = 'Releases: ● Alpha {#alpha} ─────○ Beta {#beta} ───►';
|
|
34
|
+
const ONE_POINT_TIMELINE = 'Milestones: ● Start {#start} ─────►';
|
|
35
|
+
const TREE = ['src/', '├── index.ts', '└── utils/', ' └── math.ts'].join('\n');
|
|
36
|
+
const DIAGRAM = [
|
|
37
|
+
'┌────────┐',
|
|
38
|
+
'│ Alpha │',
|
|
39
|
+
'└────┬───┘',
|
|
40
|
+
' ▼',
|
|
41
|
+
'┌────────┐',
|
|
42
|
+
'│ Beta │',
|
|
43
|
+
'└────────┘',
|
|
44
|
+
].join('\n');
|
|
45
|
+
|
|
46
|
+
let editors: Editor[] = [];
|
|
47
|
+
|
|
48
|
+
function makeEditor(markdown: string, options?: { enabled?: boolean }): Editor {
|
|
49
|
+
const editor = new Editor({
|
|
50
|
+
extensions: [
|
|
51
|
+
StarterKit.configure({
|
|
52
|
+
heading: false,
|
|
53
|
+
codeBlock: { HTMLAttributes: { class: 'squisq-code-block' } },
|
|
54
|
+
}),
|
|
55
|
+
HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
|
|
56
|
+
AsciiDiagramExtension,
|
|
57
|
+
options ? TimelineViewExtension.configure(options) : TimelineViewExtension,
|
|
58
|
+
TreeViewExtension,
|
|
59
|
+
],
|
|
60
|
+
content: markdownToTiptap(markdown),
|
|
61
|
+
});
|
|
62
|
+
editors.push(editor);
|
|
63
|
+
return editor;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
for (const editor of editors) editor.destroy();
|
|
68
|
+
editors = [];
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const timelineEntries = (editor: Editor) => TIMELINE_VIEW_KEY.getState(editor.state)?.entries ?? [];
|
|
72
|
+
const diagramEntries = (editor: Editor) => ASCII_DIAGRAM_KEY.getState(editor.state)?.entries ?? [];
|
|
73
|
+
const treeEntries = (editor: Editor) => TREEVIEW_KEY.getState(editor.state)?.entries ?? [];
|
|
74
|
+
|
|
75
|
+
function fenceOf(editor: Editor): { text: string; language: string | null } {
|
|
76
|
+
let text = '';
|
|
77
|
+
let language: string | null = null;
|
|
78
|
+
editor.state.doc.descendants((node) => {
|
|
79
|
+
if (node.type.name !== 'codeBlock' || text) return true;
|
|
80
|
+
text = node.textContent;
|
|
81
|
+
language = (node.attrs as { language?: string | null }).language ?? null;
|
|
82
|
+
return false;
|
|
83
|
+
});
|
|
84
|
+
return { text, language };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('TimelineViewExtension registry', () => {
|
|
88
|
+
it('detects untagged and explicit timeline fences with distinct block ids', () => {
|
|
89
|
+
const editor = makeEditor(
|
|
90
|
+
'```\n' + TIMELINE + '\n```\n\n```timeline\n' + TIMELINE_B + '\n```\n',
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const entries = timelineEntries(editor);
|
|
94
|
+
expect(entries).toHaveLength(2);
|
|
95
|
+
expect(entries[0].id).not.toBe(entries[1].id);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('ignores real-language and non-timeline fences', () => {
|
|
99
|
+
const editor = makeEditor(
|
|
100
|
+
'```ts\n' + TIMELINE + '\n```\n\n```\nplain code\nwithout a timeline\n```\n',
|
|
101
|
+
);
|
|
102
|
+
expect(timelineEntries(editor)).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('is mutually exclusive with diagram and tree fence editors', () => {
|
|
106
|
+
const editor = makeEditor(
|
|
107
|
+
'```\n' + TIMELINE + '\n```\n\n```\n' + DIAGRAM + '\n```\n\n```\n' + TREE + '\n```\n',
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(timelineEntries(editor)).toHaveLength(1);
|
|
111
|
+
expect(diagramEntries(editor)).toHaveLength(1);
|
|
112
|
+
expect(treeEntries(editor)).toHaveLength(1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('keeps a block id stable when content above shifts its position', () => {
|
|
116
|
+
const editor = makeEditor('Before.\n\n```\n' + TIMELINE + '\n```\n');
|
|
117
|
+
const [before] = timelineEntries(editor);
|
|
118
|
+
|
|
119
|
+
editor.commands.insertContentAt(0, '<p>Inserted above</p>');
|
|
120
|
+
|
|
121
|
+
const [after] = timelineEntries(editor);
|
|
122
|
+
expect(after.id).toBe(before.id);
|
|
123
|
+
expect(after.pos).toBeGreaterThan(before.pos);
|
|
124
|
+
expect(findTimelineBlockPos(editor, before.id)).toBe(after.pos);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('survives a self-rewrite that promotes the fence language', () => {
|
|
128
|
+
const editor = makeEditor('```text\n' + TIMELINE + '\n```\n');
|
|
129
|
+
const [before] = timelineEntries(editor);
|
|
130
|
+
const rewritten = TIMELINE.replace('Review', 'Verify');
|
|
131
|
+
|
|
132
|
+
expect(replaceAsciiFenceText(editor, before.pos, rewritten, 'timeline')).toBe(true);
|
|
133
|
+
|
|
134
|
+
const [after] = timelineEntries(editor);
|
|
135
|
+
expect(after.id).toBe(before.id);
|
|
136
|
+
expect(findTimelineBlockPos(editor, before.id)).toBe(after.pos);
|
|
137
|
+
expect(fenceOf(editor)).toEqual({ text: rewritten, language: 'timeline' });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('keeps an already registered fence interactive after it degrades to one point', () => {
|
|
141
|
+
const editor = makeEditor('```\n' + TIMELINE + '\n```\n');
|
|
142
|
+
const [before] = timelineEntries(editor);
|
|
143
|
+
|
|
144
|
+
expect(replaceAsciiFenceText(editor, before.pos, ONE_POINT_TIMELINE)).toBe(true);
|
|
145
|
+
|
|
146
|
+
const entries = timelineEntries(editor);
|
|
147
|
+
expect(entries).toHaveLength(1);
|
|
148
|
+
expect(entries[0].id).toBe(before.id);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('drops a registered fence after an invalid rewrite', () => {
|
|
152
|
+
const editor = makeEditor('```\n' + TIMELINE + '\n```\n');
|
|
153
|
+
const [entry] = timelineEntries(editor);
|
|
154
|
+
|
|
155
|
+
expect(replaceAsciiFenceText(editor, entry.pos, 'plain text\nno points or rail')).toBe(true);
|
|
156
|
+
expect(timelineEntries(editor)).toHaveLength(0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('enabled: false leaves the extension inert', () => {
|
|
160
|
+
const editor = makeEditor('```timeline\n' + TIMELINE + '\n```\n', { enabled: false });
|
|
161
|
+
expect(TIMELINE_VIEW_KEY.getState(editor.state)).toBeUndefined();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/** Real Tiptap coverage for parse -> op -> render -> verified fence rewrite. */
|
|
2
|
+
|
|
3
|
+
import { Editor } from '@tiptap/core';
|
|
4
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
5
|
+
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
6
|
+
import { parseAsciiTimeline } from '@bendyline/squisq/doc';
|
|
7
|
+
import { markdownToTiptap } from '../../tiptapBridge';
|
|
8
|
+
import { HeadingWithTemplate } from '../../TemplateAnnotation';
|
|
9
|
+
import { TIMELINE_VIEW_KEY, TimelineViewExtension } from '../TimelineViewExtension';
|
|
10
|
+
import { applyTimelineCommand, isTimelineSourceSafeForSemanticEdit } from '../timelineCommands';
|
|
11
|
+
|
|
12
|
+
beforeAll(() => {
|
|
13
|
+
if (typeof globalThis.ResizeObserver === 'undefined') {
|
|
14
|
+
class ResizeObserverStub {
|
|
15
|
+
observe(): void {}
|
|
16
|
+
unobserve(): void {}
|
|
17
|
+
disconnect(): void {}
|
|
18
|
+
}
|
|
19
|
+
globalThis.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver;
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const ART = [
|
|
24
|
+
'Milestones: ● Start {#start} ─────● Review {#review} ─────►',
|
|
25
|
+
'',
|
|
26
|
+
'branch: start -> review : flow',
|
|
27
|
+
].join('\n');
|
|
28
|
+
|
|
29
|
+
const WRAPPED_FLOW = [
|
|
30
|
+
'CLIENT KERNEL',
|
|
31
|
+
'────── ──────',
|
|
32
|
+
'input ──► command ───────► validate',
|
|
33
|
+
' ├─ invalid → rejected ─┐',
|
|
34
|
+
' └─ valid → queue │',
|
|
35
|
+
'buffer ◄── delta ◄──────── output │',
|
|
36
|
+
'error ◄──────────────────────────────────────────┘',
|
|
37
|
+
].join('\n');
|
|
38
|
+
|
|
39
|
+
let editors: Editor[] = [];
|
|
40
|
+
|
|
41
|
+
function makeEditor(markdown: string): Editor {
|
|
42
|
+
const editor = new Editor({
|
|
43
|
+
extensions: [
|
|
44
|
+
StarterKit.configure({
|
|
45
|
+
heading: false,
|
|
46
|
+
codeBlock: { HTMLAttributes: { class: 'squisq-code-block' } },
|
|
47
|
+
}),
|
|
48
|
+
HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
|
|
49
|
+
TimelineViewExtension,
|
|
50
|
+
],
|
|
51
|
+
content: markdownToTiptap(markdown),
|
|
52
|
+
});
|
|
53
|
+
editors.push(editor);
|
|
54
|
+
return editor;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
for (const editor of editors) editor.destroy();
|
|
59
|
+
editors = [];
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function firstBlockId(editor: Editor): string {
|
|
63
|
+
const state = TIMELINE_VIEW_KEY.getState(editor.state);
|
|
64
|
+
expect(state?.entries).toHaveLength(1);
|
|
65
|
+
return state!.entries[0].id;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function fenceOf(editor: Editor): { text: string; language: string | null } {
|
|
69
|
+
let text = '';
|
|
70
|
+
let language: string | null = null;
|
|
71
|
+
editor.state.doc.descendants((node) => {
|
|
72
|
+
if (node.type.name !== 'codeBlock' || text) return true;
|
|
73
|
+
text = node.textContent;
|
|
74
|
+
language = (node.attrs as { language?: string | null }).language ?? null;
|
|
75
|
+
return false;
|
|
76
|
+
});
|
|
77
|
+
return { text, language };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('applyTimelineCommand', () => {
|
|
81
|
+
it('creates, renames, and deletes a line through verified fence rewrites', () => {
|
|
82
|
+
const editor = makeEditor('```timeline\n' + ART + '\n```\n');
|
|
83
|
+
const blockId = firstBlockId(editor);
|
|
84
|
+
|
|
85
|
+
expect(
|
|
86
|
+
applyTimelineCommand(editor, blockId, {
|
|
87
|
+
kind: 'addTrack',
|
|
88
|
+
label: 'Release',
|
|
89
|
+
eventLabel: 'Ready',
|
|
90
|
+
}),
|
|
91
|
+
).toEqual({ applied: true, eventId: 'ready', trackId: 'release' });
|
|
92
|
+
let timeline = parseAsciiTimeline(fenceOf(editor).text);
|
|
93
|
+
expect(timeline.tracks.map((track) => track.label)).toEqual(['Milestones', 'Release']);
|
|
94
|
+
expect(timeline.tracks[1].events[0]).toMatchObject({ id: 'ready', label: 'Ready' });
|
|
95
|
+
|
|
96
|
+
expect(
|
|
97
|
+
applyTimelineCommand(editor, blockId, {
|
|
98
|
+
kind: 'updateTrack',
|
|
99
|
+
trackId: 'release',
|
|
100
|
+
label: 'Shipping',
|
|
101
|
+
}),
|
|
102
|
+
).toEqual({ applied: true });
|
|
103
|
+
timeline = parseAsciiTimeline(fenceOf(editor).text);
|
|
104
|
+
expect(timeline.tracks[1]).toMatchObject({ id: 'release', label: 'Shipping' });
|
|
105
|
+
|
|
106
|
+
expect(
|
|
107
|
+
applyTimelineCommand(editor, blockId, { kind: 'removeTrack', trackId: 'release' }),
|
|
108
|
+
).toEqual({ applied: true });
|
|
109
|
+
timeline = parseAsciiTimeline(fenceOf(editor).text);
|
|
110
|
+
expect(timeline.tracks.map((track) => track.id)).toEqual(['milestones']);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('adds a point, returns its id, and promotes the language in one rewrite', () => {
|
|
114
|
+
const editor = makeEditor('```text\n' + ART + '\n```\n');
|
|
115
|
+
const blockId = firstBlockId(editor);
|
|
116
|
+
expect(fenceOf(editor).language).toBe('text');
|
|
117
|
+
|
|
118
|
+
const result = applyTimelineCommand(editor, blockId, {
|
|
119
|
+
kind: 'addEvent',
|
|
120
|
+
trackId: 'milestones',
|
|
121
|
+
position: 0.5,
|
|
122
|
+
label: 'Build',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(result).toEqual({ applied: true, eventId: 'build' });
|
|
126
|
+
expect(fenceOf(editor).language).toBe('timeline');
|
|
127
|
+
const parsed = parseAsciiTimeline(fenceOf(editor).text);
|
|
128
|
+
expect(parsed.tracks[0].events.map((event) => event.id)).toContain('build');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('updates text and keeps the selected id and branch endpoint stable', () => {
|
|
132
|
+
const editor = makeEditor('```timeline\n' + ART + '\n```\n');
|
|
133
|
+
const result = applyTimelineCommand(editor, firstBlockId(editor), {
|
|
134
|
+
kind: 'updateEvent',
|
|
135
|
+
eventId: 'start',
|
|
136
|
+
patch: { label: 'Boot', description: 'Initialize the world', marker: 'diamond' },
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(result).toEqual({ applied: true });
|
|
140
|
+
const timeline = parseAsciiTimeline(fenceOf(editor).text);
|
|
141
|
+
expect(timeline.tracks[0].events[0]).toMatchObject({
|
|
142
|
+
id: 'start',
|
|
143
|
+
label: 'Boot',
|
|
144
|
+
description: 'Initialize the world',
|
|
145
|
+
marker: 'diamond',
|
|
146
|
+
});
|
|
147
|
+
expect(timeline.links[0]).toMatchObject({ source: 'start', target: 'review' });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('allows a first visual edit to canonicalize a wrapped flow', () => {
|
|
151
|
+
const parsed = parseAsciiTimeline(WRAPPED_FLOW);
|
|
152
|
+
expect(isTimelineSourceSafeForSemanticEdit(WRAPPED_FLOW, parsed)).toBe(true);
|
|
153
|
+
|
|
154
|
+
const editor = makeEditor('```text\n' + WRAPPED_FLOW + '\n```\n');
|
|
155
|
+
const result = applyTimelineCommand(editor, firstBlockId(editor), {
|
|
156
|
+
kind: 'updateEvent',
|
|
157
|
+
eventId: 'input',
|
|
158
|
+
patch: { label: 'Input received' },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(result).toEqual({ applied: true });
|
|
162
|
+
expect(fenceOf(editor).language).toBe('timeline');
|
|
163
|
+
expect(fenceOf(editor).text).toContain('{#client start=');
|
|
164
|
+
expect(parseAsciiTimeline(fenceOf(editor).text).tracks[0].events[0]).toMatchObject({
|
|
165
|
+
id: 'input',
|
|
166
|
+
label: 'Input received',
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('removes a point and its incident branch', () => {
|
|
171
|
+
const editor = makeEditor('```timeline\n' + ART + '\n```\n');
|
|
172
|
+
const result = applyTimelineCommand(editor, firstBlockId(editor), {
|
|
173
|
+
kind: 'removeEvent',
|
|
174
|
+
eventId: 'review',
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(result).toEqual({ applied: true });
|
|
178
|
+
const timeline = parseAsciiTimeline(fenceOf(editor).text);
|
|
179
|
+
expect(timeline.tracks[0].events.map((event) => event.id)).toEqual(['start']);
|
|
180
|
+
expect(timeline.links).toEqual([]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('keeps the block id stable over language promotion', () => {
|
|
184
|
+
const editor = makeEditor('```\n' + ART + '\n```\n');
|
|
185
|
+
const before = firstBlockId(editor);
|
|
186
|
+
const result = applyTimelineCommand(editor, before, {
|
|
187
|
+
kind: 'updateEvent',
|
|
188
|
+
eventId: 'start',
|
|
189
|
+
patch: { label: 'Boot' },
|
|
190
|
+
});
|
|
191
|
+
expect(result.applied).toBe(true);
|
|
192
|
+
expect(firstBlockId(editor)).toBe(before);
|
|
193
|
+
expect(fenceOf(editor).language).toBe('timeline');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('one undo restores the exact original fence bytes', () => {
|
|
197
|
+
const editor = makeEditor('```text\n' + ART + '\n```\n');
|
|
198
|
+
const blockId = firstBlockId(editor);
|
|
199
|
+
const before = fenceOf(editor);
|
|
200
|
+
expect(
|
|
201
|
+
applyTimelineCommand(editor, blockId, {
|
|
202
|
+
kind: 'updateEvent',
|
|
203
|
+
eventId: 'start',
|
|
204
|
+
patch: { label: 'Boot' },
|
|
205
|
+
}).applied,
|
|
206
|
+
).toBe(true);
|
|
207
|
+
expect(fenceOf(editor)).not.toEqual(before);
|
|
208
|
+
|
|
209
|
+
editor.commands.undo();
|
|
210
|
+
expect(fenceOf(editor)).toEqual(before);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('leaves the document untouched for invalid/no-op commands', () => {
|
|
214
|
+
const editor = makeEditor('```timeline\n' + ART + '\n```\n');
|
|
215
|
+
const blockId = firstBlockId(editor);
|
|
216
|
+
const before = JSON.stringify(editor.state.doc.toJSON());
|
|
217
|
+
|
|
218
|
+
expect(
|
|
219
|
+
applyTimelineCommand(editor, blockId, {
|
|
220
|
+
kind: 'addEvent',
|
|
221
|
+
trackId: 'milestones',
|
|
222
|
+
position: 0,
|
|
223
|
+
}),
|
|
224
|
+
).toEqual({ applied: false });
|
|
225
|
+
expect(
|
|
226
|
+
applyTimelineCommand(editor, blockId, {
|
|
227
|
+
kind: 'updateEvent',
|
|
228
|
+
eventId: 'missing',
|
|
229
|
+
patch: { label: 'Nope' },
|
|
230
|
+
}),
|
|
231
|
+
).toEqual({ applied: false });
|
|
232
|
+
expect(
|
|
233
|
+
applyTimelineCommand(editor, 'timeline-999', {
|
|
234
|
+
kind: 'removeEvent',
|
|
235
|
+
eventId: 'start',
|
|
236
|
+
}),
|
|
237
|
+
).toEqual({ applied: false });
|
|
238
|
+
expect(JSON.stringify(editor.state.doc.toJSON())).toBe(before);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('blocks edits when parse warnings represent unresolved source', () => {
|
|
242
|
+
const unresolved = ART + '\nbranch: review -> missing : preserve this declaration';
|
|
243
|
+
const editor = makeEditor('```timeline\n' + unresolved + '\n```\n');
|
|
244
|
+
const before = fenceOf(editor);
|
|
245
|
+
|
|
246
|
+
expect(
|
|
247
|
+
applyTimelineCommand(editor, firstBlockId(editor), {
|
|
248
|
+
kind: 'updateEvent',
|
|
249
|
+
eventId: 'start',
|
|
250
|
+
patch: { label: 'Boot' },
|
|
251
|
+
}),
|
|
252
|
+
).toEqual({ applied: false, reason: 'unsafe-source' });
|
|
253
|
+
expect(fenceOf(editor)).toEqual(before);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it.each([
|
|
257
|
+
{
|
|
258
|
+
name: 'an ignored prose line',
|
|
259
|
+
art: ART + '\nauthor-note: preserve this raw line',
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
name: 'an unrecognized inline attribute',
|
|
263
|
+
art: ART.replace('{#start}', '{#start mood=urgent}'),
|
|
264
|
+
},
|
|
265
|
+
])('blocks edits that would drop $name', ({ art }) => {
|
|
266
|
+
const editor = makeEditor('```timeline\n' + art + '\n```\n');
|
|
267
|
+
const before = fenceOf(editor);
|
|
268
|
+
|
|
269
|
+
expect(
|
|
270
|
+
applyTimelineCommand(editor, firstBlockId(editor), {
|
|
271
|
+
kind: 'updateEvent',
|
|
272
|
+
eventId: 'start',
|
|
273
|
+
patch: { label: 'Boot' },
|
|
274
|
+
}),
|
|
275
|
+
).toEqual({ applied: false, reason: 'unsafe-source' });
|
|
276
|
+
expect(fenceOf(editor)).toEqual(before);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it.each([
|
|
280
|
+
{
|
|
281
|
+
name: 'punctuation after an otherwise valid id',
|
|
282
|
+
art: ART.replace('{#start}', '{#start !!!}'),
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
name: 'an invalid known attribute value made only from represented tokens',
|
|
286
|
+
art: ART.replace('{#start}', '{#start side=Start}'),
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
name: 'duplicate attributes',
|
|
290
|
+
art: ART.replace('{#start}', '{#start side=above side=below}'),
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: 'a malformed metadata block',
|
|
294
|
+
art: ART.replace('{#start}', '{#start side=above'),
|
|
295
|
+
},
|
|
296
|
+
])('fails closed for $name', ({ art }) => {
|
|
297
|
+
const editor = makeEditor('```timeline\n' + art + '\n```\n');
|
|
298
|
+
const before = fenceOf(editor);
|
|
299
|
+
|
|
300
|
+
expect(
|
|
301
|
+
applyTimelineCommand(editor, firstBlockId(editor), {
|
|
302
|
+
kind: 'updateEvent',
|
|
303
|
+
eventId: 'start',
|
|
304
|
+
patch: { label: 'Boot' },
|
|
305
|
+
}),
|
|
306
|
+
).toEqual({ applied: false, reason: 'unsafe-source' });
|
|
307
|
+
expect(fenceOf(editor)).toEqual(before);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it.each([
|
|
311
|
+
{ name: 'a punctuation-only row', suffix: '!!!' },
|
|
312
|
+
{ name: 'ignored prose whose token already exists', suffix: 'Start' },
|
|
313
|
+
])('fails closed for $name', ({ suffix }) => {
|
|
314
|
+
const art = `${ART}\n${suffix}`;
|
|
315
|
+
const editor = makeEditor('```timeline\n' + art + '\n```\n');
|
|
316
|
+
const before = fenceOf(editor);
|
|
317
|
+
|
|
318
|
+
expect(
|
|
319
|
+
applyTimelineCommand(editor, firstBlockId(editor), {
|
|
320
|
+
kind: 'updateEvent',
|
|
321
|
+
eventId: 'review',
|
|
322
|
+
patch: { label: 'Gate' },
|
|
323
|
+
}),
|
|
324
|
+
).toEqual({ applied: false, reason: 'unsafe-source' });
|
|
325
|
+
expect(fenceOf(editor)).toEqual(before);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('audits the supported 400-line maximum without quadratic reparsing', () => {
|
|
329
|
+
const art = Array.from(
|
|
330
|
+
{ length: 400 },
|
|
331
|
+
(_, index) =>
|
|
332
|
+
`Track ${index}: ● A${index} {#a${index}} ─────────● B${index} {#b${index}} ─────►`,
|
|
333
|
+
).join('\n');
|
|
334
|
+
const timeline = parseAsciiTimeline(art);
|
|
335
|
+
const started = performance.now();
|
|
336
|
+
|
|
337
|
+
expect(isTimelineSourceSafeForSemanticEdit(art, timeline)).toBe(true);
|
|
338
|
+
// The former line-deletion probe took >1 s locally at this size. Leave a
|
|
339
|
+
// generous CI margin while still guarding against reintroducing O(n²)
|
|
340
|
+
// whole-document parsing into the synchronous editor path.
|
|
341
|
+
expect(performance.now() - started).toBeLessThan(1_000);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('rejects stale commands after the editor becomes read-only', () => {
|
|
345
|
+
const editor = makeEditor('```timeline\n' + ART + '\n```\n');
|
|
346
|
+
const blockId = firstBlockId(editor);
|
|
347
|
+
editor.setEditable(false);
|
|
348
|
+
const before = fenceOf(editor);
|
|
349
|
+
|
|
350
|
+
expect(
|
|
351
|
+
applyTimelineCommand(editor, blockId, {
|
|
352
|
+
kind: 'updateEvent',
|
|
353
|
+
eventId: 'start',
|
|
354
|
+
patch: { label: 'Boot' },
|
|
355
|
+
}),
|
|
356
|
+
).toEqual({ applied: false, reason: 'read-only' });
|
|
357
|
+
expect(fenceOf(editor)).toEqual(before);
|
|
358
|
+
});
|
|
359
|
+
});
|