@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
|
@@ -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
|
|
|
@@ -11,12 +11,13 @@
|
|
|
11
11
|
* deletes it.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { useCallback, useState } from 'react';
|
|
14
|
+
import { type DragEvent as ReactDragEvent, useCallback, useRef, useState } from 'react';
|
|
15
15
|
import type { Editor } from '@tiptap/react';
|
|
16
16
|
import type { TreeNode } from '@bendyline/squisq/doc';
|
|
17
17
|
import { Icon } from '../Icon';
|
|
18
18
|
import { useTreeViewData } from './treeViewData';
|
|
19
19
|
import { applyTreeCommand, type TreeCommand } from './treeViewCommands';
|
|
20
|
+
import type { TreeDropPosition } from './treeOps';
|
|
20
21
|
|
|
21
22
|
interface TreeOutlineWidgetProps {
|
|
22
23
|
editor: Editor;
|
|
@@ -25,9 +26,46 @@ interface TreeOutlineWidgetProps {
|
|
|
25
26
|
host?: HTMLElement | null;
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
interface TreeDropTarget {
|
|
30
|
+
id: string;
|
|
31
|
+
position: TreeDropPosition;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TREE_DRAG_MIME = 'application/x-squisq-tree-node';
|
|
35
|
+
|
|
36
|
+
function findNode(nodes: readonly TreeNode[], id: string): TreeNode | null {
|
|
37
|
+
for (const node of nodes) {
|
|
38
|
+
if (node.id === id) return node;
|
|
39
|
+
const child = findNode(node.children, id);
|
|
40
|
+
if (child) return child;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function nodeContains(node: TreeNode, id: string): boolean {
|
|
46
|
+
return node.id === id || node.children.some((child) => nodeContains(child, id));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function canDropNode(nodes: readonly TreeNode[], sourceId: string, targetId: string): boolean {
|
|
50
|
+
const source = findNode(nodes, sourceId);
|
|
51
|
+
return source != null && !nodeContains(source, targetId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function dropPositionForPointer(event: ReactDragEvent<HTMLElement>): TreeDropPosition {
|
|
55
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
56
|
+
if (rect.height <= 0) return 'child';
|
|
57
|
+
const ratio = (event.clientY - rect.top) / rect.height;
|
|
58
|
+
if (ratio < 0.3) return 'before';
|
|
59
|
+
if (ratio > 0.7) return 'after';
|
|
60
|
+
return 'child';
|
|
61
|
+
}
|
|
62
|
+
|
|
28
63
|
export function TreeOutlineWidget({ editor, blockId }: TreeOutlineWidgetProps) {
|
|
29
64
|
const view = useTreeViewData(editor, blockId);
|
|
30
65
|
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set());
|
|
66
|
+
const activeDragRef = useRef<string | null>(null);
|
|
67
|
+
const [draggedId, setDraggedId] = useState<string | null>(null);
|
|
68
|
+
const [dropTarget, setDropTarget] = useState<TreeDropTarget | null>(null);
|
|
31
69
|
|
|
32
70
|
const dispatch = useCallback(
|
|
33
71
|
(cmd: TreeCommand) => applyTreeCommand(editor, blockId, cmd),
|
|
@@ -42,6 +80,72 @@ export function TreeOutlineWidget({ editor, blockId }: TreeOutlineWidgetProps) {
|
|
|
42
80
|
});
|
|
43
81
|
}, []);
|
|
44
82
|
|
|
83
|
+
const clearDragState = useCallback(() => {
|
|
84
|
+
activeDragRef.current = null;
|
|
85
|
+
setDraggedId(null);
|
|
86
|
+
setDropTarget(null);
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
const handleDragStart = useCallback((event: ReactDragEvent<HTMLElement>, id: string) => {
|
|
90
|
+
event.stopPropagation();
|
|
91
|
+
activeDragRef.current = id;
|
|
92
|
+
setDraggedId(id);
|
|
93
|
+
setDropTarget(null);
|
|
94
|
+
event.dataTransfer.effectAllowed = 'move';
|
|
95
|
+
// Firefox requires a text payload before it starts a native drag.
|
|
96
|
+
event.dataTransfer.setData(TREE_DRAG_MIME, id);
|
|
97
|
+
event.dataTransfer.setData('text/plain', id);
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
const handleDragOver = useCallback(
|
|
101
|
+
(event: ReactDragEvent<HTMLElement>, targetId: string) => {
|
|
102
|
+
const sourceId = activeDragRef.current;
|
|
103
|
+
if (!sourceId) return;
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
event.stopPropagation();
|
|
106
|
+
|
|
107
|
+
if (!view || !canDropNode(view.tree.roots, sourceId, targetId)) {
|
|
108
|
+
event.dataTransfer.dropEffect = 'none';
|
|
109
|
+
setDropTarget(null);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const position = dropPositionForPointer(event);
|
|
114
|
+
event.dataTransfer.dropEffect = 'move';
|
|
115
|
+
setDropTarget((current) =>
|
|
116
|
+
current?.id === targetId && current.position === position
|
|
117
|
+
? current
|
|
118
|
+
: { id: targetId, position },
|
|
119
|
+
);
|
|
120
|
+
},
|
|
121
|
+
[view],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const handleDrop = useCallback(
|
|
125
|
+
(event: ReactDragEvent<HTMLElement>, targetId: string) => {
|
|
126
|
+
const sourceId = activeDragRef.current;
|
|
127
|
+
if (!sourceId) return;
|
|
128
|
+
event.preventDefault();
|
|
129
|
+
event.stopPropagation();
|
|
130
|
+
const position = dropPositionForPointer(event);
|
|
131
|
+
const canMove = view && canDropNode(view.tree.roots, sourceId, targetId);
|
|
132
|
+
|
|
133
|
+
clearDragState();
|
|
134
|
+
if (!canMove) return;
|
|
135
|
+
const moved = dispatch({ kind: 'moveItem', id: sourceId, targetId, position });
|
|
136
|
+
if (moved && position === 'child') {
|
|
137
|
+
// Make the result visible when a node is dropped into a collapsed row.
|
|
138
|
+
setCollapsed((current) => {
|
|
139
|
+
if (!current.has(targetId)) return current;
|
|
140
|
+
const next = new Set(current);
|
|
141
|
+
next.delete(targetId);
|
|
142
|
+
return next;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
[clearDragState, dispatch, view],
|
|
147
|
+
);
|
|
148
|
+
|
|
45
149
|
if (!view) return null;
|
|
46
150
|
const roots = view.tree.roots;
|
|
47
151
|
const firstRootId = roots[0]?.id;
|
|
@@ -89,8 +193,14 @@ export function TreeOutlineWidget({ editor, blockId }: TreeOutlineWidgetProps) {
|
|
|
89
193
|
node={node}
|
|
90
194
|
depth={0}
|
|
91
195
|
collapsed={collapsed}
|
|
196
|
+
draggedId={draggedId}
|
|
197
|
+
dropTarget={dropTarget}
|
|
92
198
|
toggleCollapse={toggleCollapse}
|
|
93
199
|
dispatch={dispatch}
|
|
200
|
+
onDragStart={handleDragStart}
|
|
201
|
+
onDragOver={handleDragOver}
|
|
202
|
+
onDrop={handleDrop}
|
|
203
|
+
onDragEnd={clearDragState}
|
|
94
204
|
/>
|
|
95
205
|
))}
|
|
96
206
|
</ul>
|
|
@@ -108,19 +218,39 @@ function TreeRowView({
|
|
|
108
218
|
node,
|
|
109
219
|
depth,
|
|
110
220
|
collapsed,
|
|
221
|
+
draggedId,
|
|
222
|
+
dropTarget,
|
|
111
223
|
toggleCollapse,
|
|
112
224
|
dispatch,
|
|
225
|
+
onDragStart,
|
|
226
|
+
onDragOver,
|
|
227
|
+
onDrop,
|
|
228
|
+
onDragEnd,
|
|
113
229
|
}: {
|
|
114
230
|
node: TreeNode;
|
|
115
231
|
depth: number;
|
|
116
232
|
collapsed: ReadonlySet<string>;
|
|
233
|
+
draggedId: string | null;
|
|
234
|
+
dropTarget: TreeDropTarget | null;
|
|
117
235
|
toggleCollapse: (id: string) => void;
|
|
118
236
|
dispatch: (cmd: TreeCommand) => boolean;
|
|
237
|
+
onDragStart: (event: ReactDragEvent<HTMLElement>, id: string) => void;
|
|
238
|
+
onDragOver: (event: ReactDragEvent<HTMLElement>, id: string) => void;
|
|
239
|
+
onDrop: (event: ReactDragEvent<HTMLElement>, id: string) => void;
|
|
240
|
+
onDragEnd: () => void;
|
|
119
241
|
}) {
|
|
120
242
|
const hasChildren = node.children.length > 0;
|
|
121
243
|
const isCollapsed = collapsed.has(node.id);
|
|
122
244
|
const isDir = node.isDir || hasChildren;
|
|
123
245
|
const [draft, setDraft] = useState(node.label);
|
|
246
|
+
const dropPosition = dropTarget?.id === node.id ? dropTarget.position : null;
|
|
247
|
+
const itemClassName = [
|
|
248
|
+
'squisq-tree-item',
|
|
249
|
+
draggedId === node.id ? 'squisq-tree-item--dragging' : '',
|
|
250
|
+
dropPosition ? `squisq-tree-item--drop-${dropPosition}` : '',
|
|
251
|
+
]
|
|
252
|
+
.filter(Boolean)
|
|
253
|
+
.join(' ');
|
|
124
254
|
|
|
125
255
|
const commit = () => {
|
|
126
256
|
if (draft !== node.label && draft.trim().length > 0) {
|
|
@@ -129,8 +259,12 @@ function TreeRowView({
|
|
|
129
259
|
};
|
|
130
260
|
|
|
131
261
|
return (
|
|
132
|
-
<li role="treeitem" style={{ paddingLeft: `${depth * 18}px` }}>
|
|
133
|
-
<div
|
|
262
|
+
<li className={itemClassName} role="treeitem" style={{ paddingLeft: `${depth * 18}px` }}>
|
|
263
|
+
<div
|
|
264
|
+
className="squisq-tree-row"
|
|
265
|
+
onDragOver={(event) => onDragOver(event, node.id)}
|
|
266
|
+
onDrop={(event) => onDrop(event, node.id)}
|
|
267
|
+
>
|
|
134
268
|
{hasChildren ? (
|
|
135
269
|
<button
|
|
136
270
|
type="button"
|
|
@@ -174,6 +308,16 @@ function TreeRowView({
|
|
|
174
308
|
}}
|
|
175
309
|
/>
|
|
176
310
|
<span className="squisq-tree-controls">
|
|
311
|
+
<span
|
|
312
|
+
className="squisq-tree-drag-handle"
|
|
313
|
+
draggable
|
|
314
|
+
aria-hidden="true"
|
|
315
|
+
title={`Drag ${node.label} to move`}
|
|
316
|
+
onDragStart={(event) => onDragStart(event, node.id)}
|
|
317
|
+
onDragEnd={onDragEnd}
|
|
318
|
+
>
|
|
319
|
+
<Icon icon="fa-solid fa-grip-vertical" />
|
|
320
|
+
</span>
|
|
177
321
|
<button
|
|
178
322
|
type="button"
|
|
179
323
|
title="Add child"
|
|
@@ -227,8 +371,14 @@ function TreeRowView({
|
|
|
227
371
|
node={child}
|
|
228
372
|
depth={depth + 1}
|
|
229
373
|
collapsed={collapsed}
|
|
374
|
+
draggedId={draggedId}
|
|
375
|
+
dropTarget={dropTarget}
|
|
230
376
|
toggleCollapse={toggleCollapse}
|
|
231
377
|
dispatch={dispatch}
|
|
378
|
+
onDragStart={onDragStart}
|
|
379
|
+
onDragOver={onDragOver}
|
|
380
|
+
onDrop={onDrop}
|
|
381
|
+
onDragEnd={onDragEnd}
|
|
232
382
|
/>
|
|
233
383
|
))}
|
|
234
384
|
</ul>
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Editor } from '@tiptap/core';
|
|
2
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
3
|
+
import { cleanup, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
4
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { parseTree } from '@bendyline/squisq/doc';
|
|
6
|
+
import { markdownToTiptap } from '../../tiptapBridge';
|
|
7
|
+
import { HeadingWithTemplate } from '../../TemplateAnnotation';
|
|
8
|
+
import { TreeOutlineWidget } from '../TreeOutlineWidget';
|
|
9
|
+
import { TREEVIEW_KEY, TreeViewExtension } from '../TreeViewExtension';
|
|
10
|
+
|
|
11
|
+
const ART = ['src/', '├── index.ts', '├── utils/', '│ └── math.ts', '└── config.ts'].join('\n');
|
|
12
|
+
|
|
13
|
+
const editors: Editor[] = [];
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
if (typeof globalThis.ResizeObserver !== 'undefined') return;
|
|
17
|
+
class ResizeObserverStub {
|
|
18
|
+
observe(): void {}
|
|
19
|
+
unobserve(): void {}
|
|
20
|
+
disconnect(): void {}
|
|
21
|
+
}
|
|
22
|
+
globalThis.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
cleanup();
|
|
27
|
+
for (const editor of editors) editor.destroy();
|
|
28
|
+
editors.length = 0;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function makeEditor(): Editor {
|
|
32
|
+
const editor = new Editor({
|
|
33
|
+
extensions: [
|
|
34
|
+
StarterKit.configure({
|
|
35
|
+
heading: false,
|
|
36
|
+
codeBlock: { HTMLAttributes: { class: 'squisq-code-block' } },
|
|
37
|
+
}),
|
|
38
|
+
HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
|
|
39
|
+
TreeViewExtension,
|
|
40
|
+
],
|
|
41
|
+
content: markdownToTiptap(`\`\`\`tree\n${ART}\n\`\`\`\n`),
|
|
42
|
+
});
|
|
43
|
+
editors.push(editor);
|
|
44
|
+
return editor;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function blockIdOf(editor: Editor): string {
|
|
48
|
+
const entries = TREEVIEW_KEY.getState(editor.state)?.entries ?? [];
|
|
49
|
+
expect(entries).toHaveLength(1);
|
|
50
|
+
return entries[0].id;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function treeOf(editor: Editor) {
|
|
54
|
+
let text = '';
|
|
55
|
+
editor.state.doc.descendants((node) => {
|
|
56
|
+
if (node.type.name !== 'codeBlock') return true;
|
|
57
|
+
text = node.textContent;
|
|
58
|
+
return false;
|
|
59
|
+
});
|
|
60
|
+
return parseTree(text);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function renderWidget(): Editor {
|
|
64
|
+
const editor = makeEditor();
|
|
65
|
+
render(<TreeOutlineWidget editor={editor} blockId={blockIdOf(editor)} fallbackPos={0} />);
|
|
66
|
+
return editor;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function makeDataTransfer(): DataTransfer {
|
|
70
|
+
const values = new Map<string, string>();
|
|
71
|
+
return {
|
|
72
|
+
effectAllowed: 'uninitialized',
|
|
73
|
+
dropEffect: 'none',
|
|
74
|
+
setData: vi.fn((type: string, value: string) => values.set(type, value)),
|
|
75
|
+
getData: vi.fn((type: string) => values.get(type) ?? ''),
|
|
76
|
+
get types() {
|
|
77
|
+
return [...values.keys()];
|
|
78
|
+
},
|
|
79
|
+
} as unknown as DataTransfer;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function rowFor(label: string): HTMLElement {
|
|
83
|
+
const input = screen.getByDisplayValue(label);
|
|
84
|
+
const row = input.closest<HTMLElement>('.squisq-tree-row');
|
|
85
|
+
expect(row).not.toBeNull();
|
|
86
|
+
return row as HTMLElement;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function setRowRect(row: HTMLElement): void {
|
|
90
|
+
vi.spyOn(row, 'getBoundingClientRect').mockReturnValue({
|
|
91
|
+
x: 0,
|
|
92
|
+
y: 0,
|
|
93
|
+
top: 0,
|
|
94
|
+
right: 300,
|
|
95
|
+
bottom: 30,
|
|
96
|
+
left: 0,
|
|
97
|
+
width: 300,
|
|
98
|
+
height: 30,
|
|
99
|
+
toJSON: () => ({}),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fireDragAt(
|
|
104
|
+
type: 'dragOver' | 'drop',
|
|
105
|
+
row: HTMLElement,
|
|
106
|
+
transfer: DataTransfer,
|
|
107
|
+
clientY: number,
|
|
108
|
+
): void {
|
|
109
|
+
const event = createEvent[type](row, { dataTransfer: transfer });
|
|
110
|
+
Object.defineProperty(event, 'clientY', { value: clientY });
|
|
111
|
+
fireEvent(row, event);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
describe('TreeOutlineWidget drag and drop', () => {
|
|
115
|
+
it('drops before a row to reorder siblings', async () => {
|
|
116
|
+
const editor = renderWidget();
|
|
117
|
+
const indexRow = rowFor('index.ts');
|
|
118
|
+
const transfer = makeDataTransfer();
|
|
119
|
+
|
|
120
|
+
fireEvent.dragStart(screen.getByTitle('Drag config.ts to move'), { dataTransfer: transfer });
|
|
121
|
+
setRowRect(indexRow);
|
|
122
|
+
expect(transfer.effectAllowed).toBe('move');
|
|
123
|
+
fireDragAt('dragOver', indexRow, transfer, 1);
|
|
124
|
+
expect(indexRow.closest('.squisq-tree-item')?.classList).toContain(
|
|
125
|
+
'squisq-tree-item--drop-before',
|
|
126
|
+
);
|
|
127
|
+
fireDragAt('drop', indexRow, transfer, 1);
|
|
128
|
+
|
|
129
|
+
await waitFor(() =>
|
|
130
|
+
expect(treeOf(editor).roots[0].children.map((node) => node.label)).toEqual([
|
|
131
|
+
'config.ts',
|
|
132
|
+
'index.ts',
|
|
133
|
+
'utils/',
|
|
134
|
+
]),
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('drops onto the middle of a row to indent as its last child', async () => {
|
|
139
|
+
const editor = renderWidget();
|
|
140
|
+
const utilsRow = rowFor('utils/');
|
|
141
|
+
const transfer = makeDataTransfer();
|
|
142
|
+
|
|
143
|
+
fireEvent.dragStart(screen.getByTitle('Drag config.ts to move'), { dataTransfer: transfer });
|
|
144
|
+
setRowRect(utilsRow);
|
|
145
|
+
fireDragAt('dragOver', utilsRow, transfer, 15);
|
|
146
|
+
expect(utilsRow.closest('.squisq-tree-item')?.classList).toContain(
|
|
147
|
+
'squisq-tree-item--drop-child',
|
|
148
|
+
);
|
|
149
|
+
fireDragAt('drop', utilsRow, transfer, 15);
|
|
150
|
+
|
|
151
|
+
await waitFor(() => {
|
|
152
|
+
const utils = treeOf(editor).roots[0].children.find((node) => node.label === 'utils/');
|
|
153
|
+
expect(utils?.children.map((node) => node.label)).toEqual(['math.ts', 'config.ts']);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|