@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/shortcuts.ts
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { resolveFeatures, type OutlineFeatureName, type OutlineFeatures } from './features.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The keyboard shortcuts, as DATA.
|
|
5
|
+
*
|
|
6
|
+
* A hard-coded `addKeyboardShortcuts` in the editor extension has three problems, and each
|
|
7
|
+
* one lands on a different person:
|
|
8
|
+
*
|
|
9
|
+
* 1. A host cannot CHANGE it. Every embedder inherits `Tab` and `Mod-.` whether those
|
|
10
|
+
* collide with their own application or not — and `Tab` in particular is the web's
|
|
11
|
+
* focus key, so an editor that eats it is one a keyboard user cannot leave.
|
|
12
|
+
* 2. A host cannot READ it. Rendering a shortcuts sheet means retyping the list by hand,
|
|
13
|
+
* and the copy drifts from the bindings the moment either changes.
|
|
14
|
+
* 3. Nobody can turn it off, per binding or at all.
|
|
15
|
+
*
|
|
16
|
+
* So the table is plain serializable data — no closures, no editor reference — and the
|
|
17
|
+
* Tiptap extension is generated from it. A settings screen can render it, store it, diff
|
|
18
|
+
* it and hand back an override map.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The commands a shortcut can be bound to. Names, not functions — see the note above. */
|
|
22
|
+
export type ShortcutCommand =
|
|
23
|
+
| 'indent'
|
|
24
|
+
| 'outdent'
|
|
25
|
+
| 'setLevel1'
|
|
26
|
+
| 'setLevel2'
|
|
27
|
+
| 'setLevel3'
|
|
28
|
+
| 'setLevel4'
|
|
29
|
+
| 'setLevel5'
|
|
30
|
+
| 'setLevel6'
|
|
31
|
+
| 'setBodyText'
|
|
32
|
+
| 'toggleCollapse'
|
|
33
|
+
| 'collapseSiblings'
|
|
34
|
+
| 'insertZwnj'
|
|
35
|
+
| 'undo'
|
|
36
|
+
| 'redo'
|
|
37
|
+
| 'bold'
|
|
38
|
+
| 'italic'
|
|
39
|
+
| 'underline'
|
|
40
|
+
| 'strike'
|
|
41
|
+
| 'moveUp'
|
|
42
|
+
| 'moveDown';
|
|
43
|
+
|
|
44
|
+
export interface ShortcutBinding {
|
|
45
|
+
readonly command: ShortcutCommand;
|
|
46
|
+
/** ProseMirror's spelling: `Mod-` is ⌘ on Apple and Ctrl elsewhere. */
|
|
47
|
+
readonly key: string;
|
|
48
|
+
/** For grouping in a help sheet. */
|
|
49
|
+
readonly group: 'structure' | 'collapse' | 'formatting' | 'editing' | 'persian';
|
|
50
|
+
/**
|
|
51
|
+
* The feature this binding belongs to, if any.
|
|
52
|
+
*
|
|
53
|
+
* Lets `features={{ collapse: false }}` drop `Mod-.` without the host having to know
|
|
54
|
+
* which key that was. A binding with no feature is structural and always survives —
|
|
55
|
+
* indent and outdent ARE the outline, not a feature of it.
|
|
56
|
+
*/
|
|
57
|
+
readonly feature?: OutlineFeatureName;
|
|
58
|
+
/** Shown in a help sheet. English; a host translates via its own label pack. */
|
|
59
|
+
readonly description: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The defaults.
|
|
64
|
+
*
|
|
65
|
+
* `Mod-1…6` rather than 1…9: levels 7–9 exist in the model because Word documents contain
|
|
66
|
+
* them, but no keyboard has a comfortable ninth digit and the ladder covers the rest.
|
|
67
|
+
*/
|
|
68
|
+
export const DEFAULT_SHORTCUTS: readonly ShortcutBinding[] = [
|
|
69
|
+
{ command: 'indent', key: 'Tab', group: 'structure', description: 'Indent block' },
|
|
70
|
+
{ command: 'outdent', key: 'Shift-Tab', group: 'structure', description: 'Outdent block' },
|
|
71
|
+
{ command: 'setLevel1', key: 'Mod-1', group: 'structure', description: 'Heading 1' },
|
|
72
|
+
{ command: 'setLevel2', key: 'Mod-2', group: 'structure', description: 'Heading 2' },
|
|
73
|
+
{ command: 'setLevel3', key: 'Mod-3', group: 'structure', description: 'Heading 3' },
|
|
74
|
+
{ command: 'setLevel4', key: 'Mod-4', group: 'structure', description: 'Heading 4' },
|
|
75
|
+
{ command: 'setLevel5', key: 'Mod-5', group: 'structure', description: 'Heading 5' },
|
|
76
|
+
{ command: 'setLevel6', key: 'Mod-6', group: 'structure', description: 'Heading 6' },
|
|
77
|
+
{ command: 'setBodyText', key: 'Mod-0', group: 'structure', description: 'Body text' },
|
|
78
|
+
{ command: 'moveUp', key: 'Alt-ArrowUp', group: 'structure', description: 'Move block up' },
|
|
79
|
+
{ command: 'moveDown', key: 'Alt-ArrowDown', group: 'structure', description: 'Move block down' },
|
|
80
|
+
|
|
81
|
+
{
|
|
82
|
+
command: 'toggleCollapse',
|
|
83
|
+
key: 'Mod-.',
|
|
84
|
+
group: 'collapse',
|
|
85
|
+
feature: 'collapse',
|
|
86
|
+
description: 'Collapse or expand this node',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
command: 'collapseSiblings',
|
|
90
|
+
key: 'Mod-Shift-.',
|
|
91
|
+
group: 'collapse',
|
|
92
|
+
feature: 'collapse',
|
|
93
|
+
description: 'Collapse every sibling at this level',
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
{
|
|
97
|
+
command: 'bold',
|
|
98
|
+
key: 'Mod-b',
|
|
99
|
+
group: 'formatting',
|
|
100
|
+
feature: 'formatting',
|
|
101
|
+
description: 'Bold',
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
command: 'italic',
|
|
105
|
+
key: 'Mod-i',
|
|
106
|
+
group: 'formatting',
|
|
107
|
+
feature: 'formatting',
|
|
108
|
+
description: 'Italic',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
command: 'underline',
|
|
112
|
+
key: 'Mod-u',
|
|
113
|
+
group: 'formatting',
|
|
114
|
+
feature: 'formatting',
|
|
115
|
+
description: 'Underline',
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
command: 'strike',
|
|
119
|
+
key: 'Mod-Shift-x',
|
|
120
|
+
group: 'formatting',
|
|
121
|
+
feature: 'formatting',
|
|
122
|
+
description: 'Strikethrough',
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
{ command: 'undo', key: 'Mod-z', group: 'editing', feature: 'history', description: 'Undo' },
|
|
126
|
+
{
|
|
127
|
+
command: 'redo',
|
|
128
|
+
key: 'Mod-Shift-z',
|
|
129
|
+
group: 'editing',
|
|
130
|
+
feature: 'history',
|
|
131
|
+
description: 'Redo',
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* نیمفاصله. Not a convenience: this is how Persian compound words are written at all —
|
|
136
|
+
* میشود, نمیتوان, دانشآموزان — and an editor without it cannot be used for real Persian.
|
|
137
|
+
*/
|
|
138
|
+
{
|
|
139
|
+
command: 'insertZwnj',
|
|
140
|
+
key: 'Shift-Space',
|
|
141
|
+
group: 'persian',
|
|
142
|
+
description: 'Half-space (ZWNJ)',
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
const COMMANDS = new Set<string>(DEFAULT_SHORTCUTS.map((b) => b.command));
|
|
147
|
+
|
|
148
|
+
export interface ShortcutOptions {
|
|
149
|
+
/** `false` binds nothing at all. */
|
|
150
|
+
readonly enabled?: boolean;
|
|
151
|
+
/**
|
|
152
|
+
* Rebind (`'Mod-Shift-c'`) or unbind (`null`) by command name.
|
|
153
|
+
*
|
|
154
|
+
* By command rather than by key, because the host knows what they want to move, not
|
|
155
|
+
* what it is currently bound to — and the current binding is exactly the thing they
|
|
156
|
+
* would have to keep in sync by hand.
|
|
157
|
+
*/
|
|
158
|
+
readonly overrides?: Readonly<Partial<Record<ShortcutCommand, string | null>>>;
|
|
159
|
+
/** Bindings whose feature is off are dropped. */
|
|
160
|
+
readonly features?: Partial<OutlineFeatures> | readonly OutlineFeatureName[];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Apply a host's overrides and feature set to the default table.
|
|
165
|
+
*
|
|
166
|
+
* Collisions THROW. Letting a rebinding silently win means the losing command stops
|
|
167
|
+
* working with nothing logged, discoverable only by someone trying it and wondering what
|
|
168
|
+
* broke — and by then the override is somewhere in the host's configuration, far from the
|
|
169
|
+
* symptom.
|
|
170
|
+
*/
|
|
171
|
+
export function resolveShortcuts(options?: ShortcutOptions): readonly ShortcutBinding[] {
|
|
172
|
+
if (options?.enabled === false) return [];
|
|
173
|
+
|
|
174
|
+
const features = resolveFeatures(options?.features);
|
|
175
|
+
const overrides = options?.overrides ?? {};
|
|
176
|
+
|
|
177
|
+
for (const name of Object.keys(overrides)) {
|
|
178
|
+
if (!COMMANDS.has(name)) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`Unknown shortcut command ${JSON.stringify(name)}. Known: ${[...COMMANDS].join(', ')}.`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const resolved: ShortcutBinding[] = [];
|
|
186
|
+
for (const binding of DEFAULT_SHORTCUTS) {
|
|
187
|
+
if (binding.feature !== undefined && !features[binding.feature]) continue;
|
|
188
|
+
|
|
189
|
+
const override = Object.hasOwn(overrides, binding.command)
|
|
190
|
+
? overrides[binding.command]
|
|
191
|
+
: undefined;
|
|
192
|
+
// An explicit null unbinds; `undefined` means the host said nothing about it.
|
|
193
|
+
if (override === null) continue;
|
|
194
|
+
resolved.push(override === undefined ? binding : { ...binding, key: override });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/*
|
|
198
|
+
* Collisions are checked AFTER every override is applied, not while applying them, so
|
|
199
|
+
* that swapping two commands works in a single call. Checking as we go would reject
|
|
200
|
+
* `{ indent: null, toggleCollapse: 'Tab' }` depending on table order, which is a rule
|
|
201
|
+
* nobody could predict.
|
|
202
|
+
*/
|
|
203
|
+
const seen = new Map<string, ShortcutCommand>();
|
|
204
|
+
for (const binding of resolved) {
|
|
205
|
+
const previous = seen.get(binding.key);
|
|
206
|
+
if (previous !== undefined) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`Shortcut collision on ${binding.key}: ${previous} and ${binding.command}. ` +
|
|
209
|
+
`Unbind one with { ${previous}: null }.`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
seen.set(binding.key, binding.command);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return resolved;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface ShortcutGroup {
|
|
219
|
+
readonly group: ShortcutBinding['group'];
|
|
220
|
+
readonly bindings: readonly ShortcutBinding[];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Stable group order for a help sheet — structure first, because it is the product. */
|
|
224
|
+
const GROUP_ORDER: readonly ShortcutBinding['group'][] = [
|
|
225
|
+
'structure',
|
|
226
|
+
'collapse',
|
|
227
|
+
'formatting',
|
|
228
|
+
'editing',
|
|
229
|
+
'persian',
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Group the live bindings for display.
|
|
234
|
+
*
|
|
235
|
+
* The order is fixed rather than derived from the data: a sheet that reshuffles between
|
|
236
|
+
* openings cannot be learned, and learning it is the only reason it exists.
|
|
237
|
+
*/
|
|
238
|
+
export function shortcutsFor(bindings: readonly ShortcutBinding[]): readonly ShortcutGroup[] {
|
|
239
|
+
return GROUP_ORDER.map((group) => ({
|
|
240
|
+
group,
|
|
241
|
+
bindings: bindings.filter((binding) => binding.group === group),
|
|
242
|
+
})).filter((entry) => entry.bindings.length > 0);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Which glyph set to render. Callers pass their own platform check. */
|
|
246
|
+
export type ShortcutPlatform = 'apple' | 'other';
|
|
247
|
+
|
|
248
|
+
const APPLE_GLYPHS: Readonly<Record<string, string>> = {
|
|
249
|
+
Mod: '⌘',
|
|
250
|
+
Shift: '⇧',
|
|
251
|
+
Alt: '⌥',
|
|
252
|
+
Ctrl: '⌃',
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const ARROWS: Readonly<Record<string, string>> = {
|
|
256
|
+
ArrowUp: '↑',
|
|
257
|
+
ArrowDown: '↓',
|
|
258
|
+
ArrowLeft: '←',
|
|
259
|
+
ArrowRight: '→',
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* A binding as a person reads it.
|
|
264
|
+
*
|
|
265
|
+
* `Mod` is ProseMirror's platform-agnostic spelling and must never reach a user: nobody
|
|
266
|
+
* has a Mod key. Apple users read glyphs with no separator, everyone else reads words
|
|
267
|
+
* joined by `+`, and getting that backwards is one of the small things that makes software
|
|
268
|
+
* feel foreign.
|
|
269
|
+
*/
|
|
270
|
+
export function formatShortcut(key: string, platform: ShortcutPlatform): string {
|
|
271
|
+
const parts = key.split('-');
|
|
272
|
+
const last = parts.pop() ?? '';
|
|
273
|
+
// A single letter is shown capitalised — `Mod-b` is written ⌘B on every key cap.
|
|
274
|
+
const final = ARROWS[last] ?? (last.length === 1 ? last.toUpperCase() : last);
|
|
275
|
+
|
|
276
|
+
if (platform === 'apple') {
|
|
277
|
+
return parts.map((part) => APPLE_GLYPHS[part] ?? part).join('') + final;
|
|
278
|
+
}
|
|
279
|
+
return [...parts.map((part) => (part === 'Mod' ? 'Ctrl' : part)), final].join('+');
|
|
280
|
+
}
|
package/src/spine.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { ancestors, buildIndex, hasChildren, subtreeRange, visibleIndices } from './tree.js';
|
|
2
|
+
import { BODY, type Blocks, type OutlineLevel, type TreeIndex } from './types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Operations behind the collapse spine.
|
|
6
|
+
*
|
|
7
|
+
* Collapse is the most-used interaction in the product and the mechanism the whole
|
|
8
|
+
* performance strategy rests on (a collapsed subtree is *unmounted*, never hidden), so its
|
|
9
|
+
* operations live together rather than scattered through `ops.ts`.
|
|
10
|
+
*
|
|
11
|
+
* All of them are pure and return the SAME list when nothing changed, like every other
|
|
12
|
+
* structural operation here — downstream memoization depends on it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How many blocks a heading would fold away.
|
|
17
|
+
*
|
|
18
|
+
* This is what the spine's scope line is a picture of, and what the count pill states. It
|
|
19
|
+
* excludes the heading itself: the question a reader is asking is "how much disappears if
|
|
20
|
+
* I click this", and the heading does not.
|
|
21
|
+
*/
|
|
22
|
+
export function subtreeSize(blocks: Blocks, blockId: string, index?: TreeIndex): number {
|
|
23
|
+
const at = blocks.findIndex((block) => block.id === blockId);
|
|
24
|
+
if (at === -1) return 0;
|
|
25
|
+
const [start, end] = subtreeRange(index ?? buildIndex(blocks), at);
|
|
26
|
+
return Math.max(0, end - start - 1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Collapse or expand every sibling at one heading's level, within its parent.
|
|
31
|
+
*
|
|
32
|
+
* Bound to ⌥click. "Siblings" means blocks with the same parent AND the same level — not
|
|
33
|
+
* every block at that level in the document, which would fold unrelated branches the user
|
|
34
|
+
* cannot see and did not ask about.
|
|
35
|
+
*
|
|
36
|
+
* The clicked heading decides the direction for all of them: if it is open, they all
|
|
37
|
+
* close. Deriving each one independently would produce a checkerboard.
|
|
38
|
+
*/
|
|
39
|
+
export function collapseSiblings<C>(
|
|
40
|
+
blocks: Blocks<C>,
|
|
41
|
+
blockId: string,
|
|
42
|
+
collapsed?: boolean,
|
|
43
|
+
): Blocks<C> {
|
|
44
|
+
const index = buildIndex(blocks);
|
|
45
|
+
const at = blocks.findIndex((block) => block.id === blockId);
|
|
46
|
+
if (at === -1) return blocks;
|
|
47
|
+
|
|
48
|
+
const level = blocks[at]?.level ?? BODY;
|
|
49
|
+
if (level === BODY) return blocks;
|
|
50
|
+
const parent = index.parent[at] ?? -1;
|
|
51
|
+
|
|
52
|
+
const target = collapsed ?? !(blocks[at]?.collapsed ?? false);
|
|
53
|
+
const wanted = new Set<number>();
|
|
54
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
55
|
+
if ((blocks[i]?.level ?? BODY) !== level) continue;
|
|
56
|
+
if ((index.parent[i] ?? -1) !== parent) continue;
|
|
57
|
+
// A heading with nothing under it has no collapsed state worth setting.
|
|
58
|
+
if (!hasChildren(index, i)) continue;
|
|
59
|
+
wanted.add(i);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let changed = false;
|
|
63
|
+
const next = blocks.map((block, i) => {
|
|
64
|
+
if (!wanted.has(i) || (block.collapsed ?? false) === target) return block;
|
|
65
|
+
changed = true;
|
|
66
|
+
return { ...block, collapsed: target };
|
|
67
|
+
});
|
|
68
|
+
return changed ? next : blocks;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Open the tree down to `level`, closing everything deeper.
|
|
73
|
+
*
|
|
74
|
+
* Bound to ⌘⌥1…6 and to the ladder control. `level` 0 means "close everything" — every
|
|
75
|
+
* heading that governs a subtree folds; a level above the deepest heading means "open
|
|
76
|
+
* everything".
|
|
77
|
+
*
|
|
78
|
+
* Headings with no children are left alone: a collapsed flag on a leaf is invisible, and
|
|
79
|
+
* setting it would make the ladder's state depend on blocks the reader cannot see.
|
|
80
|
+
*/
|
|
81
|
+
export function openToLevel<C>(blocks: Blocks<C>, level: number): Blocks<C> {
|
|
82
|
+
const index = buildIndex(blocks);
|
|
83
|
+
let changed = false;
|
|
84
|
+
|
|
85
|
+
const next = blocks.map((block, i) => {
|
|
86
|
+
const blockLevel = block.level ?? BODY;
|
|
87
|
+
if (blockLevel === BODY || !hasChildren(index, i)) return block;
|
|
88
|
+
/*
|
|
89
|
+
* A heading AT the target level COLLAPSES: "open to level 2" means level-2 headings
|
|
90
|
+
* are the deepest thing visible, so their own children are folded away. The heading
|
|
91
|
+
* itself is still on screen — it is its subtree that closes.
|
|
92
|
+
*/
|
|
93
|
+
const shouldCollapse = blockLevel >= level;
|
|
94
|
+
if ((block.collapsed ?? false) === shouldCollapse) return block;
|
|
95
|
+
changed = true;
|
|
96
|
+
return { ...block, collapsed: shouldCollapse };
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return changed ? next : blocks;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Open a document as deep as a mounted-block budget allows.
|
|
104
|
+
*
|
|
105
|
+
* For a large import. A real 20,407-block book opened fully mounts every block — the cost the
|
|
106
|
+
* whole scale strategy exists to avoid (CLAUDE.md §2) — so it opens to the deepest level whose
|
|
107
|
+
* mounted window fits, and the ladder takes the reader further. Returns the SAME list when it
|
|
108
|
+
* already fits, stored collapse state included: an author's own folds are not overridden.
|
|
109
|
+
*
|
|
110
|
+
* Never shallower than level 1. Folding the top level away too would leave nothing on screen
|
|
111
|
+
* but the chapters' absence; one row per chapter is the floor, over budget or not.
|
|
112
|
+
*/
|
|
113
|
+
export function openToFit<C>(blocks: Blocks<C>, budget: number): Blocks<C> {
|
|
114
|
+
const mounted = (candidate: Blocks<C>) => visibleIndices(candidate, buildIndex(candidate)).length;
|
|
115
|
+
if (mounted(blocks) <= budget) return blocks;
|
|
116
|
+
|
|
117
|
+
for (let level = deepestParentLevel(blocks); level > 1; level--) {
|
|
118
|
+
const candidate = openToLevel(blocks, level);
|
|
119
|
+
if (mounted(candidate) <= budget) return candidate;
|
|
120
|
+
}
|
|
121
|
+
return openToLevel(blocks, 1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The deepest level in the document that governs a subtree.
|
|
126
|
+
*
|
|
127
|
+
* The ladder offers rungs only for levels that exist: a document of H1s and H2s should not
|
|
128
|
+
* show six of them, because four of the buttons would do nothing.
|
|
129
|
+
*/
|
|
130
|
+
export function deepestParentLevel(blocks: Blocks): OutlineLevel {
|
|
131
|
+
const index = buildIndex(blocks);
|
|
132
|
+
let deepest = 0;
|
|
133
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
134
|
+
const level = blocks[i]?.level ?? BODY;
|
|
135
|
+
if (level !== BODY && hasChildren(index, i) && level > deepest) deepest = level;
|
|
136
|
+
}
|
|
137
|
+
return deepest as OutlineLevel;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Which level the tree is currently open to, or null when the state is mixed.
|
|
142
|
+
*
|
|
143
|
+
* The ladder highlights a rung only when the document actually matches it. Reporting a
|
|
144
|
+
* best guess would leave the control lit while the tree says otherwise, and the user would
|
|
145
|
+
* stop trusting it.
|
|
146
|
+
*/
|
|
147
|
+
export function currentOpenLevel(blocks: Blocks): number | null {
|
|
148
|
+
const index = buildIndex(blocks);
|
|
149
|
+
let deepestOpen = 0;
|
|
150
|
+
let shallowestCollapsed = Number.POSITIVE_INFINITY;
|
|
151
|
+
|
|
152
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
153
|
+
const block = blocks[i];
|
|
154
|
+
const level = block?.level ?? BODY;
|
|
155
|
+
if (level === BODY || !hasChildren(index, i)) continue;
|
|
156
|
+
if (block?.collapsed) shallowestCollapsed = Math.min(shallowestCollapsed, level);
|
|
157
|
+
else deepestOpen = Math.max(deepestOpen, level);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (shallowestCollapsed === Number.POSITIVE_INFINITY) return deepestOpen + 1;
|
|
161
|
+
if (deepestOpen === 0) return 0;
|
|
162
|
+
// Consistent only when every open heading is shallower than every collapsed one.
|
|
163
|
+
return deepestOpen < shallowestCollapsed ? shallowestCollapsed : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Whether a block is inside a collapsed ancestor, and therefore not rendered.
|
|
168
|
+
*
|
|
169
|
+
* Needed by anything that wants to reveal a block: a scroll target that is currently
|
|
170
|
+
* unmounted has no geometry, so callers must open the way to it first.
|
|
171
|
+
*/
|
|
172
|
+
export function isHidden(blocks: Blocks, blockId: string): boolean {
|
|
173
|
+
const at = blocks.findIndex((block) => block.id === blockId);
|
|
174
|
+
if (at === -1) return false;
|
|
175
|
+
const index = buildIndex(blocks);
|
|
176
|
+
return ancestors(index, at).some((i) => blocks[i]?.collapsed === true);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Close everything except the path to one subtree, and the subtree itself.
|
|
181
|
+
*
|
|
182
|
+
* Frame 3a's node menu calls this «فقط این زیردرخت باز بماند». It is the one command that
|
|
183
|
+
* makes a 50,000-block document usable for a single afternoon's work: every other branch
|
|
184
|
+
* unmounts, so what is left on screen is exactly the part being written.
|
|
185
|
+
*
|
|
186
|
+
* The heading's ANCESTORS have to stay open too, or the isolated subtree would itself be
|
|
187
|
+
* inside a fold and the command would blank the document — the most obvious way to write
|
|
188
|
+
* this, and wrong.
|
|
189
|
+
*
|
|
190
|
+
* Other branches fold at EVERY depth, not just at their topmost heading. Stopping at the
|
|
191
|
+
* first fold leaves the old open state buried inside it, so re-opening that branch later
|
|
192
|
+
* spills exactly what the user had just asked to put away.
|
|
193
|
+
*/
|
|
194
|
+
export function isolateSubtree<C>(blocks: Blocks<C>, blockId: string): Blocks<C> {
|
|
195
|
+
const at = blocks.findIndex((block) => block.id === blockId);
|
|
196
|
+
if (at === -1) return blocks;
|
|
197
|
+
|
|
198
|
+
const index = buildIndex(blocks);
|
|
199
|
+
const [, end] = subtreeRange(index, at);
|
|
200
|
+
const open = new Set<number>(ancestors(index, at));
|
|
201
|
+
for (let i = at; i < end; i++) open.add(i);
|
|
202
|
+
|
|
203
|
+
let changed = false;
|
|
204
|
+
const next = blocks.map((block, i) => {
|
|
205
|
+
// A leaf's collapsed flag is invisible, so leave it exactly as it was.
|
|
206
|
+
if (!hasChildren(index, i)) return block;
|
|
207
|
+
const collapsed = !open.has(i);
|
|
208
|
+
if ((block.collapsed === true) === collapsed) return block;
|
|
209
|
+
changed = true;
|
|
210
|
+
return { ...block, collapsed };
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return changed ? next : blocks;
|
|
214
|
+
}
|
package/src/styles.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { Block, Blocks, BlockFormat, NamedStyle, OutlineLevel } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Named-style resolution.
|
|
5
|
+
*
|
|
6
|
+
* Word's model, reproduced exactly: a paragraph's effective formatting is its style's
|
|
7
|
+
* formatting, merged with the style that style is based on, and so on to the root, with
|
|
8
|
+
* the paragraph's own direct formatting winning over all of it. Getting the precedence
|
|
9
|
+
* backwards is invisible on most documents and catastrophic on the one where an author
|
|
10
|
+
* overrode a heading's spacing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Depth limit for `basedOn` walking. Real Word documents contain cycles. */
|
|
14
|
+
const MAX_CHAIN = 32;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The chain from `styleId` to its root, nearest first.
|
|
18
|
+
*
|
|
19
|
+
* Cycles are broken rather than trusted not to exist: `w:basedOn` pointing back into its
|
|
20
|
+
* own ancestry is a documented corruption in files produced by older converters, and a
|
|
21
|
+
* naive walk hangs on it.
|
|
22
|
+
*/
|
|
23
|
+
export function styleChain(
|
|
24
|
+
styleId: string | undefined,
|
|
25
|
+
styles: readonly NamedStyle[] | undefined,
|
|
26
|
+
): NamedStyle[] {
|
|
27
|
+
if (!styleId || !styles || styles.length === 0) return [];
|
|
28
|
+
const byId = new Map(styles.map((style) => [style.id, style]));
|
|
29
|
+
const chain: NamedStyle[] = [];
|
|
30
|
+
const seen = new Set<string>();
|
|
31
|
+
|
|
32
|
+
let current = byId.get(styleId);
|
|
33
|
+
while (current && !seen.has(current.id) && chain.length < MAX_CHAIN) {
|
|
34
|
+
seen.add(current.id);
|
|
35
|
+
chain.push(current);
|
|
36
|
+
current = current.basedOn ? byId.get(current.basedOn) : undefined;
|
|
37
|
+
}
|
|
38
|
+
return chain;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A block's effective paragraph formatting: the style chain, then direct overrides.
|
|
43
|
+
*
|
|
44
|
+
* Merged field by field rather than by object spread of the whole chain, because a style
|
|
45
|
+
* that sets only `spaceAfterTwips` must not blank out an ancestor's `align`.
|
|
46
|
+
*/
|
|
47
|
+
export function effectiveFormat(
|
|
48
|
+
block: Block<unknown>,
|
|
49
|
+
styles: readonly NamedStyle[] | undefined,
|
|
50
|
+
): BlockFormat {
|
|
51
|
+
const chain = styleChain(block.styleId, styles);
|
|
52
|
+
let out: BlockFormat = {};
|
|
53
|
+
// Root first, so a nearer style overrides a more distant one.
|
|
54
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
55
|
+
out = { ...out, ...definedOnly(chain[i]!.format) };
|
|
56
|
+
}
|
|
57
|
+
return { ...out, ...definedOnly(block.format) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Run-level defaults a style contributes, resolved down its chain. */
|
|
61
|
+
export function effectiveRunStyle(
|
|
62
|
+
styleId: string | undefined,
|
|
63
|
+
styles: readonly NamedStyle[] | undefined,
|
|
64
|
+
): { font?: string; sizeHalfPt?: number; bold?: boolean; italic?: boolean; color?: string } {
|
|
65
|
+
const chain = styleChain(styleId, styles);
|
|
66
|
+
let out: Record<string, unknown> = {};
|
|
67
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
68
|
+
const style = chain[i]!;
|
|
69
|
+
out = {
|
|
70
|
+
...out,
|
|
71
|
+
...definedOnly({
|
|
72
|
+
font: style.font,
|
|
73
|
+
sizeHalfPt: style.sizeHalfPt,
|
|
74
|
+
bold: style.bold,
|
|
75
|
+
italic: style.italic,
|
|
76
|
+
color: style.color,
|
|
77
|
+
}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The outline level a style implies, from the nearest ancestor that states one.
|
|
85
|
+
*
|
|
86
|
+
* Returns `undefined` rather than 0 when nothing in the chain says: 0 is body text, a
|
|
87
|
+
* real answer, and defaulting to it would silently demote every heading whose style table
|
|
88
|
+
* failed to load.
|
|
89
|
+
*/
|
|
90
|
+
export function styleOutlineLevel(
|
|
91
|
+
styleId: string | undefined,
|
|
92
|
+
styles: readonly NamedStyle[] | undefined,
|
|
93
|
+
): OutlineLevel | undefined {
|
|
94
|
+
for (const style of styleChain(styleId, styles)) {
|
|
95
|
+
if (style.outlineLevel !== undefined) return style.outlineLevel;
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface StyleUsage {
|
|
101
|
+
readonly style: NamedStyle;
|
|
102
|
+
/** Blocks referencing this style directly. */
|
|
103
|
+
readonly count: number;
|
|
104
|
+
/** Of those, how many also carry direct formatting that overrides it. */
|
|
105
|
+
readonly overridden: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* How many blocks each style governs.
|
|
110
|
+
*
|
|
111
|
+
* The number is what makes "redefine this style across the document" a decision rather
|
|
112
|
+
* than a gamble — "this changes 412 paragraphs" and "this changes 2" are different
|
|
113
|
+
* actions, and the UI cannot tell them apart without counting.
|
|
114
|
+
*/
|
|
115
|
+
export function styleUsage(
|
|
116
|
+
blocks: Blocks<unknown>,
|
|
117
|
+
styles: readonly NamedStyle[] | undefined,
|
|
118
|
+
): StyleUsage[] {
|
|
119
|
+
if (!styles || styles.length === 0) return [];
|
|
120
|
+
const counts = new Map<string, { count: number; overridden: number }>();
|
|
121
|
+
|
|
122
|
+
for (const block of blocks) {
|
|
123
|
+
if (!block.styleId) continue;
|
|
124
|
+
const entry = counts.get(block.styleId) ?? { count: 0, overridden: 0 };
|
|
125
|
+
entry.count += 1;
|
|
126
|
+
if (block.format && Object.keys(definedOnly(block.format)).length > 0) entry.overridden += 1;
|
|
127
|
+
counts.set(block.styleId, entry);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return styles
|
|
131
|
+
.map((style) => ({
|
|
132
|
+
style,
|
|
133
|
+
count: counts.get(style.id)?.count ?? 0,
|
|
134
|
+
overridden: counts.get(style.id)?.overridden ?? 0,
|
|
135
|
+
}))
|
|
136
|
+
// Used styles first, then alphabetically — an unused style is still worth listing,
|
|
137
|
+
// because it is what an author reaches for next.
|
|
138
|
+
.sort((a, b) => b.count - a.count || a.style.name.localeCompare(b.style.name));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Redefine a style, and report what it touched.
|
|
143
|
+
*
|
|
144
|
+
* Returns the SAME document reference when nothing changed, as every structural op in
|
|
145
|
+
* this project does, so downstream memoization holds.
|
|
146
|
+
*/
|
|
147
|
+
export function redefineStyle(
|
|
148
|
+
styles: readonly NamedStyle[] | undefined,
|
|
149
|
+
styleId: string,
|
|
150
|
+
patch: Partial<Omit<NamedStyle, 'id'>>,
|
|
151
|
+
): readonly NamedStyle[] | undefined {
|
|
152
|
+
if (!styles) return styles;
|
|
153
|
+
let changed = false;
|
|
154
|
+
const next = styles.map((style) => {
|
|
155
|
+
if (style.id !== styleId) return style;
|
|
156
|
+
const merged = { ...style, ...definedOnly(patch), format: { ...style.format, ...definedOnly(patch.format) } };
|
|
157
|
+
if (shallowEqual(merged, style) && shallowEqual(merged.format ?? {}, style.format ?? {})) {
|
|
158
|
+
return style;
|
|
159
|
+
}
|
|
160
|
+
changed = true;
|
|
161
|
+
return merged;
|
|
162
|
+
});
|
|
163
|
+
return changed ? next : styles;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Drop direct formatting from every block using a style, so the style governs again.
|
|
168
|
+
*
|
|
169
|
+
* This is what "reset to the style" means in Word, and it is the other half of redefining
|
|
170
|
+
* one: a redefinition has no visible effect on paragraphs whose direct formatting already
|
|
171
|
+
* overrides the field being changed.
|
|
172
|
+
*/
|
|
173
|
+
export function clearDirectFormat<C>(blocks: Blocks<C>, styleId: string): Blocks<C> {
|
|
174
|
+
let changed = false;
|
|
175
|
+
const next = blocks.map((block) => {
|
|
176
|
+
if (block.styleId !== styleId || !block.format) return block;
|
|
177
|
+
changed = true;
|
|
178
|
+
const { format: _dropped, ...rest } = block;
|
|
179
|
+
return rest as Block<C>;
|
|
180
|
+
});
|
|
181
|
+
return changed ? next : blocks;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Strip `undefined` values so a spread does not blank out an inherited field. */
|
|
185
|
+
function definedOnly<T extends object>(value: T | undefined): Partial<T> {
|
|
186
|
+
if (!value) return {};
|
|
187
|
+
const out: Record<string, unknown> = {};
|
|
188
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
189
|
+
if (entry !== undefined) out[key] = entry;
|
|
190
|
+
}
|
|
191
|
+
return out as Partial<T>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function shallowEqual(a: object, b: object): boolean {
|
|
195
|
+
const keysA = Object.keys(a);
|
|
196
|
+
const keysB = Object.keys(b);
|
|
197
|
+
if (keysA.length !== keysB.length) return false;
|
|
198
|
+
return keysA.every(
|
|
199
|
+
(key) => (a as Record<string, unknown>)[key] === (b as Record<string, unknown>)[key],
|
|
200
|
+
);
|
|
201
|
+
}
|