@tarhnama/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/dist/code-tokens.d.ts +39 -0
- package/dist/code-tokens.d.ts.map +1 -0
- package/dist/code-tokens.js +189 -0
- package/dist/code-tokens.js.map +1 -0
- package/dist/context-menu.d.ts +59 -0
- package/dist/context-menu.d.ts.map +1 -0
- package/dist/context-menu.js +69 -0
- package/dist/context-menu.js.map +1 -0
- package/dist/features.d.ts +114 -0
- package/dist/features.d.ts.map +1 -0
- package/dist/features.js +128 -0
- package/dist/features.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/nested.d.ts +66 -0
- package/dist/nested.d.ts.map +1 -0
- package/dist/nested.js +194 -0
- package/dist/nested.js.map +1 -0
- package/dist/ops.d.ts +120 -0
- package/dist/ops.d.ts.map +1 -0
- package/dist/ops.js +333 -0
- package/dist/ops.js.map +1 -0
- package/dist/shortcuts.d.ts +90 -0
- package/dist/shortcuts.d.ts.map +1 -0
- package/dist/shortcuts.js +179 -0
- package/dist/shortcuts.js.map +1 -0
- package/dist/spine.d.ts +92 -0
- package/dist/spine.d.ts.map +1 -0
- package/dist/spine.js +213 -0
- package/dist/spine.js.map +1 -0
- package/dist/styles.d.ts +63 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +169 -0
- package/dist/styles.js.map +1 -0
- package/dist/tree.d.ts +70 -0
- package/dist/tree.d.ts.map +1 -0
- package/dist/tree.js +190 -0
- package/dist/tree.js.map +1 -0
- package/dist/types.d.ts +239 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +47 -0
- package/dist/types.js.map +1 -0
- package/package.json +52 -0
- package/src/code-tokens.ts +236 -0
- package/src/context-menu.ts +139 -0
- package/src/features.ts +173 -0
- package/src/index.ts +58 -0
- package/src/nested.ts +271 -0
- package/src/ops.ts +341 -0
- package/src/shortcuts.ts +280 -0
- package/src/spine.ts +214 -0
- package/src/styles.ts +201 -0
- package/src/tree.ts +189 -0
- package/src/types.ts +275 -0
package/src/nested.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { buildIndex, subtreeRange } from './tree.js';
|
|
2
|
+
import { BODY, MAX_LEVEL, type Blocks, type OutlineLevel } from './types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Nested tree <-> flat blocks.
|
|
6
|
+
*
|
|
7
|
+
* Almost every system that stores a tree stores it as ADJACENCY: one row per node with a
|
|
8
|
+
* `parentId` and an ordinal. This editor's model is deliberately the opposite — a flat list
|
|
9
|
+
* where each block carries an explicit level, which is what lets it import the Word and
|
|
10
|
+
* Markdown documents that skip levels constantly (CLAUDE.md §1).
|
|
11
|
+
*
|
|
12
|
+
* Binding the two is the entire cost of embedding this editor in a host that already has a
|
|
13
|
+
* tree, and every host has to do it. Doing it here once, with tests, beats each of them
|
|
14
|
+
* writing the awkward parts again — the awkward parts being ordering, depth beyond what a
|
|
15
|
+
* level can express, cycles, orphans, and the fields a tree carries that an outline cannot.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** A host's nested node. The shape is the common denominator, not any one product's schema. */
|
|
19
|
+
export interface NestedNode<T = unknown> {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
readonly parentId: string | null;
|
|
22
|
+
readonly title: string;
|
|
23
|
+
/** Order among siblings. Absent sorts last, stably. */
|
|
24
|
+
readonly ordinal?: number;
|
|
25
|
+
/** Prose under the heading. */
|
|
26
|
+
readonly body?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Anything the host keeps that the outline cannot represent — evidence, a source, a
|
|
29
|
+
* foreign key. Carried through untouched so a round trip never costs the host a field.
|
|
30
|
+
*/
|
|
31
|
+
readonly data?: T;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface NestedToBlocksResult<C> {
|
|
35
|
+
readonly blocks: Blocks<C>;
|
|
36
|
+
/**
|
|
37
|
+
* What could not be represented faithfully.
|
|
38
|
+
*
|
|
39
|
+
* Returned rather than thrown, and never silent: a 12-deep branch that quietly flattens
|
|
40
|
+
* to 9 loses three levels of someone's tree with nothing reporting a problem, and they
|
|
41
|
+
* find out when they save.
|
|
42
|
+
*/
|
|
43
|
+
readonly warnings: readonly string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** How a title and a body become block content. Defaults to a single plain run. */
|
|
47
|
+
export interface NestedOptions<C> {
|
|
48
|
+
readonly toContent?: (text: string) => C;
|
|
49
|
+
readonly fromContent?: (content: C | undefined) => string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const defaultToContent = (text: string): unknown => [{ text }];
|
|
53
|
+
|
|
54
|
+
const defaultFromContent = (content: unknown): string => {
|
|
55
|
+
if (!Array.isArray(content)) return '';
|
|
56
|
+
return content.map((run) => (run as { text?: string }).text ?? '').join('');
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Where the true depth is stashed when a level cannot hold it. See `nestedToBlocks`. */
|
|
60
|
+
const DEPTH_ATTR = 'nestedDepth';
|
|
61
|
+
/** Where the host's own payload rides. */
|
|
62
|
+
const DATA_ATTR = 'data';
|
|
63
|
+
/** The parent, recorded so the reverse mapping never has to guess at a clamped level. */
|
|
64
|
+
const PARENT_ATTR = 'nestedParentId';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Flatten a nested tree into blocks.
|
|
68
|
+
*
|
|
69
|
+
* Depth becomes level. Where depth exceeds `MAX_LEVEL` the level CLAMPS — a block with
|
|
70
|
+
* level 12 is not representable and would be rejected downstream — but the real depth and
|
|
71
|
+
* the real parent ride along in `attrs`, so `blocksToNested` restores the exact tree. The
|
|
72
|
+
* clamp is a display limit, never a data loss, and it is reported either way.
|
|
73
|
+
*/
|
|
74
|
+
export function nestedToBlocks<C = unknown, T = unknown>(
|
|
75
|
+
nodes: readonly NestedNode<T>[],
|
|
76
|
+
options?: NestedOptions<C>,
|
|
77
|
+
): NestedToBlocksResult<C> {
|
|
78
|
+
const toContent = (options?.toContent ?? defaultToContent) as (text: string) => C;
|
|
79
|
+
const warnings: string[] = [];
|
|
80
|
+
|
|
81
|
+
const byId = new Map<string, NestedNode<T>>();
|
|
82
|
+
for (const node of nodes) byId.set(node.id, node);
|
|
83
|
+
|
|
84
|
+
// Children by parent, in sibling order. Built once: doing it per node is the quadratic
|
|
85
|
+
// version of this function and a knowledge tree can be large.
|
|
86
|
+
const children = new Map<string | null, NestedNode<T>[]>();
|
|
87
|
+
for (const node of nodes) {
|
|
88
|
+
/*
|
|
89
|
+
* A parent that is not in the set makes this node a ROOT rather than dropping it.
|
|
90
|
+
* Dropping deletes the user's content on the way in; promoting keeps it where they can
|
|
91
|
+
* see it and fix it.
|
|
92
|
+
*/
|
|
93
|
+
const missing = node.parentId !== null && !byId.has(node.parentId);
|
|
94
|
+
if (missing) {
|
|
95
|
+
warnings.push(
|
|
96
|
+
`Node ${JSON.stringify(node.id)} has parent ${JSON.stringify(node.parentId)}, which is not in the tree — treated as a root.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const key = missing ? null : node.parentId;
|
|
100
|
+
const bucket = children.get(key);
|
|
101
|
+
if (bucket) bucket.push(node);
|
|
102
|
+
else children.set(key, [node]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const bucket of children.values()) {
|
|
106
|
+
bucket.sort((a, b) => {
|
|
107
|
+
// Absent ordinals sort last and keep their relative order, so a host that never set
|
|
108
|
+
// them gets its array order back rather than an arbitrary shuffle.
|
|
109
|
+
const left = a.ordinal ?? Number.MAX_SAFE_INTEGER;
|
|
110
|
+
const right = b.ordinal ?? Number.MAX_SAFE_INTEGER;
|
|
111
|
+
return left - right;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const blocks: unknown[] = [];
|
|
116
|
+
const seen = new Set<string>();
|
|
117
|
+
|
|
118
|
+
/* Iterative, not recursive: a knowledge tree deep enough to blow the stack is a crash
|
|
119
|
+
with no message, and this runs on data the library did not create. */
|
|
120
|
+
const stack: { node: NestedNode<T>; depth: number }[] = [];
|
|
121
|
+
for (const root of [...(children.get(null) ?? [])].reverse()) {
|
|
122
|
+
stack.push({ node: root, depth: 1 });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
while (stack.length > 0) {
|
|
126
|
+
const { node, depth } = stack.pop()!;
|
|
127
|
+
if (seen.has(node.id)) continue;
|
|
128
|
+
seen.add(node.id);
|
|
129
|
+
|
|
130
|
+
const level = Math.min(depth, MAX_LEVEL) as OutlineLevel;
|
|
131
|
+
if (depth > MAX_LEVEL) {
|
|
132
|
+
warnings.push(
|
|
133
|
+
`Node ${JSON.stringify(node.id)} is ${depth} deep; levels stop at ${MAX_LEVEL}, so it renders at ${MAX_LEVEL}. Its real depth is preserved.`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const attrs: Record<string, unknown> = {};
|
|
138
|
+
if (node.data !== undefined) attrs[DATA_ATTR] = node.data;
|
|
139
|
+
// Only recorded when the level cannot express the truth on its own.
|
|
140
|
+
if (depth > MAX_LEVEL) {
|
|
141
|
+
attrs[DEPTH_ATTR] = depth;
|
|
142
|
+
attrs[PARENT_ATTR] = node.parentId;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
blocks.push({
|
|
146
|
+
id: node.id,
|
|
147
|
+
level,
|
|
148
|
+
type: 'heading',
|
|
149
|
+
dir: 'auto',
|
|
150
|
+
collapsed: false,
|
|
151
|
+
content: toContent(node.title),
|
|
152
|
+
...(Object.keys(attrs).length > 0 ? { attrs } : {}),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (node.body !== undefined && node.body !== '') {
|
|
156
|
+
blocks.push({
|
|
157
|
+
// Body text is not a node of its own, so it gets no node id to collide with.
|
|
158
|
+
id: '',
|
|
159
|
+
level: BODY,
|
|
160
|
+
type: 'paragraph',
|
|
161
|
+
dir: 'auto',
|
|
162
|
+
collapsed: false,
|
|
163
|
+
content: toContent(node.body),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const child of [...(children.get(node.id) ?? [])].reverse()) {
|
|
168
|
+
stack.push({ node: child, depth: depth + 1 });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/*
|
|
173
|
+
* Anything never reached is in a cycle, or hangs off one. Reported rather than emitted:
|
|
174
|
+
* a cycle has no depth, so there is no level to give it, and inventing one would put the
|
|
175
|
+
* nodes somewhere arbitrary in the user's document.
|
|
176
|
+
*/
|
|
177
|
+
const unreachable = nodes.filter((node) => !seen.has(node.id));
|
|
178
|
+
if (unreachable.length > 0) {
|
|
179
|
+
warnings.push(
|
|
180
|
+
`${unreachable.length} node(s) are unreachable from any root — a parent cycle: ${unreachable
|
|
181
|
+
.map((n) => JSON.stringify(n.id))
|
|
182
|
+
.join(', ')}.`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { blocks: blocks as Blocks<C>, warnings };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Rebuild the nested tree from blocks.
|
|
191
|
+
*
|
|
192
|
+
* The inverse of `nestedToBlocks`, and the direction that has to be right on save: a host
|
|
193
|
+
* writes what this returns back into their database, so anything it gets wrong is data loss
|
|
194
|
+
* rather than a rendering bug.
|
|
195
|
+
*
|
|
196
|
+
* Ordinals are RENUMBERED from document order. That is the point of editing an outline —
|
|
197
|
+
* the user reordered it — and preserving the incoming ordinals would throw their edit away.
|
|
198
|
+
*/
|
|
199
|
+
export function blocksToNested<C = unknown, T = unknown>(
|
|
200
|
+
blocks: Blocks<C>,
|
|
201
|
+
options?: NestedOptions<C>,
|
|
202
|
+
): readonly NestedNode<T>[] {
|
|
203
|
+
const fromContent = (options?.fromContent ?? defaultFromContent) as (
|
|
204
|
+
content: C | undefined,
|
|
205
|
+
) => string;
|
|
206
|
+
|
|
207
|
+
const index = buildIndex(blocks);
|
|
208
|
+
const out: NestedNode<T>[] = [];
|
|
209
|
+
const ordinals = new Map<string | null, number>();
|
|
210
|
+
|
|
211
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
212
|
+
const block = blocks[i];
|
|
213
|
+
if (!block || block.level === BODY) continue;
|
|
214
|
+
|
|
215
|
+
/*
|
|
216
|
+
* The parent is the nearest ancestor HEADING. Taken from the tree index rather than
|
|
217
|
+
* recomputed, so this agrees with collapse, indent and every other consumer of the
|
|
218
|
+
* hierarchy — a second implementation of "who is my parent" is a second answer.
|
|
219
|
+
*/
|
|
220
|
+
const attrs = block.attrs;
|
|
221
|
+
const clampedParent = attrs?.[PARENT_ATTR];
|
|
222
|
+
let parentId: string | null;
|
|
223
|
+
if (typeof clampedParent === 'string' || clampedParent === null) {
|
|
224
|
+
// Beyond MAX_LEVEL the level cannot express the parent, so the recorded one wins.
|
|
225
|
+
parentId = clampedParent;
|
|
226
|
+
} else {
|
|
227
|
+
const parent = parentOf(blocks, index, i);
|
|
228
|
+
parentId = parent === -1 ? null : (blocks[parent]?.id ?? null);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const ordinal = ordinals.get(parentId) ?? 0;
|
|
232
|
+
ordinals.set(parentId, ordinal + 1);
|
|
233
|
+
|
|
234
|
+
// Body text belonging to this heading: the level-0 blocks that immediately follow it.
|
|
235
|
+
const bodyParts: string[] = [];
|
|
236
|
+
for (let j = i + 1; j < blocks.length; j++) {
|
|
237
|
+
const next = blocks[j];
|
|
238
|
+
if (!next || next.level !== BODY) break;
|
|
239
|
+
bodyParts.push(fromContent(next.content));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const data = attrs?.[DATA_ATTR];
|
|
243
|
+
|
|
244
|
+
out.push({
|
|
245
|
+
id: block.id,
|
|
246
|
+
parentId,
|
|
247
|
+
title: fromContent(block.content),
|
|
248
|
+
ordinal,
|
|
249
|
+
...(bodyParts.length > 0 ? { body: bodyParts.join('\n\n') } : {}),
|
|
250
|
+
...(data !== undefined ? { data: data as T } : {}),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Nearest preceding block at a shallower level — the parent heading, or -1. */
|
|
258
|
+
function parentOf(blocks: Blocks, index: ReturnType<typeof buildIndex>, i: number): number {
|
|
259
|
+
const level = blocks[i]?.level;
|
|
260
|
+
if (level === undefined || level <= 1) return -1;
|
|
261
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
262
|
+
const other = blocks[j]?.level;
|
|
263
|
+
if (other === undefined || other === BODY) continue;
|
|
264
|
+
if (other < level) {
|
|
265
|
+
// Confirm containment against the shared index rather than trusting the scan alone.
|
|
266
|
+
const [, end] = subtreeRange(index, j);
|
|
267
|
+
return i < end ? j : -1;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return -1;
|
|
271
|
+
}
|
package/src/ops.ts
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Block,
|
|
3
|
+
type Blocks,
|
|
4
|
+
type OutlineLevel,
|
|
5
|
+
BODY,
|
|
6
|
+
MAX_LEVEL,
|
|
7
|
+
} from './types.js';
|
|
8
|
+
import { ancestors, buildIndex, isLocked, prevHeading, subtreeRange } from './tree.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Structural operations. All are pure: they take a flat list and return a new one,
|
|
12
|
+
* sharing unchanged blocks by reference so downstream memoization works.
|
|
13
|
+
*
|
|
14
|
+
* The defining rule of this module: an operation on a heading applies to its whole
|
|
15
|
+
* SUBTREE. Indenting a heading and leaving its children behind — the bug in the
|
|
16
|
+
* earlier Tiptap scaffold — produces an orphaned document.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Can `i` be indented?
|
|
21
|
+
*
|
|
22
|
+
* The rule prevents skipped levels: a heading may go one deeper than the nearest
|
|
23
|
+
* preceding heading, never more. Without this you can indent from level 1 straight
|
|
24
|
+
* to level 3 and create a block with no representable parent.
|
|
25
|
+
*
|
|
26
|
+
* Body text has no level, so indenting it is meaningless — use `setLevel` to promote
|
|
27
|
+
* body text into a heading instead.
|
|
28
|
+
*/
|
|
29
|
+
export function canIndent(blocks: Blocks, i: number): boolean {
|
|
30
|
+
const block = blocks[i];
|
|
31
|
+
if (!block || block.level === BODY) return false;
|
|
32
|
+
// A locked block's position is fixed. Checked here rather than in `indent` so the UI
|
|
33
|
+
// can grey the control out instead of offering an action that silently does nothing.
|
|
34
|
+
if (isLocked(blocks, buildIndex(blocks), i)) return false;
|
|
35
|
+
|
|
36
|
+
const prev = prevHeading(blocks, i);
|
|
37
|
+
if (prev === -1) return false; // first heading in the document: nothing to nest under
|
|
38
|
+
if (block.level > blocks[prev]!.level) return false; // already a child of prev
|
|
39
|
+
|
|
40
|
+
// The deepest descendant must still fit within MAX_LEVEL after the shift.
|
|
41
|
+
const index = buildIndex(blocks);
|
|
42
|
+
const end = index.subtreeEnd[i]!;
|
|
43
|
+
for (let j = i; j < end; j++) {
|
|
44
|
+
const l = blocks[j]!.level;
|
|
45
|
+
if (l !== BODY && l + 1 > MAX_LEVEL) return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function indent<C>(blocks: Blocks<C>, i: number): Blocks<C> {
|
|
51
|
+
if (!canIndent(blocks, i)) return blocks;
|
|
52
|
+
return shiftSubtree(blocks, i, +1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Level 1 cannot outdent — there is nothing above it. Body text has no level. */
|
|
56
|
+
export function canOutdent(blocks: Blocks, i: number): boolean {
|
|
57
|
+
const block = blocks[i];
|
|
58
|
+
if (!block || block.level < 2) return false;
|
|
59
|
+
return !isLocked(blocks, buildIndex(blocks), i);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Outdent a heading and its subtree.
|
|
64
|
+
*
|
|
65
|
+
* Pleasant property of the flat model: the following siblings that were at the
|
|
66
|
+
* outdented block's old level automatically become its children, which is exactly
|
|
67
|
+
* what Workflowy and Word's outline view do — no extra code needed.
|
|
68
|
+
*/
|
|
69
|
+
export function outdent<C>(blocks: Blocks<C>, i: number): Blocks<C> {
|
|
70
|
+
if (!canOutdent(blocks, i)) return blocks;
|
|
71
|
+
return shiftSubtree(blocks, i, -1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function shiftSubtree<C>(blocks: Blocks<C>, i: number, delta: number): Blocks<C> {
|
|
75
|
+
const index = buildIndex(blocks);
|
|
76
|
+
const end = index.subtreeEnd[i]!;
|
|
77
|
+
const next = blocks.slice();
|
|
78
|
+
for (let j = i; j < end; j++) {
|
|
79
|
+
const b = blocks[j]!;
|
|
80
|
+
if (b.level === BODY) continue; // body text has no level to shift
|
|
81
|
+
next[j] = { ...b, level: (b.level + delta) as OutlineLevel };
|
|
82
|
+
}
|
|
83
|
+
return next;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Set a block's level directly — used to promote body text to a heading and back. */
|
|
87
|
+
export function canSetLevel(blocks: Blocks, i: number): boolean {
|
|
88
|
+
return !!blocks[i] && !isLocked(blocks, buildIndex(blocks), i);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function setLevel<C>(blocks: Blocks<C>, i: number, level: OutlineLevel): Blocks<C> {
|
|
92
|
+
if (!canSetLevel(blocks, i)) return blocks;
|
|
93
|
+
const block = blocks[i];
|
|
94
|
+
if (!block || block.level === level) return blocks;
|
|
95
|
+
const next = blocks.slice();
|
|
96
|
+
next[i] = { ...block, level };
|
|
97
|
+
return next;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function toggleCollapse<C>(blocks: Blocks<C>, i: number): Blocks<C> {
|
|
101
|
+
const block = blocks[i];
|
|
102
|
+
if (!block) return blocks;
|
|
103
|
+
const next = blocks.slice();
|
|
104
|
+
next[i] = { ...block, collapsed: !block.collapsed };
|
|
105
|
+
return next;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Expand every collapsed ancestor of a block, so it can actually be reached.
|
|
110
|
+
*
|
|
111
|
+
* Needed wherever the app navigates to a block the user did not click: a search result, a
|
|
112
|
+
* cross-document link, a backlink. A collapsed subtree is ABSENT from the DOM, not hidden,
|
|
113
|
+
* so scrolling to it finds nothing and the jump silently does nothing at all — which reads
|
|
114
|
+
* as a broken feature rather than as "that match is folded away".
|
|
115
|
+
*
|
|
116
|
+
* Only ancestors are touched. The block's own collapse state is left alone: revealing a
|
|
117
|
+
* heading should not also unfold everything beneath it.
|
|
118
|
+
*
|
|
119
|
+
* Returns the SAME list when nothing was collapsed.
|
|
120
|
+
*/
|
|
121
|
+
export function expandAncestors<C>(blocks: Blocks<C>, blockId: string): Blocks<C> {
|
|
122
|
+
const at = blocks.findIndex((block) => block.id === blockId);
|
|
123
|
+
if (at === -1) return blocks;
|
|
124
|
+
|
|
125
|
+
const index = buildIndex(blocks);
|
|
126
|
+
const toOpen = new Set(
|
|
127
|
+
ancestors(index, at).filter((i) => blocks[i]?.collapsed === true),
|
|
128
|
+
);
|
|
129
|
+
if (toOpen.size === 0) return blocks;
|
|
130
|
+
|
|
131
|
+
return blocks.map((block, i) => (toOpen.has(i) ? { ...block, collapsed: false } : block));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function setDirection<C>(
|
|
135
|
+
blocks: Blocks<C>,
|
|
136
|
+
i: number,
|
|
137
|
+
dir: Block['dir'],
|
|
138
|
+
): Blocks<C> {
|
|
139
|
+
const block = blocks[i];
|
|
140
|
+
if (!block || block.dir === dir) return blocks;
|
|
141
|
+
const next = blocks.slice();
|
|
142
|
+
next[i] = { ...block, dir };
|
|
143
|
+
return next;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Move the subtree rooted at `from` so it begins at document position `to`.
|
|
148
|
+
*
|
|
149
|
+
* `to` is interpreted against the ORIGINAL list. Moving a subtree into itself is a
|
|
150
|
+
* no-op rather than an error, because drag-and-drop will attempt it constantly.
|
|
151
|
+
*/
|
|
152
|
+
/**
|
|
153
|
+
* Can the subtree at `from` be moved to `to`?
|
|
154
|
+
*
|
|
155
|
+
* Both ends matter: a locked subtree cannot be dragged away, and nothing may be dropped
|
|
156
|
+
* INTO a locked one. Checking only the source is the easy mistake — it lets a reader
|
|
157
|
+
* append into a chapter they are not allowed to change.
|
|
158
|
+
*/
|
|
159
|
+
export function canMoveSubtree(blocks: Blocks, from: number, to: number): boolean {
|
|
160
|
+
const index = buildIndex(blocks);
|
|
161
|
+
if (!blocks[from]) return false;
|
|
162
|
+
if (isLocked(blocks, index, from)) return false;
|
|
163
|
+
// A drop lands BETWEEN blocks, so the block before the insertion point is checked as
|
|
164
|
+
// well: landing just after a locked heading would place the block inside its subtree.
|
|
165
|
+
const before = to - 1;
|
|
166
|
+
if (before >= 0 && before < blocks.length && isLocked(blocks, index, before)) {
|
|
167
|
+
const [, end] = subtreeRange(index, before);
|
|
168
|
+
if (to < end) return false;
|
|
169
|
+
}
|
|
170
|
+
if (to < blocks.length && isLocked(blocks, index, to)) return false;
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function moveSubtree<C>(blocks: Blocks<C>, from: number, to: number): Blocks<C> {
|
|
175
|
+
const index = buildIndex(blocks);
|
|
176
|
+
const end = index.subtreeEnd[from]!;
|
|
177
|
+
if (to >= from && to <= end) return blocks; // dropping inside itself
|
|
178
|
+
if (!canMoveSubtree(blocks, from, to)) return blocks;
|
|
179
|
+
|
|
180
|
+
const slice = blocks.slice(from, end);
|
|
181
|
+
const rest = [...blocks.slice(0, from), ...blocks.slice(end)];
|
|
182
|
+
const insertAt = to > from ? to - (end - from) : to;
|
|
183
|
+
return [...rest.slice(0, insertAt), ...slice, ...rest.slice(insertAt)];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Repair skipped levels — the import path for real Word documents, which jump from
|
|
188
|
+
* H1 to H3 constantly.
|
|
189
|
+
*
|
|
190
|
+
* Each heading is clamped to at most one deeper than the preceding heading, and the
|
|
191
|
+
* first heading is clamped to 1. Body text is untouched.
|
|
192
|
+
*/
|
|
193
|
+
export function normalizeLevels<C>(blocks: Blocks<C>): Blocks<C> {
|
|
194
|
+
let changed = false;
|
|
195
|
+
const next = blocks.slice();
|
|
196
|
+
let prevLevel = 0;
|
|
197
|
+
|
|
198
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
199
|
+
const b = blocks[i]!;
|
|
200
|
+
if (b.level === BODY) continue;
|
|
201
|
+
|
|
202
|
+
const max = (prevLevel + 1) as OutlineLevel;
|
|
203
|
+
const level = b.level > max ? max : b.level;
|
|
204
|
+
if (level !== b.level) {
|
|
205
|
+
next[i] = { ...b, level };
|
|
206
|
+
changed = true;
|
|
207
|
+
}
|
|
208
|
+
prevLevel = level;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return changed ? next : blocks;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** True when no heading is more than one level deeper than the one before it. */
|
|
215
|
+
export function isNormalized(blocks: Blocks): boolean {
|
|
216
|
+
let prevLevel = 0;
|
|
217
|
+
for (const b of blocks) {
|
|
218
|
+
if (b.level === BODY) continue;
|
|
219
|
+
if (b.level > prevLevel + 1) return false;
|
|
220
|
+
prevLevel = b.level;
|
|
221
|
+
}
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Lock or unlock a block. Locking a heading makes its whole subtree read-only by
|
|
228
|
+
* derivation — only the heading itself carries the flag.
|
|
229
|
+
*/
|
|
230
|
+
export function setLocked<C>(blocks: Blocks<C>, i: number, locked: boolean): Blocks<C> {
|
|
231
|
+
const block = blocks[i];
|
|
232
|
+
if (!block || (block.locked ?? false) === locked) return blocks;
|
|
233
|
+
const out = [...blocks];
|
|
234
|
+
// Unlocking REMOVES the flag rather than setting it to `false`, so a document that
|
|
235
|
+
// never used locking serializes exactly as it did before the feature existed.
|
|
236
|
+
out[i] = locked ? { ...block, locked: true } : stripLocked(block);
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function stripLocked<C>(block: Block<C>): Block<C> {
|
|
241
|
+
const { locked: _locked, ...rest } = block;
|
|
242
|
+
return rest as Block<C>;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The previous sibling of the block at `i`, or -1.
|
|
247
|
+
*
|
|
248
|
+
* A sibling is the nearest preceding block at the SAME level that shares a parent — which
|
|
249
|
+
* for this model means: walking backwards, the first block at that level, stopping if a
|
|
250
|
+
* shallower one appears first, because that is the parent and the run has ended.
|
|
251
|
+
*/
|
|
252
|
+
function previousSibling(blocks: Blocks, i: number): number {
|
|
253
|
+
const level = blocks[i]?.level;
|
|
254
|
+
if (level === undefined) return -1;
|
|
255
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
256
|
+
const other = blocks[j]?.level;
|
|
257
|
+
if (other === undefined) continue;
|
|
258
|
+
if (other === level) return j;
|
|
259
|
+
// Body text (level 0) sits under headings and never terminates a heading's run, but a
|
|
260
|
+
// heading shallower than us IS our parent and there is no earlier sibling under it.
|
|
261
|
+
if (other !== BODY && other < level) return -1;
|
|
262
|
+
if (level === BODY && other !== BODY) return -1;
|
|
263
|
+
}
|
|
264
|
+
return -1;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Move a node before its previous sibling, subtree and all.
|
|
269
|
+
*
|
|
270
|
+
* "Up" is **a sibling**, not a row. A heading with forty blocks under it must jump the
|
|
271
|
+
* whole of its neighbour's subtree; swapping single rows would tear both apart, and the
|
|
272
|
+
* design's درخت menu calls this "move node" for that reason.
|
|
273
|
+
*
|
|
274
|
+
* A first sibling does not move. The tempting alternative — promote it above its parent —
|
|
275
|
+
* changes the node's LEVEL as a side effect of a move, and a command that quietly does two
|
|
276
|
+
* things is one users stop trusting. Indent and outdent are how a level changes.
|
|
277
|
+
*/
|
|
278
|
+
export function moveBlockUp<C>(blocks: Blocks<C>, blockId: string): Blocks<C> {
|
|
279
|
+
const at = blocks.findIndex((block) => block.id === blockId);
|
|
280
|
+
if (at === -1) return blocks;
|
|
281
|
+
const target = previousSibling(blocks, at);
|
|
282
|
+
if (target === -1) return blocks;
|
|
283
|
+
return moveSubtree(blocks, at, target);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Move a node after its next sibling, subtree and all.
|
|
288
|
+
*
|
|
289
|
+
* The target is the END of the sibling's subtree, in ORIGINAL indices — `moveSubtree`
|
|
290
|
+
* interprets `to` against the list it was given and does the shift arithmetic itself.
|
|
291
|
+
* Pre-adjusting by the moved subtree's size (the obvious defensive move) lands the target
|
|
292
|
+
* back inside the node being moved, where `moveSubtree`'s "dropping inside itself" guard
|
|
293
|
+
* correctly refuses it and the command silently does nothing.
|
|
294
|
+
*/
|
|
295
|
+
export function moveBlockDown<C>(blocks: Blocks<C>, blockId: string): Blocks<C> {
|
|
296
|
+
const at = blocks.findIndex((block) => block.id === blockId);
|
|
297
|
+
if (at === -1) return blocks;
|
|
298
|
+
|
|
299
|
+
const index = buildIndex(blocks);
|
|
300
|
+
const [, end] = subtreeRange(index, at);
|
|
301
|
+
if (end >= blocks.length) return blocks;
|
|
302
|
+
|
|
303
|
+
const level = blocks[at]?.level;
|
|
304
|
+
const nextLevel = blocks[end]?.level;
|
|
305
|
+
if (level === undefined || nextLevel === undefined) return blocks;
|
|
306
|
+
// The block after our subtree is only a SIBLING if it is at our level; anything
|
|
307
|
+
// shallower is the end of our parent's run and there is nothing left to swap with.
|
|
308
|
+
if (nextLevel !== level) return blocks;
|
|
309
|
+
|
|
310
|
+
const [, siblingEnd] = subtreeRange(index, end);
|
|
311
|
+
return moveSubtree(blocks, at, siblingEnd);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The level this heading skipped over, or `null`.
|
|
316
|
+
*
|
|
317
|
+
* `isNormalized` answers "does this document skip anywhere", which is enough to enable a
|
|
318
|
+
* repair command and useless for showing the reader WHERE. Frame 1a puts an amber
|
|
319
|
+
* «سطح ۳ جا افتاده» chip on the offending heading, so the question has to be asked of a
|
|
320
|
+
* single block.
|
|
321
|
+
*
|
|
322
|
+
* Returns the FIRST missing level, not the deepest. When 2 → 4 skips only 3 that is the
|
|
323
|
+
* same answer either way, but 1 → 4 skips both 2 and 3, and 2 is the one to add first —
|
|
324
|
+
* naming 3 would send someone to fix the wrong end of the gap.
|
|
325
|
+
*
|
|
326
|
+
* Body text is never an answer and never breaks the chain: level 0 is not a hierarchy
|
|
327
|
+
* level, it belongs to the nearest preceding heading, and a paragraph between two headings
|
|
328
|
+
* does not make the second one a skip.
|
|
329
|
+
*/
|
|
330
|
+
export function skippedLevelAt(blocks: Blocks, i: number): OutlineLevel | null {
|
|
331
|
+
const block = blocks[i];
|
|
332
|
+
if (!block || block.level === BODY) return null;
|
|
333
|
+
|
|
334
|
+
const prev = prevHeading(blocks, i);
|
|
335
|
+
// A document that opens below level 1 has no representable parent for its first heading.
|
|
336
|
+
const parentLevel = prev === -1 ? 0 : (blocks[prev]?.level ?? 0);
|
|
337
|
+
|
|
338
|
+
// Going back up, or one step down, is never a skip.
|
|
339
|
+
if (block.level <= parentLevel + 1) return null;
|
|
340
|
+
return (parentLevel + 1) as OutlineLevel;
|
|
341
|
+
}
|