@bendyline/squisq-editor-react 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/index.d.ts +75 -33
  2. package/dist/index.js +1717 -935
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles/index.css +145 -5
  5. package/package.json +4 -4
  6. package/src/DocumentSettingsDialog.tsx +32 -18
  7. package/src/EditorShell.tsx +1 -1
  8. package/src/PreviewControls.tsx +57 -24
  9. package/src/RecorderEntry.tsx +2 -0
  10. package/src/Toolbar.tsx +164 -19
  11. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  12. package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
  13. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  14. package/src/__tests__/previewControls.test.tsx +164 -0
  15. package/src/__tests__/recorderTheme.test.tsx +42 -0
  16. package/src/__tests__/selectionConversions.test.ts +80 -0
  17. package/src/__tests__/tiptapBridge.test.ts +48 -7
  18. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  19. package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
  20. package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
  21. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
  22. package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
  23. package/src/asciiDiagram/asciiDiagramData.ts +33 -0
  24. package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
  25. package/src/codeContext/types.ts +1 -1
  26. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  27. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  28. package/src/diagram/DiagramCanvas.tsx +16 -2
  29. package/src/frontmatterSettings.ts +23 -0
  30. package/src/index.ts +7 -1
  31. package/src/recorder/RecorderButton.tsx +9 -1
  32. package/src/recorder/RecorderModal.tsx +84 -41
  33. package/src/recorder/RecorderPanel.tsx +9 -1
  34. package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
  35. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  36. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  37. package/src/scene/commands/SceneCommand.ts +13 -2
  38. package/src/scene/tools/SelectTool.ts +5 -5
  39. package/src/selectionConversions.ts +155 -0
  40. package/src/styles/ascii-timeline.css +101 -4
  41. package/src/styles/editor.css +30 -0
  42. package/src/styles/tree-view.css +51 -1
  43. package/src/timeline/TimelineEditorWidget.tsx +200 -41
  44. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
  45. package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
  46. package/src/timeline/__tests__/timelineOps.test.ts +55 -0
  47. package/src/timeline/timelineCommands.ts +54 -0
  48. package/src/timeline/timelineOps.ts +107 -3
  49. package/src/tiptapBridge.ts +23 -5
  50. package/src/treeview/TreeOutlineWidget.tsx +153 -3
  51. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  52. package/src/treeview/__tests__/treeOps.test.ts +52 -0
  53. package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
  54. package/src/treeview/treeOps.ts +59 -0
  55. package/src/treeview/treeViewCommands.ts +5 -0
@@ -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
+ });
@@ -3,6 +3,7 @@ import { parseTree, renderTree, type Tree, type TreeNode } from '@bendyline/squi
3
3
  import {
4
4
  addItemOp,
5
5
  indentItemOp,
6
+ moveItemOp,
6
7
  moveItemDownOp,
7
8
  moveItemUpOp,
8
9
  outdentItemOp,
@@ -90,6 +91,56 @@ describe('treeOps', () => {
90
91
  expect(outdentItemOp(t, 'root')).toBe(t);
91
92
  });
92
93
 
94
+ it('moveItemOp reorders siblings before or after a target', () => {
95
+ expect(flat(moveItemOp(tree(BASE), 'c', 'a', 'before'))).toEqual([
96
+ '0:root/',
97
+ '1:c',
98
+ '1:a',
99
+ '1:b/',
100
+ '2:b1',
101
+ ]);
102
+ expect(flat(moveItemOp(tree(BASE), 'b', 'c', 'after'))).toEqual([
103
+ '0:root/',
104
+ '1:a',
105
+ '1:c',
106
+ '1:b/',
107
+ '2:b1',
108
+ ]);
109
+ });
110
+
111
+ it('moveItemOp reparents nodes and carries their subtrees', () => {
112
+ expect(flat(moveItemOp(tree(BASE), 'c', 'a', 'child'))).toEqual([
113
+ '0:root/',
114
+ '1:a',
115
+ '2:c',
116
+ '1:b/',
117
+ '2:b1',
118
+ ]);
119
+ expect(flat(moveItemOp(tree(BASE), 'b', 'a', 'child'))).toEqual([
120
+ '0:root/',
121
+ '1:a',
122
+ '2:b/',
123
+ '3:b1',
124
+ '1:c',
125
+ ]);
126
+ });
127
+
128
+ it('moveItemOp can outdent relative to a shallower target', () => {
129
+ expect(flat(moveItemOp(tree(BASE), 'b1', 'b', 'before'))).toEqual([
130
+ '0:root/',
131
+ '1:a',
132
+ '1:b1',
133
+ '1:b/',
134
+ '1:c',
135
+ ]);
136
+ });
137
+
138
+ it('moveItemOp rejects self-drops and drops into the source subtree', () => {
139
+ const t = tree(BASE);
140
+ expect(moveItemOp(t, 'b', 'b', 'child')).toBe(t);
141
+ expect(moveItemOp(t, 'b', 'b1', 'child')).toBe(t);
142
+ });
143
+
93
144
  it('moveItemUp / moveItemDown reorder siblings within bounds', () => {
94
145
  expect(flat(moveItemDownOp(tree(BASE), 'a'))).toEqual([
95
146
  '0:root/',
@@ -115,6 +166,7 @@ describe('treeOps', () => {
115
166
  (t: Tree) => addItemOp(t, 'b', 'child', 'b2'),
116
167
  (t: Tree) => indentItemOp(t, 'c'),
117
168
  (t: Tree) => outdentItemOp(t, 'b1'),
169
+ (t: Tree) => moveItemOp(t, 'c', 'a', 'child'),
118
170
  (t: Tree) => moveItemDownOp(t, 'a'),
119
171
  ]) {
120
172
  const next = op(tree(BASE));
@@ -111,6 +111,22 @@ describe('applyTreeCommand', () => {
111
111
  expect(utils?.children.map((c) => c.label)).toContain('config.ts');
112
112
  });
113
113
 
114
+ it('moveItem reorders and reparents through one fence rewrite', () => {
115
+ const editor = makeEditor('```\n' + ART + '\n```\n');
116
+ const id = firstId(editor);
117
+ expect(
118
+ applyTreeCommand(editor, id, {
119
+ kind: 'moveItem',
120
+ id: 'config-ts',
121
+ targetId: 'utils',
122
+ position: 'child',
123
+ }),
124
+ ).toBe(true);
125
+ const tree = parseTree(fenceOf(editor).text);
126
+ const utils = tree.roots[0].children.find((n) => n.label === 'utils/');
127
+ expect(utils?.children.map((child) => child.label)).toEqual(['math.ts', 'config.ts']);
128
+ });
129
+
114
130
  it('removeItem drops a node', () => {
115
131
  const editor = makeEditor('```\n' + ART + '\n```\n');
116
132
  const id = firstId(editor);
@@ -41,6 +41,8 @@ interface Loc {
41
41
  parent: TreeNode | null;
42
42
  }
43
43
 
44
+ export type TreeDropPosition = 'before' | 'child' | 'after';
45
+
44
46
  /** Locate a node by id in a (cloned) tree, returning its sibling array + index + parent. */
45
47
  function locate(roots: TreeNode[], id: string, parent: TreeNode | null = null): Loc | null {
46
48
  for (let i = 0; i < roots.length; i++) {
@@ -55,6 +57,10 @@ function markDir(n: TreeNode): void {
55
57
  n.isDir = n.label.endsWith('/') || n.children.length > 0;
56
58
  }
57
59
 
60
+ function containsNode(node: TreeNode, id: string): boolean {
61
+ return node.id === id || node.children.some((child) => containsNode(child, id));
62
+ }
63
+
58
64
  export function renameItemOp(tree: Tree, id: string, label: string): Tree {
59
65
  const clean = sanitizeTreeLabel(label);
60
66
  const next = cloneTree(tree);
@@ -127,6 +133,59 @@ export function outdentItemOp(tree: Tree, id: string): Tree {
127
133
  return next;
128
134
  }
129
135
 
136
+ /**
137
+ * Move a node and its complete subtree relative to any other node. Dropping
138
+ * before/after adopts the target's parent; dropping as a child appends to the
139
+ * target. A node can never be moved into its own subtree.
140
+ */
141
+ export function moveItemOp(
142
+ tree: Tree,
143
+ id: string,
144
+ targetId: string,
145
+ position: TreeDropPosition,
146
+ ): Tree {
147
+ const currentSource = locate(tree.roots, id);
148
+ const currentTarget = locate(tree.roots, targetId);
149
+ if (
150
+ !currentSource ||
151
+ !currentTarget ||
152
+ id === targetId ||
153
+ containsNode(currentSource.node, targetId)
154
+ ) {
155
+ return tree;
156
+ }
157
+
158
+ // Avoid a fence rewrite when the drop describes the node's current slot.
159
+ if (currentSource.siblings === currentTarget.siblings) {
160
+ if (position === 'before' && currentSource.index === currentTarget.index - 1) return tree;
161
+ if (position === 'after' && currentSource.index === currentTarget.index + 1) return tree;
162
+ }
163
+ if (
164
+ position === 'child' &&
165
+ currentSource.parent?.id === currentTarget.node.id &&
166
+ currentSource.index === currentSource.siblings.length - 1
167
+ ) {
168
+ return tree;
169
+ }
170
+
171
+ const next = cloneTree(tree);
172
+ const source = locate(next.roots, id);
173
+ if (!source) return tree;
174
+ const [moved] = source.siblings.splice(source.index, 1);
175
+ if (source.parent) markDir(source.parent);
176
+
177
+ // Locate again after removal so same-sibling indices are current.
178
+ const target = locate(next.roots, targetId);
179
+ if (!target) return tree;
180
+ if (position === 'child') {
181
+ target.node.children.push(moved);
182
+ markDir(target.node);
183
+ } else {
184
+ target.siblings.splice(target.index + (position === 'after' ? 1 : 0), 0, moved);
185
+ }
186
+ return next;
187
+ }
188
+
130
189
  export function moveItemUpOp(tree: Tree, id: string): Tree {
131
190
  const next = cloneTree(tree);
132
191
  const loc = locate(next.roots, id);
@@ -13,12 +13,14 @@ import { findTreeBlockPos, parseTreeForNode } from './TreeViewExtension';
13
13
  import {
14
14
  addItemOp,
15
15
  indentItemOp,
16
+ moveItemOp,
16
17
  moveItemDownOp,
17
18
  moveItemUpOp,
18
19
  outdentItemOp,
19
20
  removeItemOp,
20
21
  renameItemOp,
21
22
  toggleDirOp,
23
+ type TreeDropPosition,
22
24
  } from './treeOps';
23
25
 
24
26
  export type TreeCommand =
@@ -32,6 +34,7 @@ export type TreeCommand =
32
34
  | { kind: 'renameItem'; id: string; label: string }
33
35
  | { kind: 'indentItem'; id: string }
34
36
  | { kind: 'outdentItem'; id: string }
37
+ | { kind: 'moveItem'; id: string; targetId: string; position: TreeDropPosition }
35
38
  | { kind: 'moveItemUp'; id: string }
36
39
  | { kind: 'moveItemDown'; id: string }
37
40
  | { kind: 'removeItem'; id: string }
@@ -79,6 +82,8 @@ export function applyTreeCommand(editor: Editor, blockId: string, cmd: TreeComma
79
82
  return applyOp(editor, blockId, (t) => indentItemOp(t, cmd.id));
80
83
  case 'outdentItem':
81
84
  return applyOp(editor, blockId, (t) => outdentItemOp(t, cmd.id));
85
+ case 'moveItem':
86
+ return applyOp(editor, blockId, (t) => moveItemOp(t, cmd.id, cmd.targetId, cmd.position));
82
87
  case 'moveItemUp':
83
88
  return applyOp(editor, blockId, (t) => moveItemUpOp(t, cmd.id));
84
89
  case 'moveItemDown':