@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.
Files changed (110) hide show
  1. package/README.md +10 -2
  2. package/dist/index.d.ts +579 -47
  3. package/dist/index.js +7477 -2943
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/index.css +1008 -38
  6. package/package.json +6 -5
  7. package/src/DocumentSettingsDialog.tsx +32 -18
  8. package/src/EditorContext.tsx +27 -0
  9. package/src/EditorShell.tsx +25 -1
  10. package/src/PreviewControls.tsx +77 -29
  11. package/src/PreviewPanel.tsx +5 -1
  12. package/src/RawEditor.tsx +12 -0
  13. package/src/RecorderEntry.tsx +5 -0
  14. package/src/Toolbar.tsx +479 -36
  15. package/src/WysiwygEditor.tsx +25 -11
  16. package/src/__tests__/buildPreviewDocContent.test.ts +17 -0
  17. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  18. package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
  19. package/src/__tests__/editorShellProps.test.tsx +65 -0
  20. package/src/__tests__/findMode.test.tsx +104 -0
  21. package/src/__tests__/findModel.test.ts +55 -0
  22. package/src/__tests__/markdownCodeFence.test.ts +45 -0
  23. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  24. package/src/__tests__/mediaReferences.test.ts +15 -0
  25. package/src/__tests__/previewControls.test.tsx +195 -0
  26. package/src/__tests__/recorderTheme.test.tsx +42 -0
  27. package/src/__tests__/selectionConversions.test.ts +80 -0
  28. package/src/__tests__/tiptapBridge.test.ts +57 -7
  29. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  30. package/src/__tests__/toolbarSelectionConversion.test.tsx +190 -0
  31. package/src/__tests__/writeCanvasSettings.test.ts +15 -0
  32. package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
  33. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
  34. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
  35. package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
  36. package/src/asciiDiagram/asciiDiagramData.ts +33 -0
  37. package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
  38. package/src/buildPreviewDoc.ts +9 -7
  39. package/src/codeContext/types.ts +1 -1
  40. package/src/codeSnippet/CodeSnippetExtension.ts +205 -0
  41. package/src/codeSnippet/CodeSnippetWidget.tsx +109 -0
  42. package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +95 -0
  43. package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +50 -0
  44. package/src/codeSnippet/codeSnippetCommands.ts +21 -0
  45. package/src/codeSnippet/codeSnippetData.ts +43 -0
  46. package/src/codeSnippet/codeSnippetLanguages.ts +216 -0
  47. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  48. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  49. package/src/diagram/DiagramCanvas.tsx +17 -2
  50. package/src/find/FindHighlightExtension.ts +81 -0
  51. package/src/find/FindToolbar.tsx +314 -0
  52. package/src/find/findModel.ts +77 -0
  53. package/src/frontmatterSettings.ts +23 -0
  54. package/src/index.ts +96 -1
  55. package/src/markdownCodeFence.ts +72 -0
  56. package/src/mediaReferences.ts +5 -3
  57. package/src/mermaid/MermaidDiagramCanvas.tsx +693 -0
  58. package/src/mermaid/MermaidDiagramExtension.ts +243 -0
  59. package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +184 -0
  60. package/src/mermaid/MermaidDiagramWidget.tsx +653 -0
  61. package/src/mermaid/MermaidShapePalette.tsx +245 -0
  62. package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +361 -0
  63. package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +35 -0
  64. package/src/mermaid/__tests__/mermaidRenderer.test.ts +49 -0
  65. package/src/mermaid/__tests__/mermaidSourceOps.test.ts +213 -0
  66. package/src/mermaid/__tests__/mermaidSyntax.test.ts +71 -0
  67. package/src/mermaid/mermaidCommands.ts +34 -0
  68. package/src/mermaid/mermaidData.ts +31 -0
  69. package/src/mermaid/mermaidDiagramTypes.ts +325 -0
  70. package/src/mermaid/mermaidModel.ts +31 -0
  71. package/src/mermaid/mermaidRenderer.ts +181 -0
  72. package/src/mermaid/mermaidShapes.ts +113 -0
  73. package/src/mermaid/mermaidSourceOps.ts +454 -0
  74. package/src/recorder/RecorderButton.tsx +9 -1
  75. package/src/recorder/RecorderModal.tsx +84 -41
  76. package/src/recorder/RecorderPanel.tsx +9 -1
  77. package/src/scene/Scene.tsx +46 -15
  78. package/src/scene/SceneBlockToolbar.tsx +29 -14
  79. package/src/scene/SceneViewControls.tsx +43 -0
  80. package/src/scene/__tests__/fitScale.test.ts +19 -0
  81. package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
  82. package/src/scene/__tests__/useScenePanZoom.test.ts +28 -1
  83. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  84. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  85. package/src/scene/commands/SceneCommand.ts +13 -2
  86. package/src/scene/fitScale.ts +17 -0
  87. package/src/scene/hooks/useScenePanZoom.ts +11 -4
  88. package/src/scene/scene.css +85 -3
  89. package/src/scene/tools/SelectTool.ts +5 -5
  90. package/src/selectionConversions.ts +155 -0
  91. package/src/styles/ascii-timeline.css +101 -4
  92. package/src/styles/code-snippet.css +76 -0
  93. package/src/styles/editor.css +352 -2
  94. package/src/styles/index.css +2 -0
  95. package/src/styles/mermaid-diagram.css +487 -0
  96. package/src/styles/tree-view.css +51 -1
  97. package/src/timeline/TimelineEditorWidget.tsx +200 -41
  98. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
  99. package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
  100. package/src/timeline/__tests__/timelineOps.test.ts +55 -0
  101. package/src/timeline/timelineCommands.ts +54 -0
  102. package/src/timeline/timelineOps.ts +107 -3
  103. package/src/tiptapBridge.ts +23 -5
  104. package/src/treeview/TreeOutlineWidget.tsx +153 -3
  105. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  106. package/src/treeview/__tests__/treeOps.test.ts +52 -0
  107. package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
  108. package/src/treeview/treeOps.ts +59 -0
  109. package/src/treeview/treeViewCommands.ts +5 -0
  110. 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 className="squisq-ascii-timeline-editor" aria-label="Timeline editor">
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
- {editable ? (
179
- <small>Click the line to add a point · Drag dots to move</small>
180
- ) : editorEditable ? (
181
- <small>Source repair needed</small>
182
- ) : (
183
- <small>Read only</small>
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" title={trackLabel}>
233
- <span>{trackLabel}</span>
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
- <button
236
- type="button"
237
- className="squisq-ascii-timeline-track-add"
238
- aria-label={`Add point to ${trackLabel} timeline`}
239
- title="Add point in the largest available gap"
240
- onClick={() => addEvent(track.id, largestGap(eventPositions))}
241
- >
242
- +
243
- </button>
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={editable ? 'Click anywhere on the line to add a point' : undefined}
351
+ title={addingPoint ? 'Click anywhere on the line to add a point' : undefined}
251
352
  onPointerMove={(event) => {
252
- if (!editable || dragged) return;
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 (!editable || event.target !== event.currentTarget) return;
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
- {editable && !dragged
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 and selects a point where the empty rail is clicked', async () => {
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
- fireEvent.click(screen.getByRole('button', { name: 'Add point to Kernel timeline' }));
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', () => {