@bendyline/squisq-editor-react 2.0.0 → 2.1.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/README.md +10 -2
- package/dist/index.d.ts +579 -47
- package/dist/index.js +7477 -2943
- package/dist/index.js.map +1 -1
- package/dist/styles/index.css +1008 -38
- package/package.json +6 -5
- package/src/DocumentSettingsDialog.tsx +32 -18
- package/src/EditorContext.tsx +27 -0
- package/src/EditorShell.tsx +25 -1
- package/src/PreviewControls.tsx +77 -29
- package/src/PreviewPanel.tsx +5 -1
- package/src/RawEditor.tsx +12 -0
- package/src/RecorderEntry.tsx +5 -0
- package/src/Toolbar.tsx +479 -36
- package/src/WysiwygEditor.tsx +25 -11
- package/src/__tests__/buildPreviewDocContent.test.ts +17 -0
- package/src/__tests__/codeContextSectionView.test.tsx +8 -6
- package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
- package/src/__tests__/editorShellProps.test.tsx +65 -0
- package/src/__tests__/findMode.test.tsx +104 -0
- package/src/__tests__/findModel.test.ts +55 -0
- package/src/__tests__/markdownCodeFence.test.ts +45 -0
- package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
- package/src/__tests__/mediaReferences.test.ts +15 -0
- package/src/__tests__/previewControls.test.tsx +195 -0
- package/src/__tests__/recorderTheme.test.tsx +42 -0
- package/src/__tests__/selectionConversions.test.ts +80 -0
- package/src/__tests__/tiptapBridge.test.ts +57 -7
- package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
- package/src/__tests__/toolbarSelectionConversion.test.tsx +190 -0
- package/src/__tests__/writeCanvasSettings.test.ts +15 -0
- package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
- package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
- package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
- package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
- package/src/asciiDiagram/asciiDiagramData.ts +33 -0
- package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
- package/src/buildPreviewDoc.ts +9 -7
- package/src/codeContext/types.ts +1 -1
- package/src/codeSnippet/CodeSnippetExtension.ts +205 -0
- package/src/codeSnippet/CodeSnippetWidget.tsx +109 -0
- package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +95 -0
- package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +50 -0
- package/src/codeSnippet/codeSnippetCommands.ts +21 -0
- package/src/codeSnippet/codeSnippetData.ts +43 -0
- package/src/codeSnippet/codeSnippetLanguages.ts +216 -0
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
- package/src/diagram/DiagramCanvas.tsx +17 -2
- package/src/find/FindHighlightExtension.ts +81 -0
- package/src/find/FindToolbar.tsx +314 -0
- package/src/find/findModel.ts +77 -0
- package/src/frontmatterSettings.ts +23 -0
- package/src/index.ts +96 -1
- package/src/markdownCodeFence.ts +72 -0
- package/src/mediaReferences.ts +5 -3
- package/src/mermaid/MermaidDiagramCanvas.tsx +693 -0
- package/src/mermaid/MermaidDiagramExtension.ts +243 -0
- package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +184 -0
- package/src/mermaid/MermaidDiagramWidget.tsx +653 -0
- package/src/mermaid/MermaidShapePalette.tsx +245 -0
- package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +361 -0
- package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +35 -0
- package/src/mermaid/__tests__/mermaidRenderer.test.ts +49 -0
- package/src/mermaid/__tests__/mermaidSourceOps.test.ts +213 -0
- package/src/mermaid/__tests__/mermaidSyntax.test.ts +71 -0
- package/src/mermaid/mermaidCommands.ts +34 -0
- package/src/mermaid/mermaidData.ts +31 -0
- package/src/mermaid/mermaidDiagramTypes.ts +325 -0
- package/src/mermaid/mermaidModel.ts +31 -0
- package/src/mermaid/mermaidRenderer.ts +181 -0
- package/src/mermaid/mermaidShapes.ts +113 -0
- package/src/mermaid/mermaidSourceOps.ts +454 -0
- package/src/recorder/RecorderButton.tsx +9 -1
- package/src/recorder/RecorderModal.tsx +84 -41
- package/src/recorder/RecorderPanel.tsx +9 -1
- package/src/scene/Scene.tsx +46 -15
- package/src/scene/SceneBlockToolbar.tsx +29 -14
- package/src/scene/SceneViewControls.tsx +43 -0
- package/src/scene/__tests__/fitScale.test.ts +19 -0
- package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
- package/src/scene/__tests__/useScenePanZoom.test.ts +28 -1
- package/src/scene/adapters/DrawingAdapter.ts +6 -1
- package/src/scene/adapters/LayoutAdapter.ts +3 -0
- package/src/scene/commands/SceneCommand.ts +13 -2
- package/src/scene/fitScale.ts +17 -0
- package/src/scene/hooks/useScenePanZoom.ts +11 -4
- package/src/scene/scene.css +85 -3
- package/src/scene/tools/SelectTool.ts +5 -5
- package/src/selectionConversions.ts +155 -0
- package/src/styles/ascii-timeline.css +101 -4
- package/src/styles/code-snippet.css +76 -0
- package/src/styles/editor.css +352 -2
- package/src/styles/index.css +2 -0
- package/src/styles/mermaid-diagram.css +487 -0
- package/src/styles/tree-view.css +51 -1
- package/src/timeline/TimelineEditorWidget.tsx +200 -41
- package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
- package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
- package/src/timeline/__tests__/timelineOps.test.ts +55 -0
- package/src/timeline/timelineCommands.ts +54 -0
- package/src/timeline/timelineOps.ts +107 -3
- package/src/tiptapBridge.ts +23 -5
- package/src/treeview/TreeOutlineWidget.tsx +153 -3
- package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
- package/src/treeview/__tests__/treeOps.test.ts +52 -0
- package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
- package/src/treeview/treeOps.ts +59 -0
- package/src/treeview/treeViewCommands.ts +5 -0
- package/src/writeCanvasSettings.ts +30 -0
|
@@ -56,6 +56,8 @@ const clamp01 = (value: number): number => Math.max(0, Math.min(1, value));
|
|
|
56
56
|
export function TimelineEditorWidget({ editor, blockId }: TimelineEditorWidgetProps) {
|
|
57
57
|
const view = useTimelineData(editor, blockId);
|
|
58
58
|
const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
|
|
59
|
+
const [addingTrackId, setAddingTrackId] = useState<string | null>(null);
|
|
60
|
+
const [editingTrackId, setEditingTrackId] = useState<string | null>(null);
|
|
59
61
|
const [hover, setHover] = useState<{ trackId: string; position: number } | null>(null);
|
|
60
62
|
const [dragged, setDragged] = useState<DraggedEvent | null>(null);
|
|
61
63
|
const suppressClickEventId = useRef<string | null>(null);
|
|
@@ -86,6 +88,8 @@ export function TimelineEditorWidget({ editor, blockId }: TimelineEditorWidgetPr
|
|
|
86
88
|
// editor's cursor/selection hot path.
|
|
87
89
|
[sourceText, sourceTimeline],
|
|
88
90
|
);
|
|
91
|
+
const editorEditable = editor.isEditable;
|
|
92
|
+
const editable = editorEditable && sourceSafe;
|
|
89
93
|
|
|
90
94
|
const positioned = useMemo(() => {
|
|
91
95
|
if (!view) return [];
|
|
@@ -142,25 +146,65 @@ export function TimelineEditorWidget({ editor, blockId }: TimelineEditorWidgetPr
|
|
|
142
146
|
setSelectedEventId(view.timeline.tracks[0]?.events[0]?.id ?? null);
|
|
143
147
|
}, [selectedEventId, view]);
|
|
144
148
|
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (editable) return;
|
|
151
|
+
setAddingTrackId(null);
|
|
152
|
+
setEditingTrackId(null);
|
|
153
|
+
setHover(null);
|
|
154
|
+
}, [editable]);
|
|
155
|
+
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (!editingTrackId || view?.timeline.tracks.some((track) => track.id === editingTrackId)) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
setEditingTrackId(null);
|
|
161
|
+
}, [editingTrackId, view]);
|
|
162
|
+
|
|
145
163
|
if (!view) return null;
|
|
146
164
|
|
|
147
|
-
const editorEditable = editor.isEditable;
|
|
148
|
-
const editable = editorEditable && sourceSafe;
|
|
149
165
|
const totalEvents = positioned.length;
|
|
166
|
+
const addTrack = () => {
|
|
167
|
+
if (!editable) return;
|
|
168
|
+
const result = dispatch({ kind: 'addTrack' });
|
|
169
|
+
if (result.applied && result.eventId && result.trackId) {
|
|
170
|
+
setAddingTrackId(null);
|
|
171
|
+
setEditingTrackId(result.trackId);
|
|
172
|
+
setHover(null);
|
|
173
|
+
setSelectedEventId(result.eventId);
|
|
174
|
+
setAnnouncement('New timeline line added. Edit its name on the line.');
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
150
178
|
const addEvent = (trackId: string, position: number) => {
|
|
151
179
|
if (!editable) return;
|
|
152
180
|
const result = dispatch({ kind: 'addEvent', trackId, position: clamp01(position) });
|
|
153
181
|
if (result.applied && result.eventId) {
|
|
182
|
+
setAddingTrackId(null);
|
|
183
|
+
setEditingTrackId(null);
|
|
184
|
+
setHover(null);
|
|
154
185
|
setSelectedEventId(result.eventId);
|
|
155
186
|
setAnnouncement('New timeline point added. Edit its text below.');
|
|
156
187
|
}
|
|
157
188
|
};
|
|
158
189
|
|
|
159
190
|
const selectEvent = (event: AsciiTimelineEvent, trackLabel: string) => {
|
|
191
|
+
setAddingTrackId(null);
|
|
192
|
+
setHover(null);
|
|
160
193
|
setSelectedEventId(event.id);
|
|
161
194
|
setAnnouncement(`${event.label || 'Timeline point'} selected on ${trackLabel}.`);
|
|
162
195
|
};
|
|
163
196
|
|
|
197
|
+
const deleteTrack = (trackId: string, trackLabel: string) => {
|
|
198
|
+
const remaining = positioned.filter((point) => point.trackId !== trackId);
|
|
199
|
+
const result = dispatch({ kind: 'removeTrack', trackId });
|
|
200
|
+
if (!result.applied) return;
|
|
201
|
+
setAddingTrackId(null);
|
|
202
|
+
setEditingTrackId((current) => (current === trackId ? null : current));
|
|
203
|
+
setHover(null);
|
|
204
|
+
if (selected?.trackId === trackId) setSelectedEventId(remaining[0]?.event.id ?? null);
|
|
205
|
+
setAnnouncement(`${trackLabel} line deleted.`);
|
|
206
|
+
};
|
|
207
|
+
|
|
164
208
|
const dragPosition = (event: ReactPointerEvent<HTMLButtonElement>): number | null => {
|
|
165
209
|
const rail = event.currentTarget.closest<HTMLElement>('.squisq-ascii-timeline-rail');
|
|
166
210
|
if (!rail) return null;
|
|
@@ -170,18 +214,36 @@ export function TimelineEditorWidget({ editor, blockId }: TimelineEditorWidgetPr
|
|
|
170
214
|
};
|
|
171
215
|
|
|
172
216
|
return (
|
|
173
|
-
<section
|
|
217
|
+
<section
|
|
218
|
+
className="squisq-ascii-timeline-editor"
|
|
219
|
+
aria-label="Timeline editor"
|
|
220
|
+
onKeyDown={(event) => {
|
|
221
|
+
if (event.key !== 'Escape' || !addingTrackId) return;
|
|
222
|
+
setAddingTrackId(null);
|
|
223
|
+
setHover(null);
|
|
224
|
+
setAnnouncement('Add point cancelled.');
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
174
227
|
<header className="squisq-ascii-timeline-header">
|
|
175
228
|
<span>
|
|
176
229
|
<Icon icon="fa-solid fa-timeline" /> Timeline
|
|
177
230
|
</span>
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
231
|
+
<div className="squisq-ascii-timeline-header-actions">
|
|
232
|
+
{editable && addingTrackId ? (
|
|
233
|
+
<small>Click the line to add a point · Esc to cancel</small>
|
|
234
|
+
) : editable ? (
|
|
235
|
+
<small>Use + to add a point · Drag dots to move</small>
|
|
236
|
+
) : editorEditable ? (
|
|
237
|
+
<small>Source repair needed</small>
|
|
238
|
+
) : (
|
|
239
|
+
<small>Read only</small>
|
|
240
|
+
)}
|
|
241
|
+
{editable ? (
|
|
242
|
+
<button type="button" onClick={addTrack}>
|
|
243
|
+
<Icon icon="fa-solid fa-plus" /> Add line
|
|
244
|
+
</button>
|
|
245
|
+
) : null}
|
|
246
|
+
</div>
|
|
185
247
|
</header>
|
|
186
248
|
|
|
187
249
|
<div
|
|
@@ -227,29 +289,68 @@ export function TimelineEditorWidget({ editor, blockId }: TimelineEditorWidgetPr
|
|
|
227
289
|
.sort((a, b) => a.position - b.position);
|
|
228
290
|
const eventPositions = events.map((entry) => entry.position);
|
|
229
291
|
const gaps = insertionGaps(eventPositions);
|
|
292
|
+
const addingPoint = editable && addingTrackId === track.id;
|
|
230
293
|
return (
|
|
231
294
|
<div className="squisq-ascii-timeline-track" key={track.id}>
|
|
232
|
-
<div className="squisq-ascii-timeline-track-label"
|
|
233
|
-
<
|
|
295
|
+
<div className="squisq-ascii-timeline-track-label">
|
|
296
|
+
<InlineTrackLabel
|
|
297
|
+
trackId={track.id}
|
|
298
|
+
label={trackLabel}
|
|
299
|
+
editing={editable && editingTrackId === track.id}
|
|
300
|
+
editable={editable}
|
|
301
|
+
dispatch={dispatch}
|
|
302
|
+
onStart={() => {
|
|
303
|
+
setAddingTrackId(null);
|
|
304
|
+
setHover(null);
|
|
305
|
+
setEditingTrackId(track.id);
|
|
306
|
+
}}
|
|
307
|
+
onFinish={() => setEditingTrackId(null)}
|
|
308
|
+
/>
|
|
234
309
|
{editable ? (
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
310
|
+
<>
|
|
311
|
+
<button
|
|
312
|
+
type="button"
|
|
313
|
+
className="squisq-ascii-timeline-track-delete"
|
|
314
|
+
aria-label={`Delete line: ${trackLabel}`}
|
|
315
|
+
disabled={view.timeline.tracks.length <= 1}
|
|
316
|
+
title={
|
|
317
|
+
view.timeline.tracks.length > 1
|
|
318
|
+
? `Delete ${trackLabel} line and all of its points`
|
|
319
|
+
: 'A timeline needs one line'
|
|
320
|
+
}
|
|
321
|
+
onClick={() => deleteTrack(track.id, trackLabel)}
|
|
322
|
+
>
|
|
323
|
+
<Icon icon="fa-solid fa-trash" />
|
|
324
|
+
</button>
|
|
325
|
+
<button
|
|
326
|
+
type="button"
|
|
327
|
+
className="squisq-ascii-timeline-track-add"
|
|
328
|
+
aria-label={`Add point to ${trackLabel} timeline`}
|
|
329
|
+
aria-pressed={addingPoint}
|
|
330
|
+
title={addingPoint ? 'Cancel adding a point' : 'Choose where to add a point'}
|
|
331
|
+
onClick={() => {
|
|
332
|
+
setEditingTrackId(null);
|
|
333
|
+
setAddingTrackId((current) => (current === track.id ? null : track.id));
|
|
334
|
+
setHover(null);
|
|
335
|
+
setAnnouncement(
|
|
336
|
+
addingPoint
|
|
337
|
+
? 'Add point cancelled.'
|
|
338
|
+
: `Choose where to add a point on ${trackLabel}.`,
|
|
339
|
+
);
|
|
340
|
+
}}
|
|
341
|
+
>
|
|
342
|
+
+
|
|
343
|
+
</button>
|
|
344
|
+
</>
|
|
244
345
|
) : null}
|
|
245
346
|
</div>
|
|
246
347
|
<div
|
|
247
|
-
className={`squisq-ascii-timeline-rail${editable ? '' : ' squisq-ascii-timeline-rail--readonly'}`}
|
|
348
|
+
className={`squisq-ascii-timeline-rail${addingPoint ? ' squisq-ascii-timeline-rail--adding' : ''}${editable ? '' : ' squisq-ascii-timeline-rail--readonly'}`}
|
|
248
349
|
role="group"
|
|
249
350
|
aria-label={`${trackLabel} timeline rail`}
|
|
250
|
-
title={
|
|
351
|
+
title={addingPoint ? 'Click anywhere on the line to add a point' : undefined}
|
|
251
352
|
onPointerMove={(event) => {
|
|
252
|
-
if (!
|
|
353
|
+
if (!addingPoint || dragged) return;
|
|
253
354
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
254
355
|
if (rect.width <= 0) return;
|
|
255
356
|
setHover({
|
|
@@ -261,14 +362,14 @@ export function TimelineEditorWidget({ editor, blockId }: TimelineEditorWidgetPr
|
|
|
261
362
|
setHover((current) => (current?.trackId === track.id ? null : current))
|
|
262
363
|
}
|
|
263
364
|
onClick={(event) => {
|
|
264
|
-
if (!
|
|
365
|
+
if (!addingPoint || event.target !== event.currentTarget) return;
|
|
265
366
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
266
367
|
if (rect.width <= 0) return;
|
|
267
368
|
addEvent(track.id, (event.clientX - rect.left) / rect.width);
|
|
268
369
|
}}
|
|
269
370
|
>
|
|
270
371
|
<span className="squisq-ascii-timeline-rail-line" aria-hidden="true" />
|
|
271
|
-
{hover?.trackId === track.id && !dragged ? (
|
|
372
|
+
{addingPoint && hover?.trackId === track.id && !dragged ? (
|
|
272
373
|
<span
|
|
273
374
|
className="squisq-ascii-timeline-add-ghost"
|
|
274
375
|
style={{ left: `${hover.position * 100}%` }}
|
|
@@ -278,7 +379,7 @@ export function TimelineEditorWidget({ editor, blockId }: TimelineEditorWidgetPr
|
|
|
278
379
|
</span>
|
|
279
380
|
) : null}
|
|
280
381
|
|
|
281
|
-
{
|
|
382
|
+
{addingPoint && !dragged
|
|
282
383
|
? gaps.map((position) => (
|
|
283
384
|
<button
|
|
284
385
|
type="button"
|
|
@@ -344,6 +445,7 @@ export function TimelineEditorWidget({ editor, blockId }: TimelineEditorWidgetPr
|
|
|
344
445
|
const point = dragPosition(pointerEvent);
|
|
345
446
|
if (point === null) return;
|
|
346
447
|
pointerEvent.stopPropagation();
|
|
448
|
+
setAddingTrackId(null);
|
|
347
449
|
setHover(null);
|
|
348
450
|
setSelectedEventId(event.id);
|
|
349
451
|
suppressClickEventId.current = null;
|
|
@@ -505,6 +607,77 @@ export function TimelineEditorWidget({ editor, blockId }: TimelineEditorWidgetPr
|
|
|
505
607
|
);
|
|
506
608
|
}
|
|
507
609
|
|
|
610
|
+
function InlineTrackLabel({
|
|
611
|
+
trackId,
|
|
612
|
+
label,
|
|
613
|
+
editing,
|
|
614
|
+
editable,
|
|
615
|
+
dispatch,
|
|
616
|
+
onStart,
|
|
617
|
+
onFinish,
|
|
618
|
+
}: {
|
|
619
|
+
trackId: string;
|
|
620
|
+
label: string;
|
|
621
|
+
editing: boolean;
|
|
622
|
+
editable: boolean;
|
|
623
|
+
dispatch: (command: TimelineCommand) => TimelineCommandResult;
|
|
624
|
+
onStart: () => void;
|
|
625
|
+
onFinish: () => void;
|
|
626
|
+
}) {
|
|
627
|
+
const [draft, setDraft] = useState(label);
|
|
628
|
+
|
|
629
|
+
useEffect(() => {
|
|
630
|
+
if (editing) setDraft(label);
|
|
631
|
+
}, [editing, label]);
|
|
632
|
+
|
|
633
|
+
const commitLabel = () => {
|
|
634
|
+
const next = draft.trim();
|
|
635
|
+
if (!next) {
|
|
636
|
+
setDraft(label);
|
|
637
|
+
} else if (next !== label) {
|
|
638
|
+
const result = dispatch({ kind: 'updateTrack', trackId, label: next });
|
|
639
|
+
if (!result.applied) setDraft(label);
|
|
640
|
+
}
|
|
641
|
+
onFinish();
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
if (!editable) return <span className="squisq-ascii-timeline-track-name">{label}</span>;
|
|
645
|
+
|
|
646
|
+
return editing ? (
|
|
647
|
+
<input
|
|
648
|
+
className="squisq-ascii-timeline-track-name-input"
|
|
649
|
+
aria-label={`Rename line: ${label}`}
|
|
650
|
+
value={draft}
|
|
651
|
+
autoFocus
|
|
652
|
+
onFocus={(event) => event.currentTarget.select()}
|
|
653
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
654
|
+
onBlur={commitLabel}
|
|
655
|
+
onKeyDown={(event) => {
|
|
656
|
+
if (event.key === 'Enter') {
|
|
657
|
+
event.preventDefault();
|
|
658
|
+
event.stopPropagation();
|
|
659
|
+
commitLabel();
|
|
660
|
+
} else if (event.key === 'Escape') {
|
|
661
|
+
event.preventDefault();
|
|
662
|
+
event.stopPropagation();
|
|
663
|
+
setDraft(label);
|
|
664
|
+
onFinish();
|
|
665
|
+
}
|
|
666
|
+
}}
|
|
667
|
+
/>
|
|
668
|
+
) : (
|
|
669
|
+
<button
|
|
670
|
+
type="button"
|
|
671
|
+
className="squisq-ascii-timeline-track-name"
|
|
672
|
+
aria-label={`Rename line: ${label}`}
|
|
673
|
+
title={`Rename ${label}`}
|
|
674
|
+
onClick={onStart}
|
|
675
|
+
>
|
|
676
|
+
{label}
|
|
677
|
+
</button>
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
|
|
508
681
|
function EventInspector({
|
|
509
682
|
selected,
|
|
510
683
|
editable,
|
|
@@ -664,20 +837,6 @@ function insertionGaps(positions: readonly number[]): number[] {
|
|
|
664
837
|
return gaps;
|
|
665
838
|
}
|
|
666
839
|
|
|
667
|
-
function largestGap(positions: readonly number[]): number {
|
|
668
|
-
if (positions.length === 0) return 0.5;
|
|
669
|
-
const sorted = [0, ...positions.map(clamp01).sort((a, b) => a - b), 1];
|
|
670
|
-
let start = sorted[0];
|
|
671
|
-
let end = sorted[1];
|
|
672
|
-
for (let index = 1; index < sorted.length - 1; index++) {
|
|
673
|
-
if (sorted[index + 1] - sorted[index] > end - start) {
|
|
674
|
-
start = sorted[index];
|
|
675
|
-
end = sorted[index + 1];
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
return (start + end) / 2;
|
|
679
|
-
}
|
|
680
|
-
|
|
681
840
|
function navigateTrackPoints(event: KeyboardEvent<HTMLButtonElement>): void {
|
|
682
841
|
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
|
683
842
|
const rail = event.currentTarget.closest('.squisq-ascii-timeline-rail');
|
|
@@ -110,6 +110,10 @@ function eventOf(editor: Editor, id: string) {
|
|
|
110
110
|
.find((event) => event.id === id);
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
function trackOf(editor: Editor, id: string) {
|
|
114
|
+
return timelineOf(editor).tracks.find((track) => track.id === id);
|
|
115
|
+
}
|
|
116
|
+
|
|
113
117
|
afterEach(() => {
|
|
114
118
|
cleanup();
|
|
115
119
|
for (const editor of editors) editor.destroy();
|
|
@@ -117,6 +121,40 @@ afterEach(() => {
|
|
|
117
121
|
});
|
|
118
122
|
|
|
119
123
|
describe('TimelineEditorWidget', () => {
|
|
124
|
+
it('creates, renames, selects, and deletes timeline lines', async () => {
|
|
125
|
+
const editor = renderWidget();
|
|
126
|
+
expect(screen.queryByRole('textbox', { name: 'Rename line: Kernel' })).toBeNull();
|
|
127
|
+
fireEvent.click(screen.getByRole('button', { name: 'Rename line: Kernel' }));
|
|
128
|
+
expect(
|
|
129
|
+
(screen.getByRole('textbox', { name: 'Rename line: Kernel' }) as HTMLInputElement).value,
|
|
130
|
+
).toBe('Kernel');
|
|
131
|
+
fireEvent.keyDown(screen.getByRole('textbox', { name: 'Rename line: Kernel' }), {
|
|
132
|
+
key: 'Escape',
|
|
133
|
+
});
|
|
134
|
+
expect(screen.queryByRole('textbox', { name: 'Rename line: Kernel' })).toBeNull();
|
|
135
|
+
|
|
136
|
+
fireEvent.click(screen.getByRole('button', { name: /Add line/ }));
|
|
137
|
+
|
|
138
|
+
await waitFor(() => expect(trackOf(editor, 'new-line')).toBeTruthy());
|
|
139
|
+
expect(timelineOf(editor).tracks).toHaveLength(3);
|
|
140
|
+
expect(trackOf(editor, 'new-line')?.events).toHaveLength(1);
|
|
141
|
+
expect(screen.getByRole('button', { name: /Edit New event, New line/ })).toBeTruthy();
|
|
142
|
+
const lineName = screen.getByRole('textbox', {
|
|
143
|
+
name: 'Rename line: New line',
|
|
144
|
+
}) as HTMLInputElement;
|
|
145
|
+
expect(lineName.value).toBe('New line');
|
|
146
|
+
|
|
147
|
+
fireEvent.change(lineName, { target: { value: 'Release' } });
|
|
148
|
+
fireEvent.blur(lineName);
|
|
149
|
+
await waitFor(() => expect(trackOf(editor, 'new-line')?.label).toBe('Release'));
|
|
150
|
+
expect(screen.getByRole('group', { name: 'Release timeline rail' })).toBeTruthy();
|
|
151
|
+
|
|
152
|
+
fireEvent.click(screen.getByRole('button', { name: 'Delete line: Release' }));
|
|
153
|
+
await waitFor(() => expect(trackOf(editor, 'new-line')).toBeUndefined());
|
|
154
|
+
expect(timelineOf(editor).tracks).toHaveLength(2);
|
|
155
|
+
expect(screen.getByRole('button', { name: 'Rename line: Kernel' })).toBeTruthy();
|
|
156
|
+
});
|
|
157
|
+
|
|
120
158
|
it('selects different dots, including a cadence-only dot, and exposes branches', async () => {
|
|
121
159
|
const editor = makeEditor();
|
|
122
160
|
const rendered = render(<TimelineEditorWidget editor={editor} blockId={blockIdOf(editor)} />);
|
|
@@ -170,7 +208,7 @@ describe('TimelineEditorWidget', () => {
|
|
|
170
208
|
).toBe('true');
|
|
171
209
|
});
|
|
172
210
|
|
|
173
|
-
it('adds
|
|
211
|
+
it('adds one point on an armed rail, then returns to selection mode', async () => {
|
|
174
212
|
const editor = renderWidget();
|
|
175
213
|
const rail = screen.getByRole('group', { name: 'Kernel timeline rail' });
|
|
176
214
|
rail.getBoundingClientRect = () =>
|
|
@@ -186,6 +224,12 @@ describe('TimelineEditorWidget', () => {
|
|
|
186
224
|
toJSON: () => ({}),
|
|
187
225
|
}) as DOMRect;
|
|
188
226
|
|
|
227
|
+
const addPoint = screen.getByRole('button', { name: 'Add point to Kernel timeline' });
|
|
228
|
+
expect(addPoint.getAttribute('aria-pressed')).toBe('false');
|
|
229
|
+
fireEvent.click(addPoint);
|
|
230
|
+
expect(addPoint.getAttribute('aria-pressed')).toBe('true');
|
|
231
|
+
expect(screen.getByText('Click the line to add a point · Esc to cancel')).toBeTruthy();
|
|
232
|
+
|
|
189
233
|
fireEvent.click(rail, { clientX: 240 });
|
|
190
234
|
|
|
191
235
|
await waitFor(() => expect(eventOf(editor, 'new-event')?.column).toBe(35));
|
|
@@ -193,6 +237,13 @@ describe('TimelineEditorWidget', () => {
|
|
|
193
237
|
expect(marker.getAttribute('aria-pressed')).toBe('true');
|
|
194
238
|
expect((screen.getByLabelText('Label') as HTMLInputElement).value).toBe('New event');
|
|
195
239
|
expect(screen.getByText('New timeline point added. Edit its text below.')).toBeTruthy();
|
|
240
|
+
expect(addPoint.getAttribute('aria-pressed')).toBe('false');
|
|
241
|
+
expect(screen.getByText('Use + to add a point · Drag dots to move')).toBeTruthy();
|
|
242
|
+
|
|
243
|
+
fireEvent.click(rail, { clientX: 400 });
|
|
244
|
+
await Promise.resolve();
|
|
245
|
+
expect(timelineOf(editor).tracks[0].events).toHaveLength(3);
|
|
246
|
+
expect(eventOf(editor, 'new-event-2')).toBeUndefined();
|
|
196
247
|
});
|
|
197
248
|
|
|
198
249
|
it('previews a dragged dot and commits its new position only on drop', async () => {
|
|
@@ -253,10 +304,14 @@ describe('TimelineEditorWidget', () => {
|
|
|
253
304
|
|
|
254
305
|
it('always exposes a keyboard-reachable add-point control for each track', async () => {
|
|
255
306
|
const editor = renderWidget();
|
|
256
|
-
|
|
307
|
+
const addPoint = screen.getByRole('button', { name: 'Add point to Kernel timeline' });
|
|
308
|
+
fireEvent.click(addPoint);
|
|
309
|
+
expect(addPoint.getAttribute('aria-pressed')).toBe('true');
|
|
310
|
+
fireEvent.click(screen.getByRole('button', { name: 'Add point to Kernel at 25 percent' }));
|
|
257
311
|
|
|
258
312
|
await waitFor(() => expect(eventOf(editor, 'new-event')?.column).toBe(25));
|
|
259
313
|
expect(screen.getByRole('button', { name: /Edit New event, Kernel/ })).toBeTruthy();
|
|
314
|
+
expect(addPoint.getAttribute('aria-pressed')).toBe('false');
|
|
260
315
|
});
|
|
261
316
|
|
|
262
317
|
it('keeps dots selectable but prevents every mutation in read-only mode', async () => {
|
|
@@ -278,6 +333,9 @@ describe('TimelineEditorWidget', () => {
|
|
|
278
333
|
expect(JSON.stringify(editor.state.doc.toJSON())).toBe(before);
|
|
279
334
|
expect(screen.queryByRole('button', { name: /Add point to Kernel at/ })).toBeNull();
|
|
280
335
|
expect(screen.queryByRole('button', { name: 'Add point to Kernel timeline' })).toBeNull();
|
|
336
|
+
expect(screen.queryByRole('button', { name: /Add line/ })).toBeNull();
|
|
337
|
+
expect(screen.queryByRole('button', { name: 'Rename line: Kernel' })).toBeNull();
|
|
338
|
+
expect(screen.queryByRole('button', { name: 'Delete line: Kernel' })).toBeNull();
|
|
281
339
|
});
|
|
282
340
|
|
|
283
341
|
it('reacts when a mounted editor toggles between editable and read-only', async () => {
|
|
@@ -287,6 +345,7 @@ describe('TimelineEditorWidget', () => {
|
|
|
287
345
|
editor.setEditable(false);
|
|
288
346
|
await screen.findByText('Read only');
|
|
289
347
|
expect(screen.queryByRole('button', { name: 'Add point to Kernel timeline' })).toBeNull();
|
|
348
|
+
expect(screen.queryByRole('button', { name: /Add line/ })).toBeNull();
|
|
290
349
|
expect(
|
|
291
350
|
(screen.getByLabelText('Label').closest('fieldset') as HTMLFieldSetElement).disabled,
|
|
292
351
|
).toBe(true);
|
|
@@ -78,6 +78,38 @@ function fenceOf(editor: Editor): { text: string; language: string | null } {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
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
|
+
|
|
81
113
|
it('adds a point, returns its id, and promotes the language in one rewrite', () => {
|
|
82
114
|
const editor = makeEditor('```text\n' + ART + '\n```\n');
|
|
83
115
|
const blockId = firstBlockId(editor);
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import { parseAsciiTimeline, renderAsciiTimeline, type AsciiTimeline } from '@bendyline/squisq/doc';
|
|
3
3
|
import {
|
|
4
|
+
addTimelineTrackOp,
|
|
4
5
|
addTimelineEventOp,
|
|
5
6
|
nextTimelineEventId,
|
|
7
|
+
nextTimelineTrackId,
|
|
8
|
+
removeTimelineTrackOp,
|
|
6
9
|
removeTimelineEventOp,
|
|
7
10
|
sanitizeTimelineText,
|
|
11
|
+
updateTimelineTrackOp,
|
|
8
12
|
updateTimelineEventOp,
|
|
9
13
|
} from '../timelineOps';
|
|
10
14
|
|
|
@@ -72,6 +76,57 @@ describe('timeline editor pure operations', () => {
|
|
|
72
76
|
const timeline = makeTimeline();
|
|
73
77
|
expect(nextTimelineEventId(timeline, 'Start')).toBe('start-2');
|
|
74
78
|
expect(nextTimelineEventId(timeline, 'A new point')).toBe('a-new-point');
|
|
79
|
+
expect(nextTimelineTrackId(timeline, 'Kernel')).toBe('kernel-2');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('adds a representable line with a starter point without mutating its input', () => {
|
|
83
|
+
const timeline = makeTimeline();
|
|
84
|
+
const before = JSON.stringify(timeline);
|
|
85
|
+
const result = addTimelineTrackOp(timeline, { label: 'Release', eventLabel: 'Ready' });
|
|
86
|
+
|
|
87
|
+
expect(JSON.stringify(timeline)).toBe(before);
|
|
88
|
+
expect(result).toMatchObject({ trackId: 'release', eventId: 'ready' });
|
|
89
|
+
expect(result.timeline.tracks[2]).toMatchObject({
|
|
90
|
+
id: 'release',
|
|
91
|
+
label: 'Release',
|
|
92
|
+
row: 3,
|
|
93
|
+
startColumn: 10,
|
|
94
|
+
endColumn: 100,
|
|
95
|
+
events: [
|
|
96
|
+
{
|
|
97
|
+
id: 'ready',
|
|
98
|
+
label: 'Ready',
|
|
99
|
+
column: 55,
|
|
100
|
+
side: 'above',
|
|
101
|
+
marker: 'filled',
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
expectFixpoint(result.timeline);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('renames and removes a line while preserving or pruning branches as appropriate', () => {
|
|
109
|
+
const timeline = makeTimeline();
|
|
110
|
+
const renamed = updateTimelineTrackOp(timeline, 'client', ' Browser\nwork ');
|
|
111
|
+
expect(renamed.tracks[1]).toMatchObject({ id: 'client', label: 'Browser work' });
|
|
112
|
+
expect(renamed.tracks[1].events).toEqual(timeline.tracks[1].events);
|
|
113
|
+
expect(renamed.links).toEqual(timeline.links);
|
|
114
|
+
|
|
115
|
+
const removed = removeTimelineTrackOp(renamed, 'client');
|
|
116
|
+
expect(removed.tracks.map((track) => track.id)).toEqual(['kernel']);
|
|
117
|
+
expect(removed.links).toEqual([]);
|
|
118
|
+
expectFixpoint(renamed);
|
|
119
|
+
expectFixpoint(removed);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('rejects invalid line edits and retains the final line', () => {
|
|
123
|
+
const timeline = makeTimeline();
|
|
124
|
+
expect(updateTimelineTrackOp(timeline, 'missing', 'Nope')).toBe(timeline);
|
|
125
|
+
expect(updateTimelineTrackOp(timeline, 'kernel', ' ')).toBe(timeline);
|
|
126
|
+
expect(removeTimelineTrackOp(timeline, 'missing')).toBe(timeline);
|
|
127
|
+
|
|
128
|
+
const oneLine = { ...timeline, tracks: [timeline.tracks[0]], links: [] };
|
|
129
|
+
expect(removeTimelineTrackOp(oneLine, 'kernel')).toBe(oneLine);
|
|
75
130
|
});
|
|
76
131
|
|
|
77
132
|
it('adds at the globally normalized position without mutating its input', () => {
|