@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
@@ -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
+ }