@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.
- package/dist/index.d.ts +75 -33
- package/dist/index.js +1717 -935
- package/dist/index.js.map +1 -1
- package/dist/styles/index.css +145 -5
- package/package.json +4 -4
- package/src/DocumentSettingsDialog.tsx +32 -18
- package/src/EditorShell.tsx +1 -1
- package/src/PreviewControls.tsx +57 -24
- package/src/RecorderEntry.tsx +2 -0
- package/src/Toolbar.tsx +164 -19
- package/src/__tests__/codeContextSectionView.test.tsx +8 -6
- package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
- package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
- package/src/__tests__/previewControls.test.tsx +164 -0
- package/src/__tests__/recorderTheme.test.tsx +42 -0
- package/src/__tests__/selectionConversions.test.ts +80 -0
- package/src/__tests__/tiptapBridge.test.ts +48 -7
- package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
- package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
- package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
- 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/codeContext/types.ts +1 -1
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
- package/src/diagram/DiagramCanvas.tsx +16 -2
- package/src/frontmatterSettings.ts +23 -0
- package/src/index.ts +7 -1
- package/src/recorder/RecorderButton.tsx +9 -1
- package/src/recorder/RecorderModal.tsx +84 -41
- package/src/recorder/RecorderPanel.tsx +9 -1
- package/src/scene/__tests__/sceneIsolation.test.tsx +27 -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/tools/SelectTool.ts +5 -5
- package/src/selectionConversions.ts +155 -0
- package/src/styles/ascii-timeline.css +101 -4
- package/src/styles/editor.css +30 -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
|
@@ -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', () => {
|
|
@@ -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
|
|
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, '') ||
|
|
355
|
+
.replace(/^-+|-+$/g, '') || fallback
|
|
252
356
|
);
|
|
253
357
|
}
|
|
254
358
|
|
package/src/tiptapBridge.ts
CHANGED
|
@@ -282,7 +282,14 @@ export function markdownToTiptap(markdown: string): string {
|
|
|
282
282
|
// Blockquote
|
|
283
283
|
if (line.startsWith('> ')) {
|
|
284
284
|
flushList();
|
|
285
|
-
|
|
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
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
|