@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.
- package/CHANGELOG.md +108 -70
- package/LICENSE +21 -21
- package/README.md +286 -247
- package/dist/canvas/box-clipboard.js +238 -238
- package/dist/canvas/box-selection.js +80 -80
- package/dist/canvas/canvas-editor.js +502 -502
- package/dist/canvas/controller.js +717 -717
- package/dist/canvas/geometry.js +131 -131
- package/dist/canvas/gesture.d.ts +0 -1
- package/dist/canvas/gesture.js +289 -289
- package/dist/canvas/handles.js +212 -212
- package/dist/canvas/menu.js +82 -82
- package/dist/canvas/mode-controller.js +81 -81
- package/dist/canvas/nudge.js +108 -108
- package/dist/canvas/overlay.js +149 -149
- package/dist/canvas/snap.js +132 -132
- package/dist/canvas/viewport.js +112 -112
- package/dist/canvas/z-order.js +171 -171
- package/dist/commands/alignment.js +150 -150
- package/dist/commands/box.js +272 -272
- package/dist/commands/clipboard.js +93 -93
- package/dist/commands/graphemes.js +112 -112
- package/dist/commands/marks.js +153 -153
- package/dist/commands/paragraph-props.js +176 -176
- package/dist/commands/size.js +368 -368
- package/dist/commands/text.js +291 -291
- package/dist/docx.d.ts +1 -0
- package/dist/docx.js +15 -13
- package/dist/editor/editing-host.js +434 -434
- package/dist/element.js +41 -41
- package/dist/fonts/OFL-Carlito.txt +94 -0
- package/dist/fonts/calibri-metrics.d.ts +27 -0
- package/dist/fonts/calibri-metrics.js +88 -0
- package/dist/history/history.js +433 -433
- package/dist/index.js +63 -63
- package/dist/input/beforeinput.js +224 -224
- package/dist/input/clipboard.js +107 -107
- package/dist/input/composition.js +341 -341
- package/dist/input/keymap.js +54 -54
- package/dist/io/ooxml/custgeom.js +72 -72
- package/dist/io/ooxml/drawingml.js +232 -232
- package/dist/io/ooxml/edits.js +67 -67
- package/dist/io/ooxml/export-splice.js +880 -880
- package/dist/io/ooxml/export.js +365 -365
- package/dist/io/ooxml/fill.d.ts +93 -8
- package/dist/io/ooxml/fill.js +618 -562
- package/dist/io/ooxml/fit.d.ts +252 -0
- package/dist/io/ooxml/fit.js +598 -0
- package/dist/io/ooxml/import-gate.js +200 -200
- package/dist/io/ooxml/import.js +691 -691
- package/dist/io/ooxml/inflate.js +126 -126
- package/dist/io/ooxml/media.js +87 -87
- package/dist/io/ooxml/ns.d.ts +2 -0
- package/dist/io/ooxml/ns.js +26 -21
- package/dist/io/ooxml/numbering.js +290 -290
- package/dist/io/ooxml/opc-write.js +171 -171
- package/dist/io/ooxml/provenance.js +93 -93
- package/dist/io/ooxml/recipe.js +708 -708
- package/dist/io/ooxml/relationships.js +59 -59
- package/dist/io/ooxml/run-fonts.d.ts +89 -0
- package/dist/io/ooxml/run-fonts.js +378 -0
- package/dist/io/ooxml/slots.d.ts +4 -0
- package/dist/io/ooxml/slots.js +582 -581
- package/dist/io/ooxml/styles-resolve.js +257 -257
- package/dist/io/ooxml/units.js +43 -43
- package/dist/io/ooxml/value.js +120 -120
- package/dist/io/ooxml/xml-scan.js +616 -616
- package/dist/io/ooxml/zip-container.js +228 -228
- package/dist/io/ooxml/zip-reject.js +106 -106
- package/dist/measure/placement.js +83 -83
- package/dist/measure/report.js +75 -75
- package/dist/model/ids.js +70 -70
- package/dist/model/normalize.js +146 -146
- package/dist/model/position.js +283 -283
- package/dist/model/transaction.js +209 -209
- package/dist/platform/capabilities.js +25 -25
- package/dist/react/index.js +21 -21
- package/dist/selection/controller.js +315 -315
- package/dist/selection/dom-map.js +334 -334
- package/dist/state/editor-state.js +109 -109
- package/dist/styles.css +66 -66
- package/package.json +94 -93
package/dist/commands/marks.js
CHANGED
|
@@ -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
|
+
}
|