@sametalmdr/word-editor 0.2.0-alpha → 0.3.0-alpha

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 (82) hide show
  1. package/CHANGELOG.md +108 -70
  2. package/LICENSE +21 -21
  3. package/README.md +286 -247
  4. package/dist/canvas/box-clipboard.js +238 -238
  5. package/dist/canvas/box-selection.js +80 -80
  6. package/dist/canvas/canvas-editor.js +502 -502
  7. package/dist/canvas/controller.js +717 -717
  8. package/dist/canvas/geometry.js +131 -131
  9. package/dist/canvas/gesture.d.ts +0 -1
  10. package/dist/canvas/gesture.js +289 -289
  11. package/dist/canvas/handles.js +212 -212
  12. package/dist/canvas/menu.js +82 -82
  13. package/dist/canvas/mode-controller.js +81 -81
  14. package/dist/canvas/nudge.js +108 -108
  15. package/dist/canvas/overlay.js +149 -149
  16. package/dist/canvas/snap.js +132 -132
  17. package/dist/canvas/viewport.js +112 -112
  18. package/dist/canvas/z-order.js +171 -171
  19. package/dist/commands/alignment.js +150 -150
  20. package/dist/commands/box.js +272 -272
  21. package/dist/commands/clipboard.js +93 -93
  22. package/dist/commands/graphemes.js +112 -112
  23. package/dist/commands/marks.js +153 -153
  24. package/dist/commands/paragraph-props.js +176 -176
  25. package/dist/commands/size.js +368 -368
  26. package/dist/commands/text.js +291 -291
  27. package/dist/docx.d.ts +1 -0
  28. package/dist/docx.js +15 -13
  29. package/dist/editor/editing-host.js +434 -434
  30. package/dist/element.js +41 -41
  31. package/dist/fonts/OFL-Carlito.txt +94 -0
  32. package/dist/fonts/calibri-metrics.d.ts +27 -0
  33. package/dist/fonts/calibri-metrics.js +88 -0
  34. package/dist/history/history.js +433 -433
  35. package/dist/index.js +63 -63
  36. package/dist/input/beforeinput.js +224 -224
  37. package/dist/input/clipboard.js +107 -107
  38. package/dist/input/composition.js +341 -341
  39. package/dist/input/keymap.js +54 -54
  40. package/dist/io/ooxml/custgeom.js +72 -72
  41. package/dist/io/ooxml/drawingml.js +232 -232
  42. package/dist/io/ooxml/edits.js +67 -67
  43. package/dist/io/ooxml/export-splice.js +880 -880
  44. package/dist/io/ooxml/export.js +365 -365
  45. package/dist/io/ooxml/fill.d.ts +93 -8
  46. package/dist/io/ooxml/fill.js +618 -562
  47. package/dist/io/ooxml/fit.d.ts +252 -0
  48. package/dist/io/ooxml/fit.js +598 -0
  49. package/dist/io/ooxml/import-gate.js +200 -200
  50. package/dist/io/ooxml/import.js +691 -691
  51. package/dist/io/ooxml/inflate.js +126 -126
  52. package/dist/io/ooxml/media.js +87 -87
  53. package/dist/io/ooxml/ns.d.ts +2 -0
  54. package/dist/io/ooxml/ns.js +26 -21
  55. package/dist/io/ooxml/numbering.js +290 -290
  56. package/dist/io/ooxml/opc-write.js +171 -171
  57. package/dist/io/ooxml/provenance.js +93 -93
  58. package/dist/io/ooxml/recipe.js +708 -708
  59. package/dist/io/ooxml/relationships.js +59 -59
  60. package/dist/io/ooxml/run-fonts.d.ts +89 -0
  61. package/dist/io/ooxml/run-fonts.js +378 -0
  62. package/dist/io/ooxml/slots.d.ts +4 -0
  63. package/dist/io/ooxml/slots.js +582 -581
  64. package/dist/io/ooxml/styles-resolve.js +257 -257
  65. package/dist/io/ooxml/units.js +43 -43
  66. package/dist/io/ooxml/value.js +120 -120
  67. package/dist/io/ooxml/xml-scan.js +616 -616
  68. package/dist/io/ooxml/zip-container.js +228 -228
  69. package/dist/io/ooxml/zip-reject.js +106 -106
  70. package/dist/measure/placement.js +83 -83
  71. package/dist/measure/report.js +75 -75
  72. package/dist/model/ids.js +70 -70
  73. package/dist/model/normalize.js +146 -146
  74. package/dist/model/position.js +283 -283
  75. package/dist/model/transaction.js +209 -209
  76. package/dist/platform/capabilities.js +25 -25
  77. package/dist/react/index.js +21 -21
  78. package/dist/selection/controller.js +315 -315
  79. package/dist/selection/dom-map.js +334 -334
  80. package/dist/state/editor-state.js +109 -109
  81. package/dist/styles.css +66 -66
  82. package/package.json +94 -93
@@ -1,153 +1,153 @@
1
- import { EMPTY_MARKS, locateParagraph, paragraphLength, withMark } from "../model/node.js";
2
- import { isCollapsed, paragraphIdOf, selectionRange } from "../model/position.js";
3
- import { rangeMarkState, sliceRuns } from "../model/runs.js";
4
- import { setMarkStep } from "../model/step.js";
5
- import { createTransaction } from "../model/transaction.js";
6
-
7
- /**
8
- * Marks a collapsed cursor inherits.
9
- *
10
- * **Left-biased**: the marks of the character *before* the cursor, falling back
11
- * to the character after it at offset 0. Typing right after bold text has to
12
- * continue bold — a right-biased rule would break that, which is the behaviour
13
- * users notice immediately.
14
- *
15
- * @param {import("../model/node.js").Doc} doc
16
- * @param {import("../model/position.js").Position} pos
17
- * @returns {import("../model/node.js").Marks}
18
- */
19
- export function marksAt(doc, pos) {
20
- const at = locateParagraph(doc, paragraphIdOf(pos));
21
- if (at === null) {
22
- return EMPTY_MARKS;
23
- }
24
- const runs = at.para.content;
25
-
26
- if (pos.offset > 0) {
27
- const before = sliceRuns(runs, pos.offset - 1, pos.offset);
28
- if (before.length > 0) {
29
- return before[0].marks;
30
- }
31
- }
32
-
33
- const after = sliceRuns(runs, pos.offset, pos.offset + 1);
34
- if (after.length > 0) {
35
- return after[0].marks;
36
- }
37
-
38
- // Empty paragraph: the sole empty run still carries marks (see `normalize`).
39
- return runs.length > 0 ? runs[0].marks : EMPTY_MARKS;
40
- }
41
-
42
- /**
43
- * The marks that apply to what the user would type right now — what a toolbar
44
- * button should light up for.
45
- *
46
- * For a range, reports only marks carried by **every** character, which is the
47
- * same all-or-nothing rule `toggleMark` uses. So a lit button always means "press
48
- * to remove".
49
- *
50
- * @param {import("../state/editor-state.js").EditorState} state
51
- * @returns {import("../model/node.js").Marks}
52
- */
53
- export function marksAtSelection(state) {
54
- const { doc, selection } = state;
55
-
56
- if (isCollapsed(selection)) {
57
- return state.storedMarks ?? marksAt(doc, selection.head);
58
- }
59
-
60
- /** @type {Record<string, true>} */
61
- const common = {};
62
- for (const name of /** @type {import("../model/node.js").MarkName[]} */ ([
63
- "bold",
64
- "italic",
65
- "underline",
66
- ])) {
67
- if (rangeHasMarkThroughout(state, name)) {
68
- common[name] = true;
69
- }
70
- }
71
- return common;
72
- }
73
-
74
- /**
75
- * @param {import("../state/editor-state.js").EditorState} state
76
- * @param {import("../model/node.js").MarkName} mark
77
- * @returns {boolean}
78
- */
79
- function rangeHasMarkThroughout(state, mark) {
80
- const { doc, selection } = state;
81
- const range = selectionRange(doc, selection);
82
- if (range === null) {
83
- return false;
84
- }
85
-
86
- const fromAt = locateParagraph(doc, paragraphIdOf(range.from));
87
- const toAt = locateParagraph(doc, paragraphIdOf(range.to));
88
- // A mark range never crosses containers (ADR-0007), so the whole walk stays
89
- // inside one container's paragraph list.
90
- if (fromAt === null || toAt === null || fromAt.containerIndex !== toAt.containerIndex) {
91
- return false;
92
- }
93
-
94
- let sawAny = false;
95
- for (let i = fromAt.paraIndex; i <= toAt.paraIndex; i += 1) {
96
- const para = fromAt.container.content[i];
97
- const start = i === fromAt.paraIndex ? range.from.offset : 0;
98
- const end = i === toAt.paraIndex ? range.to.offset : paragraphLength(para);
99
- if (end <= start) {
100
- continue;
101
- }
102
- if (rangeMarkState(para.content, start, end, mark) !== "all") {
103
- return false;
104
- }
105
- sawAny = true;
106
- }
107
- return sawAny;
108
- }
109
-
110
- /**
111
- * Toggles a mark.
112
- *
113
- * **Collapsed selection:** changes no document content. It sets `storedMarks`
114
- * instead, so the next typed character comes out formatted. The resulting
115
- * transaction has zero steps, which keeps a single dispatch path while letting
116
- * `HistoryEngine` skip it — pressing Ctrl+B then Ctrl+Z undoes the previous real
117
- * edit, not the pending format.
118
- *
119
- * **Range selection:** all-or-nothing. The mark is removed only when *every*
120
- * character already carries it; otherwise it is applied to the whole range. A
121
- * partially bold selection therefore goes fully bold on the first press.
122
- *
123
- * @param {import("../state/editor-state.js").EditorState} state
124
- * @param {import("../model/node.js").MarkName} mark
125
- * @returns {import("../model/transaction.js").Transaction | null}
126
- */
127
- export function toggleMark(state, mark) {
128
- const { doc, selection } = state;
129
-
130
- if (isCollapsed(selection)) {
131
- const current = state.storedMarks ?? marksAt(doc, selection.head);
132
- const tr = createTransaction(doc, selection, "format");
133
- tr.setStoredMarks(withMark(current, mark, current[mark] !== true));
134
- return tr.build();
135
- }
136
-
137
- const range = selectionRange(doc, selection);
138
- if (range === null) {
139
- return null;
140
- }
141
-
142
- const value = !rangeHasMarkThroughout(state, mark);
143
- const tr = createTransaction(doc, selection, "format");
144
- tr.step(setMarkStep(range.from, range.to, mark, value));
145
- if (!tr.changesDoc) {
146
- return null;
147
- }
148
-
149
- // Marks never move characters, so the selection is preserved as-is.
150
- tr.setSelection(selection);
151
- tr.setStoredMarks(null);
152
- return tr.build();
153
- }
1
+ import { EMPTY_MARKS, locateParagraph, paragraphLength, withMark } from "../model/node.js";
2
+ import { isCollapsed, paragraphIdOf, selectionRange } from "../model/position.js";
3
+ import { rangeMarkState, sliceRuns } from "../model/runs.js";
4
+ import { setMarkStep } from "../model/step.js";
5
+ import { createTransaction } from "../model/transaction.js";
6
+
7
+ /**
8
+ * Marks a collapsed cursor inherits.
9
+ *
10
+ * **Left-biased**: the marks of the character *before* the cursor, falling back
11
+ * to the character after it at offset 0. Typing right after bold text has to
12
+ * continue bold — a right-biased rule would break that, which is the behaviour
13
+ * users notice immediately.
14
+ *
15
+ * @param {import("../model/node.js").Doc} doc
16
+ * @param {import("../model/position.js").Position} pos
17
+ * @returns {import("../model/node.js").Marks}
18
+ */
19
+ export function marksAt(doc, pos) {
20
+ const at = locateParagraph(doc, paragraphIdOf(pos));
21
+ if (at === null) {
22
+ return EMPTY_MARKS;
23
+ }
24
+ const runs = at.para.content;
25
+
26
+ if (pos.offset > 0) {
27
+ const before = sliceRuns(runs, pos.offset - 1, pos.offset);
28
+ if (before.length > 0) {
29
+ return before[0].marks;
30
+ }
31
+ }
32
+
33
+ const after = sliceRuns(runs, pos.offset, pos.offset + 1);
34
+ if (after.length > 0) {
35
+ return after[0].marks;
36
+ }
37
+
38
+ // Empty paragraph: the sole empty run still carries marks (see `normalize`).
39
+ return runs.length > 0 ? runs[0].marks : EMPTY_MARKS;
40
+ }
41
+
42
+ /**
43
+ * The marks that apply to what the user would type right now — what a toolbar
44
+ * button should light up for.
45
+ *
46
+ * For a range, reports only marks carried by **every** character, which is the
47
+ * same all-or-nothing rule `toggleMark` uses. So a lit button always means "press
48
+ * to remove".
49
+ *
50
+ * @param {import("../state/editor-state.js").EditorState} state
51
+ * @returns {import("../model/node.js").Marks}
52
+ */
53
+ export function marksAtSelection(state) {
54
+ const { doc, selection } = state;
55
+
56
+ if (isCollapsed(selection)) {
57
+ return state.storedMarks ?? marksAt(doc, selection.head);
58
+ }
59
+
60
+ /** @type {Record<string, true>} */
61
+ const common = {};
62
+ for (const name of /** @type {import("../model/node.js").MarkName[]} */ ([
63
+ "bold",
64
+ "italic",
65
+ "underline",
66
+ ])) {
67
+ if (rangeHasMarkThroughout(state, name)) {
68
+ common[name] = true;
69
+ }
70
+ }
71
+ return common;
72
+ }
73
+
74
+ /**
75
+ * @param {import("../state/editor-state.js").EditorState} state
76
+ * @param {import("../model/node.js").MarkName} mark
77
+ * @returns {boolean}
78
+ */
79
+ function rangeHasMarkThroughout(state, mark) {
80
+ const { doc, selection } = state;
81
+ const range = selectionRange(doc, selection);
82
+ if (range === null) {
83
+ return false;
84
+ }
85
+
86
+ const fromAt = locateParagraph(doc, paragraphIdOf(range.from));
87
+ const toAt = locateParagraph(doc, paragraphIdOf(range.to));
88
+ // A mark range never crosses containers (ADR-0007), so the whole walk stays
89
+ // inside one container's paragraph list.
90
+ if (fromAt === null || toAt === null || fromAt.containerIndex !== toAt.containerIndex) {
91
+ return false;
92
+ }
93
+
94
+ let sawAny = false;
95
+ for (let i = fromAt.paraIndex; i <= toAt.paraIndex; i += 1) {
96
+ const para = fromAt.container.content[i];
97
+ const start = i === fromAt.paraIndex ? range.from.offset : 0;
98
+ const end = i === toAt.paraIndex ? range.to.offset : paragraphLength(para);
99
+ if (end <= start) {
100
+ continue;
101
+ }
102
+ if (rangeMarkState(para.content, start, end, mark) !== "all") {
103
+ return false;
104
+ }
105
+ sawAny = true;
106
+ }
107
+ return sawAny;
108
+ }
109
+
110
+ /**
111
+ * Toggles a mark.
112
+ *
113
+ * **Collapsed selection:** changes no document content. It sets `storedMarks`
114
+ * instead, so the next typed character comes out formatted. The resulting
115
+ * transaction has zero steps, which keeps a single dispatch path while letting
116
+ * `HistoryEngine` skip it — pressing Ctrl+B then Ctrl+Z undoes the previous real
117
+ * edit, not the pending format.
118
+ *
119
+ * **Range selection:** all-or-nothing. The mark is removed only when *every*
120
+ * character already carries it; otherwise it is applied to the whole range. A
121
+ * partially bold selection therefore goes fully bold on the first press.
122
+ *
123
+ * @param {import("../state/editor-state.js").EditorState} state
124
+ * @param {import("../model/node.js").MarkName} mark
125
+ * @returns {import("../model/transaction.js").Transaction | null}
126
+ */
127
+ export function toggleMark(state, mark) {
128
+ const { doc, selection } = state;
129
+
130
+ if (isCollapsed(selection)) {
131
+ const current = state.storedMarks ?? marksAt(doc, selection.head);
132
+ const tr = createTransaction(doc, selection, "format");
133
+ tr.setStoredMarks(withMark(current, mark, current[mark] !== true));
134
+ return tr.build();
135
+ }
136
+
137
+ const range = selectionRange(doc, selection);
138
+ if (range === null) {
139
+ return null;
140
+ }
141
+
142
+ const value = !rangeHasMarkThroughout(state, mark);
143
+ const tr = createTransaction(doc, selection, "format");
144
+ tr.step(setMarkStep(range.from, range.to, mark, value));
145
+ if (!tr.changesDoc) {
146
+ return null;
147
+ }
148
+
149
+ // Marks never move characters, so the selection is preserved as-is.
150
+ tr.setSelection(selection);
151
+ tr.setStoredMarks(null);
152
+ return tr.build();
153
+ }
@@ -1,176 +1,176 @@
1
- import { locateParagraph } from "../model/node.js";
2
- import { paragraphIdOf, selectionRange } from "../model/position.js";
3
- import { setParagraphPropsStep } from "../model/step.js";
4
- import { createTransaction } from "../model/transaction.js";
5
-
6
- // ADR-0030: paragraph indentation is editable through a DIRECT
7
- // `Paragraph.pPrOverride` record — never by mutating `Doc.styles`, for the
8
- // same measured reason ADR-0029 gave for `jc`: 70.5% of real corpus
9
- // paragraphs share a `pPrRef` with at least one other (worst case 471 on one
10
- // ref), so writing the styles table would silently re-indent unrelated
11
- // paragraphs. This module is `commands/alignment.js`'s sibling; the only
12
- // structural difference is that a patch names KEYS, because a paragraph can
13
- // carry four independent indent fields at once.
14
-
15
- /**
16
- * The `pPrOverride` keys this UI writes. Deliberately not everything
17
- * `readPPr` produces — `spacing.*` and `numPr.*` are read and rendered
18
- * correctly but nothing here writes them, so nothing here claims to. The
19
- * export side names the same set (`WRITABLE_PPR_KEYS`) and a gate test
20
- * partitions it against `readPPr`'s own output.
21
- * @type {readonly string[]}
22
- */
23
- export const INDENT_KEYS = /** @type {const} */ ([
24
- "ind.left",
25
- "ind.right",
26
- "ind.firstLine",
27
- "ind.hanging",
28
- ]);
29
-
30
- /**
31
- * OOXML measures `w:ind` in twips (twentieths of a point). The UI shows
32
- * centimetres, so the conversion lives at that boundary — NOT here, and not in
33
- * the model: the model stays in OOXML units end to end, the same discipline
34
- * that keeps `BoxLayout.x` in points until `ptToEmu` at the export seam.
35
- */
36
- export const TWIPS_PER_CM = 567;
37
-
38
- /**
39
- * The effective value of one indent key at a paragraph: its own direct
40
- * override, else the import-resolved value for its `pPrRef`, else absent.
41
- *
42
- * Unlike `paragraphAlignment` there is no docDefaults last resort and no
43
- * hardcoded fallback: OOXML's default indent is the ABSENCE of the attribute,
44
- * which the renderer already handles by writing no padding at all. Inventing
45
- * a 0 here would turn "inherited nothing" into "explicitly zero" — a real
46
- * difference on export.
47
- * @param {import("../model/node.js").Doc} doc
48
- * @param {import("../model/node.js").Paragraph} para
49
- * @param {string} key one of `INDENT_KEYS`
50
- * @returns {number | null} null when nothing in the chain declares one
51
- */
52
- export function paragraphIndent(doc, para, key) {
53
- const own = para.pPrOverride?.[key];
54
- if (typeof own === "number") {
55
- return own;
56
- }
57
- if (para.pPrRef !== undefined) {
58
- // `Doc.styles` holds raw XML attribute STRINGS (readPPr's convention),
59
- // while an override holds numbers — the same duality `applyRunStyle`'s
60
- // `toNumber` absorbs for `sz` (ADR-0028).
61
- const resolved = doc.styles[para.pPrRef]?.[key];
62
- const parsed = typeof resolved === "string" ? Number.parseInt(resolved, 10) : resolved;
63
- if (typeof parsed === "number" && Number.isFinite(parsed)) {
64
- return parsed;
65
- }
66
- }
67
- return null;
68
- }
69
-
70
- /**
71
- * The value of one indent key shared by EVERY paragraph the selection touches,
72
- * or null when they disagree — the same all-or-nothing shape
73
- * `alignmentAtSelection`/`marksAtSelection` use, so a populated input always
74
- * means "every selected paragraph is already this" and a blank one means
75
- * "mixed", never "zero".
76
- * @param {import("../state/editor-state.js").EditorState} state
77
- * @param {string} key
78
- * @returns {number | null}
79
- */
80
- export function indentAtSelection(state, key) {
81
- const paragraphs = selectedParagraphs(state);
82
- if (paragraphs === null || paragraphs.length === 0) {
83
- return null;
84
- }
85
- const first = paragraphIndent(state.doc, paragraphs[0].para, key);
86
- for (const entry of paragraphs) {
87
- if (paragraphIndent(state.doc, entry.para, key) !== first) {
88
- return null; // mixed — the input goes blank
89
- }
90
- }
91
- return first;
92
- }
93
-
94
- /**
95
- * Every paragraph the selection touches, in document order. Identical to
96
- * `commands/alignment.js`'s own — a collapsed caret selects the ONE paragraph
97
- * it sits in, because a paragraph-level change has an unambiguous target even
98
- * with no selection, so there is no `storedMarks`-style pending state here.
99
- * @param {import("../state/editor-state.js").EditorState} state
100
- * @returns {{container: import("../model/node.js").Container, para: import("../model/node.js").Paragraph, paraIndex: number}[] | null}
101
- */
102
- function selectedParagraphs(state) {
103
- const { doc, selection } = state;
104
- const range = selectionRange(doc, selection) ?? { from: selection.head, to: selection.head };
105
-
106
- const fromAt = locateParagraph(doc, paragraphIdOf(range.from));
107
- const toAt = locateParagraph(doc, paragraphIdOf(range.to));
108
- // A selection never spans containers (ADR-0007), same rule marks follow.
109
- if (fromAt === null || toAt === null || fromAt.containerIndex !== toAt.containerIndex) {
110
- return null;
111
- }
112
-
113
- const out = [];
114
- for (let i = fromAt.paraIndex; i <= toAt.paraIndex; i += 1) {
115
- out.push({ container: fromAt.container, para: fromAt.container.content[i], paraIndex: i });
116
- }
117
- return out;
118
- }
119
-
120
- /**
121
- * Patches paragraph-level properties on every paragraph the selection touches.
122
- *
123
- * A key mapped to `undefined` clears that override; a key simply absent from
124
- * `patch` is left alone. Returns null when nothing would change, so a
125
- * redundant edit never reaches the history stack — `setAlignment`'s own rule.
126
- *
127
- * @param {import("../state/editor-state.js").EditorState} state
128
- * @param {Record<string, number | undefined>} patch keys from `INDENT_KEYS`
129
- * @returns {import("../model/transaction.js").Transaction | null}
130
- */
131
- export function setParagraphProps(state, patch) {
132
- const { doc, selection } = state;
133
- const paragraphs = selectedParagraphs(state);
134
- if (paragraphs === null || paragraphs.length === 0) {
135
- return null;
136
- }
137
-
138
- const entries = paragraphs
139
- .filter((entry) => !patchIsNoOp(entry.para, patch))
140
- .map((entry) => ({ containerId: entry.container.id, paragraphId: entry.para.id, patch }));
141
- if (entries.length === 0) {
142
- return null;
143
- }
144
-
145
- const tr = createTransaction(doc, selection, "format");
146
- tr.step(setParagraphPropsStep(entries));
147
- if (!tr.changesDoc) {
148
- return null;
149
- }
150
-
151
- // Indentation never moves characters, so the selection is preserved as-is.
152
- tr.setSelection(selection);
153
- tr.setStoredMarks(null);
154
- return tr.build();
155
- }
156
-
157
- /**
158
- * Whether applying `patch` to this paragraph's OWN override record would
159
- * change nothing. Deliberately compares against the paragraph's own override,
160
- * not its effective value: clearing a key whose inherited value happens to
161
- * equal the override is a real change (it hands the field back to the style
162
- * chain), and treating it as a no-op would make that un-doable.
163
- * @param {import("../model/node.js").Paragraph} para
164
- * @param {Record<string, number | undefined>} patch
165
- * @returns {boolean}
166
- */
167
- function patchIsNoOp(para, patch) {
168
- const own = para.pPrOverride ?? {};
169
- for (const [key, value] of Object.entries(patch)) {
170
- const before = key in own ? own[key] : undefined;
171
- if (before !== value) {
172
- return false;
173
- }
174
- }
175
- return true;
176
- }
1
+ import { locateParagraph } from "../model/node.js";
2
+ import { paragraphIdOf, selectionRange } from "../model/position.js";
3
+ import { setParagraphPropsStep } from "../model/step.js";
4
+ import { createTransaction } from "../model/transaction.js";
5
+
6
+ // ADR-0030: paragraph indentation is editable through a DIRECT
7
+ // `Paragraph.pPrOverride` record — never by mutating `Doc.styles`, for the
8
+ // same measured reason ADR-0029 gave for `jc`: 70.5% of real corpus
9
+ // paragraphs share a `pPrRef` with at least one other (worst case 471 on one
10
+ // ref), so writing the styles table would silently re-indent unrelated
11
+ // paragraphs. This module is `commands/alignment.js`'s sibling; the only
12
+ // structural difference is that a patch names KEYS, because a paragraph can
13
+ // carry four independent indent fields at once.
14
+
15
+ /**
16
+ * The `pPrOverride` keys this UI writes. Deliberately not everything
17
+ * `readPPr` produces — `spacing.*` and `numPr.*` are read and rendered
18
+ * correctly but nothing here writes them, so nothing here claims to. The
19
+ * export side names the same set (`WRITABLE_PPR_KEYS`) and a gate test
20
+ * partitions it against `readPPr`'s own output.
21
+ * @type {readonly string[]}
22
+ */
23
+ export const INDENT_KEYS = /** @type {const} */ ([
24
+ "ind.left",
25
+ "ind.right",
26
+ "ind.firstLine",
27
+ "ind.hanging",
28
+ ]);
29
+
30
+ /**
31
+ * OOXML measures `w:ind` in twips (twentieths of a point). The UI shows
32
+ * centimetres, so the conversion lives at that boundary — NOT here, and not in
33
+ * the model: the model stays in OOXML units end to end, the same discipline
34
+ * that keeps `BoxLayout.x` in points until `ptToEmu` at the export seam.
35
+ */
36
+ export const TWIPS_PER_CM = 567;
37
+
38
+ /**
39
+ * The effective value of one indent key at a paragraph: its own direct
40
+ * override, else the import-resolved value for its `pPrRef`, else absent.
41
+ *
42
+ * Unlike `paragraphAlignment` there is no docDefaults last resort and no
43
+ * hardcoded fallback: OOXML's default indent is the ABSENCE of the attribute,
44
+ * which the renderer already handles by writing no padding at all. Inventing
45
+ * a 0 here would turn "inherited nothing" into "explicitly zero" — a real
46
+ * difference on export.
47
+ * @param {import("../model/node.js").Doc} doc
48
+ * @param {import("../model/node.js").Paragraph} para
49
+ * @param {string} key one of `INDENT_KEYS`
50
+ * @returns {number | null} null when nothing in the chain declares one
51
+ */
52
+ export function paragraphIndent(doc, para, key) {
53
+ const own = para.pPrOverride?.[key];
54
+ if (typeof own === "number") {
55
+ return own;
56
+ }
57
+ if (para.pPrRef !== undefined) {
58
+ // `Doc.styles` holds raw XML attribute STRINGS (readPPr's convention),
59
+ // while an override holds numbers — the same duality `applyRunStyle`'s
60
+ // `toNumber` absorbs for `sz` (ADR-0028).
61
+ const resolved = doc.styles[para.pPrRef]?.[key];
62
+ const parsed = typeof resolved === "string" ? Number.parseInt(resolved, 10) : resolved;
63
+ if (typeof parsed === "number" && Number.isFinite(parsed)) {
64
+ return parsed;
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * The value of one indent key shared by EVERY paragraph the selection touches,
72
+ * or null when they disagree — the same all-or-nothing shape
73
+ * `alignmentAtSelection`/`marksAtSelection` use, so a populated input always
74
+ * means "every selected paragraph is already this" and a blank one means
75
+ * "mixed", never "zero".
76
+ * @param {import("../state/editor-state.js").EditorState} state
77
+ * @param {string} key
78
+ * @returns {number | null}
79
+ */
80
+ export function indentAtSelection(state, key) {
81
+ const paragraphs = selectedParagraphs(state);
82
+ if (paragraphs === null || paragraphs.length === 0) {
83
+ return null;
84
+ }
85
+ const first = paragraphIndent(state.doc, paragraphs[0].para, key);
86
+ for (const entry of paragraphs) {
87
+ if (paragraphIndent(state.doc, entry.para, key) !== first) {
88
+ return null; // mixed — the input goes blank
89
+ }
90
+ }
91
+ return first;
92
+ }
93
+
94
+ /**
95
+ * Every paragraph the selection touches, in document order. Identical to
96
+ * `commands/alignment.js`'s own — a collapsed caret selects the ONE paragraph
97
+ * it sits in, because a paragraph-level change has an unambiguous target even
98
+ * with no selection, so there is no `storedMarks`-style pending state here.
99
+ * @param {import("../state/editor-state.js").EditorState} state
100
+ * @returns {{container: import("../model/node.js").Container, para: import("../model/node.js").Paragraph, paraIndex: number}[] | null}
101
+ */
102
+ function selectedParagraphs(state) {
103
+ const { doc, selection } = state;
104
+ const range = selectionRange(doc, selection) ?? { from: selection.head, to: selection.head };
105
+
106
+ const fromAt = locateParagraph(doc, paragraphIdOf(range.from));
107
+ const toAt = locateParagraph(doc, paragraphIdOf(range.to));
108
+ // A selection never spans containers (ADR-0007), same rule marks follow.
109
+ if (fromAt === null || toAt === null || fromAt.containerIndex !== toAt.containerIndex) {
110
+ return null;
111
+ }
112
+
113
+ const out = [];
114
+ for (let i = fromAt.paraIndex; i <= toAt.paraIndex; i += 1) {
115
+ out.push({ container: fromAt.container, para: fromAt.container.content[i], paraIndex: i });
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * Patches paragraph-level properties on every paragraph the selection touches.
122
+ *
123
+ * A key mapped to `undefined` clears that override; a key simply absent from
124
+ * `patch` is left alone. Returns null when nothing would change, so a
125
+ * redundant edit never reaches the history stack — `setAlignment`'s own rule.
126
+ *
127
+ * @param {import("../state/editor-state.js").EditorState} state
128
+ * @param {Record<string, number | undefined>} patch keys from `INDENT_KEYS`
129
+ * @returns {import("../model/transaction.js").Transaction | null}
130
+ */
131
+ export function setParagraphProps(state, patch) {
132
+ const { doc, selection } = state;
133
+ const paragraphs = selectedParagraphs(state);
134
+ if (paragraphs === null || paragraphs.length === 0) {
135
+ return null;
136
+ }
137
+
138
+ const entries = paragraphs
139
+ .filter((entry) => !patchIsNoOp(entry.para, patch))
140
+ .map((entry) => ({ containerId: entry.container.id, paragraphId: entry.para.id, patch }));
141
+ if (entries.length === 0) {
142
+ return null;
143
+ }
144
+
145
+ const tr = createTransaction(doc, selection, "format");
146
+ tr.step(setParagraphPropsStep(entries));
147
+ if (!tr.changesDoc) {
148
+ return null;
149
+ }
150
+
151
+ // Indentation never moves characters, so the selection is preserved as-is.
152
+ tr.setSelection(selection);
153
+ tr.setStoredMarks(null);
154
+ return tr.build();
155
+ }
156
+
157
+ /**
158
+ * Whether applying `patch` to this paragraph's OWN override record would
159
+ * change nothing. Deliberately compares against the paragraph's own override,
160
+ * not its effective value: clearing a key whose inherited value happens to
161
+ * equal the override is a real change (it hands the field back to the style
162
+ * chain), and treating it as a no-op would make that un-doable.
163
+ * @param {import("../model/node.js").Paragraph} para
164
+ * @param {Record<string, number | undefined>} patch
165
+ * @returns {boolean}
166
+ */
167
+ function patchIsNoOp(para, patch) {
168
+ const own = para.pPrOverride ?? {};
169
+ for (const [key, value] of Object.entries(patch)) {
170
+ const before = key in own ? own[key] : undefined;
171
+ if (before !== value) {
172
+ return false;
173
+ }
174
+ }
175
+ return true;
176
+ }