@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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/dist/code-tokens.d.ts +39 -0
  4. package/dist/code-tokens.d.ts.map +1 -0
  5. package/dist/code-tokens.js +189 -0
  6. package/dist/code-tokens.js.map +1 -0
  7. package/dist/context-menu.d.ts +59 -0
  8. package/dist/context-menu.d.ts.map +1 -0
  9. package/dist/context-menu.js +69 -0
  10. package/dist/context-menu.js.map +1 -0
  11. package/dist/features.d.ts +114 -0
  12. package/dist/features.d.ts.map +1 -0
  13. package/dist/features.js +128 -0
  14. package/dist/features.js.map +1 -0
  15. package/dist/index.d.ts +18 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +12 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/nested.d.ts +66 -0
  20. package/dist/nested.d.ts.map +1 -0
  21. package/dist/nested.js +194 -0
  22. package/dist/nested.js.map +1 -0
  23. package/dist/ops.d.ts +120 -0
  24. package/dist/ops.d.ts.map +1 -0
  25. package/dist/ops.js +333 -0
  26. package/dist/ops.js.map +1 -0
  27. package/dist/shortcuts.d.ts +90 -0
  28. package/dist/shortcuts.d.ts.map +1 -0
  29. package/dist/shortcuts.js +179 -0
  30. package/dist/shortcuts.js.map +1 -0
  31. package/dist/spine.d.ts +92 -0
  32. package/dist/spine.d.ts.map +1 -0
  33. package/dist/spine.js +213 -0
  34. package/dist/spine.js.map +1 -0
  35. package/dist/styles.d.ts +63 -0
  36. package/dist/styles.d.ts.map +1 -0
  37. package/dist/styles.js +169 -0
  38. package/dist/styles.js.map +1 -0
  39. package/dist/tree.d.ts +70 -0
  40. package/dist/tree.d.ts.map +1 -0
  41. package/dist/tree.js +190 -0
  42. package/dist/tree.js.map +1 -0
  43. package/dist/types.d.ts +239 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +47 -0
  46. package/dist/types.js.map +1 -0
  47. package/package.json +52 -0
  48. package/src/code-tokens.ts +236 -0
  49. package/src/context-menu.ts +139 -0
  50. package/src/features.ts +173 -0
  51. package/src/index.ts +58 -0
  52. package/src/nested.ts +271 -0
  53. package/src/ops.ts +341 -0
  54. package/src/shortcuts.ts +280 -0
  55. package/src/spine.ts +214 -0
  56. package/src/styles.ts +201 -0
  57. package/src/tree.ts +189 -0
  58. 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
+ }