@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
@@ -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
 
@@ -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 className="squisq-tree-row">
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
+ });