@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
|
@@ -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
|
+
});
|
|
@@ -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);
|
package/src/treeview/treeOps.ts
CHANGED
|
@@ -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':
|