@bendyline/squisq-editor-react 2.0.0 → 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.
Files changed (55) hide show
  1. package/dist/index.d.ts +75 -33
  2. package/dist/index.js +1717 -935
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles/index.css +145 -5
  5. package/package.json +4 -4
  6. package/src/DocumentSettingsDialog.tsx +32 -18
  7. package/src/EditorShell.tsx +1 -1
  8. package/src/PreviewControls.tsx +57 -24
  9. package/src/RecorderEntry.tsx +2 -0
  10. package/src/Toolbar.tsx +164 -19
  11. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  12. package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
  13. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  14. package/src/__tests__/previewControls.test.tsx +164 -0
  15. package/src/__tests__/recorderTheme.test.tsx +42 -0
  16. package/src/__tests__/selectionConversions.test.ts +80 -0
  17. package/src/__tests__/tiptapBridge.test.ts +48 -7
  18. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  19. package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
  20. package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
  21. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
  22. package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
  23. package/src/asciiDiagram/asciiDiagramData.ts +33 -0
  24. package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
  25. package/src/codeContext/types.ts +1 -1
  26. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  27. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  28. package/src/diagram/DiagramCanvas.tsx +16 -2
  29. package/src/frontmatterSettings.ts +23 -0
  30. package/src/index.ts +7 -1
  31. package/src/recorder/RecorderButton.tsx +9 -1
  32. package/src/recorder/RecorderModal.tsx +84 -41
  33. package/src/recorder/RecorderPanel.tsx +9 -1
  34. package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
  35. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  36. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  37. package/src/scene/commands/SceneCommand.ts +13 -2
  38. package/src/scene/tools/SelectTool.ts +5 -5
  39. package/src/selectionConversions.ts +155 -0
  40. package/src/styles/ascii-timeline.css +101 -4
  41. package/src/styles/editor.css +30 -0
  42. package/src/styles/tree-view.css +51 -1
  43. package/src/timeline/TimelineEditorWidget.tsx +200 -41
  44. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
  45. package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
  46. package/src/timeline/__tests__/timelineOps.test.ts +55 -0
  47. package/src/timeline/timelineCommands.ts +54 -0
  48. package/src/timeline/timelineOps.ts +107 -3
  49. package/src/tiptapBridge.ts +23 -5
  50. package/src/treeview/TreeOutlineWidget.tsx +153 -3
  51. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  52. package/src/treeview/__tests__/treeOps.test.ts +52 -0
  53. package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
  54. package/src/treeview/treeOps.ts +59 -0
  55. package/src/treeview/treeViewCommands.ts +5 -0
@@ -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', () => {
@@ -19,13 +19,26 @@ import {
19
19
  import { replaceAsciiFenceText } from '../asciiDiagram/asciiDiagramCommands';
20
20
  import { findTimelineBlockPos, parseTimelineForNode } from './TimelineViewExtension';
21
21
  import {
22
+ addTimelineTrackOp,
22
23
  addTimelineEventOp,
24
+ removeTimelineTrackOp,
23
25
  removeTimelineEventOp,
26
+ sanitizeTimelineText,
27
+ updateTimelineTrackOp,
24
28
  updateTimelineEventOp,
25
29
  type TimelineEventPatch,
26
30
  } from './timelineOps';
27
31
 
28
32
  export type TimelineCommand =
33
+ | {
34
+ kind: 'addTrack';
35
+ id?: string;
36
+ label?: string;
37
+ eventId?: string;
38
+ eventLabel?: string;
39
+ }
40
+ | { kind: 'updateTrack'; trackId: string; label: string }
41
+ | { kind: 'removeTrack'; trackId: string }
29
42
  | {
30
43
  kind: 'addEvent';
31
44
  trackId: string;
@@ -46,6 +59,8 @@ export interface TimelineCommandResult {
46
59
  applied: boolean;
47
60
  /** Present after add so the widget can select/focus the new marker. */
48
61
  eventId?: string;
62
+ /** Present after adding a track. */
63
+ trackId?: string;
49
64
  /** Why an otherwise valid semantic edit was deliberately blocked. */
50
65
  reason?: 'read-only' | 'unsafe-source';
51
66
  }
@@ -53,6 +68,7 @@ export interface TimelineCommandResult {
53
68
  interface OpResult {
54
69
  timeline: AsciiTimeline;
55
70
  eventId?: string;
71
+ trackId?: string;
56
72
  }
57
73
 
58
74
  const NOT_APPLIED: TimelineCommandResult = { applied: false };
@@ -67,6 +83,10 @@ function hasEvent(timeline: AsciiTimeline, eventId: string): boolean {
67
83
  return timeline.tracks.some((track) => track.events.some((event) => event.id === eventId));
68
84
  }
69
85
 
86
+ function hasTrack(timeline: AsciiTimeline, trackId: string): boolean {
87
+ return timeline.tracks.some((track) => track.id === trackId);
88
+ }
89
+
70
90
  /**
71
91
  * Layout rows and source dimensions are deliberately absent: canonical
72
92
  * rendering is allowed to compact hand-authored art. Everything the editor
@@ -276,6 +296,8 @@ function verifyRenderedTimeline(next: AsciiTimeline, rendered: string): AsciiTim
276
296
  const ids = new Set(
277
297
  verification.tracks.flatMap((track) => track.events.map((event) => event.id)),
278
298
  );
299
+ const trackIds = new Set(verification.tracks.map((track) => track.id));
300
+ if (trackIds.size !== verification.tracks.length) return null;
279
301
  if (ids.size !== countEvents(verification)) return null;
280
302
  if (verification.links.some((link) => !ids.has(link.source) || !ids.has(link.target))) {
281
303
  return null;
@@ -306,6 +328,7 @@ function applyOp(
306
328
  if (
307
329
  !verification ||
308
330
  (result.eventId && !hasEvent(verification, result.eventId)) ||
331
+ (result.trackId && !hasTrack(verification, result.trackId)) ||
309
332
  (verifyResult && !verifyResult(verification))
310
333
  ) {
311
334
  return NOT_APPLIED;
@@ -317,6 +340,7 @@ function applyOp(
317
340
  return {
318
341
  applied: true,
319
342
  ...(result.eventId ? { eventId: result.eventId } : {}),
343
+ ...(result.trackId ? { trackId: result.trackId } : {}),
320
344
  };
321
345
  }
322
346
 
@@ -331,6 +355,36 @@ export function applyTimelineCommand(
331
355
  if (!editor.isEditable) return READ_ONLY;
332
356
 
333
357
  switch (command.kind) {
358
+ case 'addTrack':
359
+ return applyOp(editor, blockId, (timeline) =>
360
+ addTimelineTrackOp(timeline, {
361
+ id: command.id,
362
+ label: command.label,
363
+ eventId: command.eventId,
364
+ eventLabel: command.eventLabel,
365
+ }),
366
+ );
367
+ case 'updateTrack': {
368
+ const expectedLabel = sanitizeTimelineText(command.label);
369
+ return applyOp(
370
+ editor,
371
+ blockId,
372
+ (timeline) => ({
373
+ timeline: updateTimelineTrackOp(timeline, command.trackId, command.label),
374
+ }),
375
+ (timeline) =>
376
+ timeline.tracks.some(
377
+ (track) => track.id === command.trackId && track.label === expectedLabel,
378
+ ),
379
+ );
380
+ }
381
+ case 'removeTrack':
382
+ return applyOp(
383
+ editor,
384
+ blockId,
385
+ (timeline) => ({ timeline: removeTimelineTrackOp(timeline, command.trackId) }),
386
+ (timeline) => !hasTrack(timeline, command.trackId),
387
+ );
334
388
  case 'addEvent':
335
389
  return applyOp(editor, blockId, (timeline) => {
336
390
  const result = addTimelineEventOp(timeline, command.trackId, command.position, {
@@ -12,6 +12,7 @@ import type {
12
12
  AsciiTimelineEvent,
13
13
  AsciiTimelineMarker,
14
14
  AsciiTimelineSide,
15
+ AsciiTimelineTrack,
15
16
  } from '@bendyline/squisq/doc';
16
17
 
17
18
  const STRUCTURAL_MARKERS = /[●○◉◆◇•]+/gu;
@@ -47,6 +48,19 @@ export interface AddTimelineEventResult {
47
48
  eventId: string;
48
49
  }
49
50
 
51
+ export interface AddTimelineTrackOptions {
52
+ id?: string;
53
+ label?: string;
54
+ eventId?: string;
55
+ eventLabel?: string;
56
+ }
57
+
58
+ export interface AddTimelineTrackResult {
59
+ timeline: AsciiTimeline;
60
+ trackId: string;
61
+ eventId: string;
62
+ }
63
+
50
64
  /**
51
65
  * Normalize editable one-line prose exactly as the canonical core renderer
52
66
  * does. This prevents the verified command path from accepting a value that
@@ -70,13 +84,103 @@ export function sanitizeTimelineText(value: string): string {
70
84
  /** Return a globally unique, renderer-safe event id. */
71
85
  export function nextTimelineEventId(timeline: AsciiTimeline, base = 'event'): string {
72
86
  const used = new Set(timeline.tracks.flatMap((track) => track.events.map((event) => event.id)));
73
- const safeBase = safeId(base) || 'event';
87
+ const safeBase = safeId(base, 'event');
74
88
  if (!used.has(safeBase)) return safeBase;
75
89
  let suffix = 2;
76
90
  while (used.has(`${safeBase}-${suffix}`)) suffix++;
77
91
  return `${safeBase}-${suffix}`;
78
92
  }
79
93
 
94
+ /** Return a renderer-safe id that is unique among timeline tracks. */
95
+ export function nextTimelineTrackId(timeline: AsciiTimeline, base = 'track'): string {
96
+ const used = new Set(timeline.tracks.map((track) => track.id));
97
+ const safeBase = safeId(base, 'track');
98
+ if (!used.has(safeBase)) return safeBase;
99
+ let suffix = 2;
100
+ while (used.has(`${safeBase}-${suffix}`)) suffix++;
101
+ return `${safeBase}-${suffix}`;
102
+ }
103
+
104
+ /**
105
+ * Add a representable track with one starter point. Empty tracks are not part
106
+ * of the authored ASCII grammar, so line creation and its first point are one
107
+ * atomic operation/undo step.
108
+ */
109
+ export function addTimelineTrackOp(
110
+ timeline: AsciiTimeline,
111
+ options: AddTimelineTrackOptions = {},
112
+ ): AddTimelineTrackResult {
113
+ const label = sanitizeTimelineText(options.label ?? '') || 'New line';
114
+ const eventLabel = sanitizeTimelineText(options.eventLabel ?? '') || 'New event';
115
+ const trackId = nextTimelineTrackId(timeline, options.id ?? label);
116
+ const eventId = nextTimelineEventId(timeline, options.eventId ?? eventLabel);
117
+ const { start, span } = globalBounds(timeline);
118
+ const column = Math.round((start + span / 2) * COORDINATE_PRECISION) / COORDINATE_PRECISION;
119
+ const row = timeline.tracks.reduce((maximum, track) => Math.max(maximum, track.row), -1) + 1;
120
+ const track: AsciiTimelineTrack = {
121
+ id: trackId,
122
+ label,
123
+ row,
124
+ startColumn: start,
125
+ endColumn: start + span,
126
+ events: [
127
+ {
128
+ id: eventId,
129
+ label: eventLabel,
130
+ column,
131
+ side: 'above',
132
+ marker: 'filled',
133
+ },
134
+ ],
135
+ };
136
+
137
+ return {
138
+ timeline: {
139
+ ...timeline,
140
+ tracks: [...timeline.tracks, track],
141
+ width: Math.max(timeline.width, Math.ceil(track.endColumn) + 1),
142
+ height: Math.max(timeline.height, row + 1),
143
+ },
144
+ trackId,
145
+ eventId,
146
+ };
147
+ }
148
+
149
+ /** Rename a track without changing its stable id, points, or branches. */
150
+ export function updateTimelineTrackOp(
151
+ timeline: AsciiTimeline,
152
+ trackId: string,
153
+ label: string,
154
+ ): AsciiTimeline {
155
+ const track = timeline.tracks.find((candidate) => candidate.id === trackId);
156
+ const nextLabel = sanitizeTimelineText(label);
157
+ if (!track || !nextLabel || track.label === nextLabel) return timeline;
158
+ return {
159
+ ...timeline,
160
+ tracks: timeline.tracks.map((candidate) =>
161
+ candidate.id === trackId ? { ...candidate, label: nextLabel } : candidate,
162
+ ),
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Remove a whole track, including branches incident to any of its points.
168
+ * Keep the last track so the source fence remains representable and mounted.
169
+ */
170
+ export function removeTimelineTrackOp(timeline: AsciiTimeline, trackId: string): AsciiTimeline {
171
+ if (timeline.tracks.length <= 1) return timeline;
172
+ const track = timeline.tracks.find((candidate) => candidate.id === trackId);
173
+ if (!track) return timeline;
174
+ const removedEventIds = new Set(track.events.map((event) => event.id));
175
+ return {
176
+ ...timeline,
177
+ tracks: timeline.tracks.filter((candidate) => candidate.id !== trackId),
178
+ links: timeline.links.filter(
179
+ (link) => !removedEventIds.has(link.source) && !removedEventIds.has(link.target),
180
+ ),
181
+ };
182
+ }
183
+
80
184
  /**
81
185
  * Add a visible point to `trackId` at a normalized global rail position.
82
186
  * Returns the stable id so the canvas can select/focus the new point after
@@ -243,12 +347,12 @@ function columnAtPosition(timeline: AsciiTimeline, position: number): number {
243
347
  return Math.round((start + clamped * span) * COORDINATE_PRECISION) / COORDINATE_PRECISION;
244
348
  }
245
349
 
246
- function safeId(value: string): string {
350
+ function safeId(value: string, fallback: string): string {
247
351
  return (
248
352
  sanitizeTimelineText(value)
249
353
  .toLowerCase()
250
354
  .replace(/[^a-z0-9_.~-]+/g, '-')
251
- .replace(/^-+|-+$/g, '') || 'event'
355
+ .replace(/^-+|-+$/g, '') || fallback
252
356
  );
253
357
  }
254
358
 
@@ -282,7 +282,14 @@ export function markdownToTiptap(markdown: string): string {
282
282
  // Blockquote
283
283
  if (line.startsWith('> ')) {
284
284
  flushList();
285
- pushBlock(`<blockquote><p>${inlineToHtml(line.slice(2))}</p></blockquote>`);
285
+ const quoteLines = [line.slice(2)];
286
+ while (i + 1 < lines.length && lines[i + 1].startsWith('> ')) {
287
+ i++;
288
+ quoteLines.push(lines[i].slice(2));
289
+ }
290
+ pushBlock(
291
+ `<blockquote>${quoteLines.map((quoteLine) => `<p>${inlineToHtml(quoteLine)}</p>`).join('')}</blockquote>`,
292
+ );
286
293
  continue;
287
294
  }
288
295
 
@@ -470,10 +477,21 @@ export function tiptapToMarkdown(html: string): string {
470
477
  // Blockquote
471
478
  const bqMatch = remaining.match(/^<blockquote>(.*?)<\/blockquote>/s);
472
479
  if (bqMatch) {
473
- const inner = htmlToInline(bqMatch[1].replace(/<\/?p>/g, ''));
474
- lines.push('> ' + inner);
475
- lines.push('');
476
- remaining = remaining.slice(bqMatch[0].length);
480
+ const paragraphs = bqMatch[1]
481
+ .split(/<\/p>\s*<p[^>]*>/i)
482
+ .map((paragraph) => paragraph.replace(/^<p[^>]*>/i, '').replace(/<\/p>\s*$/i, ''));
483
+ for (const paragraph of paragraphs) {
484
+ for (const quoteLine of htmlToInline(paragraph).split('\n')) {
485
+ lines.push('> ' + quoteLine);
486
+ }
487
+ }
488
+ const next = remaining.slice(bqMatch[0].length);
489
+ // Older bridge output and direct Tiptap edits can still leave adjacent
490
+ // blockquote nodes. Keep those nodes adjacent when serializing;
491
+ // inserting the normal block separator here turns one quote into two
492
+ // paragraphs every time the document passes through WYSIWYG mode.
493
+ if (!/^\s*<blockquote>/.test(next)) lines.push('');
494
+ remaining = next;
477
495
  continue;
478
496
  }
479
497