@byline/host-tanstack-start 3.12.1 → 3.13.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.
@@ -8,7 +8,8 @@ import { z } from "zod";
8
8
  import { BreadcrumbsClient } from "../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js";
9
9
  import { useNavigate } from "../admin-shell/chrome/loose-router.js";
10
10
  import { ListView } from "../admin-shell/collections/list.js";
11
- import { getCollectionDocuments, reorderCollectionDocument } from "../server-fns/collections/index.js";
11
+ import { TreeListView } from "../admin-shell/collections/tree-list.js";
12
+ import { getCollectionDocuments, getCollectionTree, placeTreeNode, reorderCollectionDocument } from "../server-fns/collections/index.js";
12
13
  const searchSchema = z.object({
13
14
  page: z.coerce.number().min(1).optional(),
14
15
  page_size: z.coerce.number().max(100).optional(),
@@ -36,6 +37,12 @@ function createCollectionListRoute(path) {
36
37
  loader: async ({ params, deps })=>{
37
38
  const collectionDef = getCollectionDefinition(params.collection);
38
39
  if (!collectionDef) throw notFound();
40
+ if (true === collectionDef.tree) return await getCollectionTree({
41
+ data: {
42
+ collection: params.collection,
43
+ locale: deps.locale
44
+ }
45
+ });
39
46
  const adminConfig = getCollectionAdminConfig(params.collection);
40
47
  const fields = adminConfig?.columns?.map((c)=>String(c.fieldName)).filter((name)=>collectionDef.fields.some((f)=>f.name === name));
41
48
  const data = await getCollectionDocuments({
@@ -118,7 +125,25 @@ function createCollectionListRoute(path) {
118
125
  }
119
126
  ]
120
127
  }),
121
- CustomListView ? /*#__PURE__*/ jsx(CustomListView, {
128
+ true === collectionDef.tree ? /*#__PURE__*/ jsx(TreeListView, {
129
+ rows: data.rows,
130
+ columns: columns,
131
+ workflowStatuses: workflowStatuses,
132
+ useAsTitle: collectionDef.useAsTitle,
133
+ collection: collection,
134
+ collectionLabels: data.included.collection.labels,
135
+ onMove: async ({ documentId, parentDocumentId, beforeDocumentId, afterDocumentId })=>{
136
+ await placeTreeNode({
137
+ data: {
138
+ collection,
139
+ documentId,
140
+ parentDocumentId,
141
+ beforeDocumentId,
142
+ afterDocumentId
143
+ }
144
+ });
145
+ }
146
+ }) : CustomListView ? /*#__PURE__*/ jsx(CustomListView, {
122
147
  data: data,
123
148
  workflowStatuses: workflowStatuses
124
149
  }) : /*#__PURE__*/ jsx(ListView, {
@@ -17,5 +17,6 @@ export * from './reorder';
17
17
  export * from './restore-version';
18
18
  export * from './stats';
19
19
  export * from './status';
20
+ export * from './tree';
20
21
  export * from './update';
21
22
  export * from './upload';
@@ -11,5 +11,6 @@ export * from "./reorder.js";
11
11
  export * from "./restore-version.js";
12
12
  export * from "./stats.js";
13
13
  export * from "./status.js";
14
+ export * from "./tree.js";
14
15
  export * from "./update.js";
15
16
  export * from "./upload.js";
@@ -0,0 +1,84 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export declare const placeTreeNode: import("@tanstack/react-start").RequiredFetcher<undefined, (input: {
9
+ collection: string;
10
+ documentId: string;
11
+ parentDocumentId: string | null;
12
+ beforeDocumentId?: string | null;
13
+ afterDocumentId?: string | null;
14
+ }) => {
15
+ collection: string;
16
+ documentId: string;
17
+ parentDocumentId: string | null;
18
+ beforeDocumentId?: string | null;
19
+ afterDocumentId?: string | null;
20
+ }, Promise<{
21
+ status: "ok";
22
+ orderKey: string;
23
+ }>>;
24
+ export declare const removeFromTree: import("@tanstack/react-start").RequiredFetcher<undefined, (input: {
25
+ collection: string;
26
+ documentId: string;
27
+ }) => {
28
+ collection: string;
29
+ documentId: string;
30
+ }, Promise<{
31
+ status: "ok";
32
+ }>>;
33
+ export declare const getTreeAncestors: import("@tanstack/react-start").RequiredFetcher<undefined, (input: {
34
+ collection: string;
35
+ documentId: string;
36
+ }) => {
37
+ collection: string;
38
+ documentId: string;
39
+ }, Promise<{
40
+ id: string;
41
+ title: string;
42
+ path: string;
43
+ }[]>>;
44
+ export declare const getTreeParent: import("@tanstack/react-start").RequiredFetcher<undefined, (input: {
45
+ collection: string;
46
+ documentId: string;
47
+ }) => {
48
+ collection: string;
49
+ documentId: string;
50
+ }, Promise<{
51
+ placed: boolean;
52
+ parentDocumentId: string | null;
53
+ }>>;
54
+ export interface CollectionTreeRow {
55
+ id: string;
56
+ /** Parent document id, or `null` for a root or unplaced node. Drives the
57
+ * client-side tree reconstruction for drag-to-reorder / re-parent. */
58
+ parentId: string | null;
59
+ depth: number;
60
+ unplaced: boolean;
61
+ status: string;
62
+ path: string;
63
+ createdAt: Date;
64
+ updatedAt: Date;
65
+ fields: Record<string, any>;
66
+ }
67
+ export declare const getCollectionTree: import("@tanstack/react-start").RequiredFetcher<undefined, (input: {
68
+ collection: string;
69
+ locale?: string;
70
+ }) => {
71
+ collection: string;
72
+ locale?: string;
73
+ }, Promise<{
74
+ rows: CollectionTreeRow[];
75
+ included: {
76
+ collection: {
77
+ path: string;
78
+ labels: {
79
+ singular: string;
80
+ plural: string;
81
+ };
82
+ };
83
+ };
84
+ }>>;
@@ -0,0 +1,144 @@
1
+ import { createServerFn } from "@tanstack/react-start";
2
+ import { ERR_NOT_FOUND, getLogger } from "@byline/core";
3
+ import { ensureCollection } from "../../integrations/api-utils.js";
4
+ import { getAdminBylineClient } from "../../integrations/byline-client.js";
5
+ const placeTreeNode = createServerFn({
6
+ method: 'POST'
7
+ }).validator((input)=>input).handler(async ({ data })=>{
8
+ const { collection: path, documentId } = data;
9
+ const config = await ensureCollection(path);
10
+ if (!config) throw ERR_NOT_FOUND({
11
+ message: 'Collection not found',
12
+ details: {
13
+ collectionPath: path
14
+ }
15
+ }).log(getLogger());
16
+ const handle = getAdminBylineClient().collection(path);
17
+ const result = await handle.placeTreeNode(documentId, {
18
+ parentDocumentId: data.parentDocumentId,
19
+ beforeDocumentId: data.beforeDocumentId ?? null,
20
+ afterDocumentId: data.afterDocumentId ?? null
21
+ });
22
+ return {
23
+ status: 'ok',
24
+ orderKey: result.orderKey
25
+ };
26
+ });
27
+ const removeFromTree = createServerFn({
28
+ method: 'POST'
29
+ }).validator((input)=>input).handler(async ({ data })=>{
30
+ const { collection: path, documentId } = data;
31
+ const config = await ensureCollection(path);
32
+ if (!config) throw ERR_NOT_FOUND({
33
+ message: 'Collection not found',
34
+ details: {
35
+ collectionPath: path
36
+ }
37
+ }).log(getLogger());
38
+ await getAdminBylineClient().collection(path).removeFromTree(documentId);
39
+ return {
40
+ status: 'ok'
41
+ };
42
+ });
43
+ const getTreeAncestors = createServerFn({
44
+ method: 'GET'
45
+ }).validator((input)=>input).handler(async ({ data })=>{
46
+ const { collection: path, documentId } = data;
47
+ const config = await ensureCollection(path);
48
+ if (!config) throw ERR_NOT_FOUND({
49
+ message: 'Collection not found',
50
+ details: {
51
+ collectionPath: path
52
+ }
53
+ }).log(getLogger());
54
+ const useAsTitle = config.definition.useAsTitle;
55
+ const ancestors = await getAdminBylineClient().collection(path).getAncestors(documentId, {
56
+ status: 'any'
57
+ });
58
+ return ancestors.map((doc)=>{
59
+ const fields = doc.fields;
60
+ const title = useAsTitle ? fields?.[useAsTitle] : void 0;
61
+ return {
62
+ id: doc.id,
63
+ title: 'string' == typeof title && title.length > 0 ? title : doc.path,
64
+ path: doc.path
65
+ };
66
+ });
67
+ });
68
+ const getTreeParent = createServerFn({
69
+ method: 'GET'
70
+ }).validator((input)=>input).handler(async ({ data })=>{
71
+ const { collection: path, documentId } = data;
72
+ const config = await ensureCollection(path);
73
+ if (!config) throw ERR_NOT_FOUND({
74
+ message: 'Collection not found',
75
+ details: {
76
+ collectionPath: path
77
+ }
78
+ }).log(getLogger());
79
+ return getAdminBylineClient().collection(path).getTreeParent(documentId);
80
+ });
81
+ const getCollectionTree = createServerFn({
82
+ method: 'GET'
83
+ }).validator((input)=>input).handler(async ({ data })=>{
84
+ const { collection: path } = data;
85
+ const config = await ensureCollection(path);
86
+ if (!config) throw ERR_NOT_FOUND({
87
+ message: 'Collection not found',
88
+ details: {
89
+ collectionPath: path
90
+ }
91
+ }).log(getLogger());
92
+ const handle = getAdminBylineClient().collection(path);
93
+ const forest = await handle.getSubtree({
94
+ status: 'any',
95
+ locale: data.locale
96
+ });
97
+ const rows = [];
98
+ const placed = new Set();
99
+ const walk = (nodes, depth, parentId)=>{
100
+ for (const node of nodes){
101
+ const doc = node.document;
102
+ rows.push({
103
+ id: doc.id,
104
+ parentId,
105
+ depth,
106
+ unplaced: false,
107
+ status: doc.status,
108
+ path: doc.path,
109
+ createdAt: doc.createdAt,
110
+ updatedAt: doc.updatedAt,
111
+ fields: doc.fields
112
+ });
113
+ placed.add(doc.id);
114
+ walk(node.children, depth + 1, doc.id);
115
+ }
116
+ };
117
+ walk(forest, 0, null);
118
+ const all = await handle.find({
119
+ status: 'any',
120
+ pageSize: 1000,
121
+ _bypassBeforeRead: true
122
+ });
123
+ for (const doc of all.docs)if (!placed.has(doc.id)) rows.push({
124
+ id: doc.id,
125
+ parentId: null,
126
+ depth: 0,
127
+ unplaced: true,
128
+ status: doc.status,
129
+ path: doc.path,
130
+ createdAt: doc.createdAt,
131
+ updatedAt: doc.updatedAt,
132
+ fields: doc.fields
133
+ });
134
+ return {
135
+ rows,
136
+ included: {
137
+ collection: {
138
+ path: config.collection.path,
139
+ labels: config.definition.labels
140
+ }
141
+ }
142
+ };
143
+ });
144
+ export { getCollectionTree, getTreeAncestors, getTreeParent, placeTreeNode, removeFromTree };
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "private": false,
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
- "version": "3.12.1",
6
+ "version": "3.13.0",
7
7
  "engines": {
8
8
  "node": ">=20.9.0"
9
9
  },
@@ -119,13 +119,13 @@
119
119
  "react-swipeable": "^7.0.2",
120
120
  "uuid": "^14.0.0",
121
121
  "zod": "^4.4.3",
122
- "@byline/admin": "3.12.1",
123
- "@byline/ui": "3.12.1",
124
- "@byline/i18n": "3.12.1",
125
- "@byline/client": "3.12.1",
126
- "@byline/core": "3.12.1",
127
- "@byline/ai": "3.12.1",
128
- "@byline/auth": "3.12.1"
122
+ "@byline/admin": "3.13.0",
123
+ "@byline/ai": "3.13.0",
124
+ "@byline/auth": "3.13.0",
125
+ "@byline/client": "3.13.0",
126
+ "@byline/i18n": "3.13.0",
127
+ "@byline/core": "3.13.0",
128
+ "@byline/ui": "3.13.0"
129
129
  },
130
130
  "peerDependencies": {
131
131
  "@tanstack/react-router": "^1.167.0",
@@ -491,6 +491,7 @@ export const EditView = ({
491
491
  useAsTitle={collectionDefinition.useAsTitle}
492
492
  useAsPath={collectionDefinition.useAsPath}
493
493
  advertiseLocales={collectionDefinition.advertiseLocales}
494
+ tree={collectionDefinition.tree === true}
494
495
  headingLabel={labels.singular}
495
496
  initialLocale={locale}
496
497
  onLocaleChange={handleLocaleChange}
@@ -0,0 +1,116 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { describe, expect, it } from 'vitest'
10
+
11
+ import {
12
+ applyProjection,
13
+ descendantIds,
14
+ getTreeProjection,
15
+ type TreeProjectionRow,
16
+ } from './tree-list-projection.js'
17
+
18
+ const INDENT = 24
19
+
20
+ // Pre-order placed tree:
21
+ // A (root)
22
+ // B (child of A)
23
+ // C (child of A)
24
+ // D (child of C)
25
+ // E (root)
26
+ const rows: TreeProjectionRow[] = [
27
+ { id: 'A', parentId: null, depth: 0 },
28
+ { id: 'B', parentId: 'A', depth: 1 },
29
+ { id: 'C', parentId: 'A', depth: 1 },
30
+ { id: 'D', parentId: 'C', depth: 2 },
31
+ { id: 'E', parentId: null, depth: 0 },
32
+ ]
33
+
34
+ describe('descendantIds', () => {
35
+ it('returns the contiguous deeper rows after a node', () => {
36
+ expect([...descendantIds(rows, 'C')]).toEqual(['D'])
37
+ expect([...descendantIds(rows, 'A')]).toEqual(['B', 'C', 'D'])
38
+ expect([...descendantIds(rows, 'B')]).toEqual([])
39
+ })
40
+ })
41
+
42
+ describe('getTreeProjection', () => {
43
+ it('reorders within siblings (drop past a subtree, no horizontal offset)', () => {
44
+ // Drag B onto D (C's last descendant), no indent change → B lands after C's
45
+ // whole subtree, still a child of A.
46
+ expect(getTreeProjection(rows, 'B', 'D', 0, INDENT)).toEqual({
47
+ depth: 1,
48
+ parentId: 'A',
49
+ beforeId: 'C',
50
+ afterId: null,
51
+ })
52
+ })
53
+
54
+ it('re-parents a node under the row above when dragged right one level', () => {
55
+ // Drag B onto C with a +1 level offset → B becomes C's first child, ahead
56
+ // of C's existing child D.
57
+ expect(getTreeProjection(rows, 'B', 'C', INDENT, INDENT)).toEqual({
58
+ depth: 2,
59
+ parentId: 'C',
60
+ beforeId: null,
61
+ afterId: 'D',
62
+ })
63
+ })
64
+
65
+ it('outdents a deep node to a root when dragged far left', () => {
66
+ // Drag D onto E, far left → D becomes a root after E.
67
+ expect(getTreeProjection(rows, 'D', 'E', -2 * INDENT, INDENT)).toEqual({
68
+ depth: 0,
69
+ parentId: null,
70
+ beforeId: 'E',
71
+ afterId: null,
72
+ })
73
+ })
74
+
75
+ it('clamps depth to one level below the row above', () => {
76
+ // Drag E onto B with a huge right offset: dropping above B, the deepest E
77
+ // can go is a child of A (B's parent) — it cannot leap to B's depth+1.
78
+ expect(getTreeProjection(rows, 'E', 'B', 10 * INDENT, INDENT)).toEqual({
79
+ depth: 1,
80
+ parentId: 'A',
81
+ beforeId: null,
82
+ afterId: 'B',
83
+ })
84
+ })
85
+
86
+ it('is a no-op when dropping on itself', () => {
87
+ expect(getTreeProjection(rows, 'B', 'B', 0, INDENT)).toBeNull()
88
+ })
89
+
90
+ it('is a no-op when dropping into its own subtree', () => {
91
+ // C cannot become a child of its own descendant D.
92
+ expect(getTreeProjection(rows, 'C', 'D', INDENT, INDENT)).toBeNull()
93
+ })
94
+ })
95
+
96
+ describe('applyProjection', () => {
97
+ const ids = (r: TreeProjectionRow[]) => r.map((x) => `${x.id}@${x.depth}`)
98
+
99
+ it('re-parents a node and its subtree together, shifting depth', () => {
100
+ // Move C (with child D) under E.
101
+ const projection = getTreeProjection(rows, 'C', 'E', INDENT, INDENT)!
102
+ const result = applyProjection(rows, 'C', projection)
103
+ // C now under E; D travels with C, one level deeper than C.
104
+ expect(ids(result)).toEqual(['A@0', 'B@1', 'E@0', 'C@1', 'D@2'])
105
+ expect(result.find((r) => r.id === 'C')?.parentId).toBe('E')
106
+ expect(result.find((r) => r.id === 'D')?.parentId).toBe('C')
107
+ })
108
+
109
+ it('reorders a leaf within its sibling group', () => {
110
+ // Move B after C's subtree (drop on D, no indent) → A: [C, B].
111
+ const projection = getTreeProjection(rows, 'B', 'D', 0, INDENT)!
112
+ const result = applyProjection(rows, 'B', projection)
113
+ expect(ids(result)).toEqual(['A@0', 'C@1', 'D@2', 'B@1', 'E@0'])
114
+ expect(result.find((r) => r.id === 'B')?.parentId).toBe('A')
115
+ })
116
+ })
@@ -0,0 +1,198 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * Pure projection helpers for the drag-to-reorder / re-parent tree list
11
+ * (docs/DOCUMENT-TREE.md, phase 2). Adapted from the dnd-kit "sortable tree"
12
+ * pattern: a pre-order flattened list where the drag's **horizontal** offset
13
+ * projects a target depth, clamped to what the neighbouring rows allow, and the
14
+ * target parent + sibling neighbours are resolved from that depth.
15
+ *
16
+ * Kept free of React / dnd-kit so the projection is unit-testable in isolation;
17
+ * the component feeds it the live drag offset and applies the result through the
18
+ * `placeTreeNode` tree command.
19
+ */
20
+
21
+ /** Minimal row shape the projection needs (a slice of `CollectionTreeRow`). */
22
+ export interface TreeProjectionRow {
23
+ id: string
24
+ parentId: string | null
25
+ depth: number
26
+ }
27
+
28
+ export interface TreeProjection {
29
+ /** Projected depth of the dragged node at the drop position. */
30
+ depth: number
31
+ /** Resolved parent at that depth (`null` = root). */
32
+ parentId: string | null
33
+ /** Sibling immediately *above* the drop within the target parent (or null). */
34
+ beforeId: string | null
35
+ /** Sibling immediately *below* the drop within the target parent (or null). */
36
+ afterId: string | null
37
+ }
38
+
39
+ function clamp(value: number, min: number, max: number): number {
40
+ return Math.min(Math.max(value, min), max)
41
+ }
42
+
43
+ function arrayMove<T>(items: T[], from: number, to: number): T[] {
44
+ const next = items.slice()
45
+ const [moved] = next.splice(from, 1)
46
+ if (moved !== undefined) next.splice(to, 0, moved)
47
+ return next
48
+ }
49
+
50
+ /**
51
+ * The ids of `activeId`'s descendants. In a pre-order list a node's descendants
52
+ * are the contiguous following rows with a strictly greater depth — they travel
53
+ * with the node (the adjacency edge to their parent is unchanged), so they are
54
+ * removed from the working list before projecting.
55
+ */
56
+ export function descendantIds(rows: TreeProjectionRow[], activeId: string): Set<string> {
57
+ const ids = new Set<string>()
58
+ const start = rows.findIndex((r) => r.id === activeId)
59
+ if (start === -1) return ids
60
+ const baseDepth = rows[start]?.depth
61
+ for (let i = start + 1; i < rows.length && rows[i]?.depth > baseDepth; i++) {
62
+ ids.add(rows[i]?.id)
63
+ }
64
+ return ids
65
+ }
66
+
67
+ /**
68
+ * Project where the dragged node would land. `rows` is the full pre-order placed
69
+ * tree; `dragOffsetX` is the pointer's horizontal delta in px; `indentWidth` is
70
+ * the px-per-depth indentation step. Returns `null` for a no-op drag (dropping
71
+ * on itself or into its own subtree).
72
+ */
73
+ export function getTreeProjection(
74
+ rows: TreeProjectionRow[],
75
+ activeId: string,
76
+ overId: string,
77
+ dragOffsetX: number,
78
+ indentWidth: number
79
+ ): TreeProjection | null {
80
+ if (activeId === overId) return null
81
+
82
+ // Drop into own subtree is invalid — the descendants travel with the node.
83
+ const descendants = descendantIds(rows, activeId)
84
+ if (descendants.has(overId)) return null
85
+
86
+ // Collapse the dragged subtree to a single unit for projection.
87
+ const working = rows.filter((r) => r.id === activeId || !descendants.has(r.id))
88
+
89
+ const activeIndex = working.findIndex((r) => r.id === activeId)
90
+ const overIndex = working.findIndex((r) => r.id === overId)
91
+ if (activeIndex === -1 || overIndex === -1) return null
92
+
93
+ const moved = arrayMove(working, activeIndex, overIndex)
94
+ const previous = moved[overIndex - 1]
95
+ const next = moved[overIndex + 1]
96
+
97
+ const dragDepth = Math.round(dragOffsetX / indentWidth)
98
+ const projectedDepth = working[activeIndex]?.depth + dragDepth
99
+ const maxDepth = previous ? previous.depth + 1 : 0
100
+ const minDepth = next ? next.depth : 0
101
+ const depth = clamp(projectedDepth, minDepth, maxDepth)
102
+
103
+ const parentId = resolveParentId(moved, overIndex, depth, previous)
104
+
105
+ // Sibling neighbours within the target parent, scanning out from the drop.
106
+ let beforeId: string | null = null
107
+ for (let i = overIndex - 1; i >= 0; i--) {
108
+ const row = moved[i]!
109
+ if (row.depth < depth) break
110
+ if (row.depth === depth && row.parentId === parentId) {
111
+ beforeId = row.id
112
+ break
113
+ }
114
+ }
115
+ let afterId: string | null = null
116
+ for (let i = overIndex + 1; i < moved.length; i++) {
117
+ const row = moved[i]!
118
+ if (row.depth < depth) break
119
+ if (row.depth === depth && row.parentId === parentId) {
120
+ afterId = row.id
121
+ break
122
+ }
123
+ }
124
+
125
+ return { depth, parentId, beforeId, afterId }
126
+ }
127
+
128
+ /**
129
+ * Apply a projection to the flat row list — produce the new pre-order ordering
130
+ * for an **optimistic** repaint (the server returns the canonical order on the
131
+ * next read). The dragged node's whole subtree (contiguous deeper rows) travels
132
+ * with it; depths are shifted by the projected delta and the node's `parentId`
133
+ * is updated. Generic so it preserves the caller's full row shape.
134
+ */
135
+ export function applyProjection<T extends TreeProjectionRow>(
136
+ rows: T[],
137
+ activeId: string,
138
+ p: TreeProjection
139
+ ): T[] {
140
+ const start = rows.findIndex((r) => r.id === activeId)
141
+ if (start === -1) return rows
142
+ const baseDepth = rows[start]?.depth
143
+
144
+ let end = start + 1
145
+ while (end < rows.length && rows[end]?.depth > baseDepth) end++
146
+
147
+ const delta = p.depth - baseDepth
148
+ const block = rows
149
+ .slice(start, end)
150
+ .map((row, i) =>
151
+ i === 0
152
+ ? { ...row, depth: row.depth + delta, parentId: p.parentId }
153
+ : { ...row, depth: row.depth + delta }
154
+ )
155
+
156
+ const without = [...rows.slice(0, start), ...rows.slice(end)]
157
+
158
+ let insertAt: number
159
+ if (p.beforeId != null) {
160
+ const bi = without.findIndex((r) => r.id === p.beforeId)
161
+ if (bi === -1) {
162
+ insertAt = without.length
163
+ } else {
164
+ // after beforeId and its whole subtree
165
+ let j = bi + 1
166
+ const bd = without[bi]?.depth
167
+ while (j < without.length && without[j]?.depth > bd) j++
168
+ insertAt = j
169
+ }
170
+ } else if (p.afterId != null) {
171
+ const ai = without.findIndex((r) => r.id === p.afterId)
172
+ insertAt = ai === -1 ? without.length : ai
173
+ } else if (p.parentId != null) {
174
+ const pi = without.findIndex((r) => r.id === p.parentId)
175
+ insertAt = pi === -1 ? without.length : pi + 1
176
+ } else {
177
+ insertAt = without.length
178
+ }
179
+
180
+ return [...without.slice(0, insertAt), ...block, ...without.slice(insertAt)]
181
+ }
182
+
183
+ function resolveParentId(
184
+ moved: TreeProjectionRow[],
185
+ overIndex: number,
186
+ depth: number,
187
+ previous: TreeProjectionRow | undefined
188
+ ): string | null {
189
+ if (depth === 0 || previous == null) return null
190
+ if (depth === previous.depth) return previous.parentId
191
+ if (depth > previous.depth) return previous.id
192
+ // depth < previous.depth — find the nearest preceding row at the target depth
193
+ // and inherit its parent.
194
+ for (let i = overIndex - 1; i >= 0; i--) {
195
+ if (moved[i]?.depth === depth) return moved[i]?.parentId
196
+ }
197
+ return null
198
+ }