docxodus 6.3.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/docxodus.worker.js +13 -6
- package/dist/docxodus.worker.js.map +1 -1
- package/dist/editor.bundle.js +2143 -0
- package/dist/editor.d.ts +336 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +1525 -0
- package/dist/editor.js.map +1 -0
- package/dist/index.d.ts +130 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +336 -8
- package/dist/index.js.map +1 -1
- package/dist/session.bundle.js +674 -0
- package/dist/session.d.ts +72 -2
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +104 -0
- package/dist/session.js.map +1 -1
- package/dist/types.d.ts +326 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +67 -0
- package/dist/types.js.map +1 -1
- package/dist/wasm/_framework/DocumentFormat.OpenXml.Framework.wasm +0 -0
- package/dist/wasm/_framework/DocumentFormat.OpenXml.wasm +0 -0
- package/dist/wasm/_framework/Docxodus.wasm +0 -0
- package/dist/wasm/_framework/DocxodusWasm.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.Concurrent.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.Immutable.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.NonGeneric.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.Specialized.wasm +0 -0
- package/dist/wasm/_framework/System.Collections.wasm +0 -0
- package/dist/wasm/_framework/System.ComponentModel.Primitives.wasm +0 -0
- package/dist/wasm/_framework/System.ComponentModel.TypeConverter.wasm +0 -0
- package/dist/wasm/_framework/System.ComponentModel.wasm +0 -0
- package/dist/wasm/_framework/System.Console.wasm +0 -0
- package/dist/wasm/_framework/System.Diagnostics.Process.wasm +0 -0
- package/dist/wasm/_framework/System.IO.Compression.wasm +0 -0
- package/dist/wasm/_framework/System.IO.Packaging.wasm +0 -0
- package/dist/wasm/_framework/System.IO.Pipelines.wasm +0 -0
- package/dist/wasm/_framework/System.Linq.Expressions.wasm +0 -0
- package/dist/wasm/_framework/System.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Memory.wasm +0 -0
- package/dist/wasm/_framework/System.Net.Http.wasm +0 -0
- package/dist/wasm/_framework/System.Net.Primitives.wasm +0 -0
- package/dist/wasm/_framework/System.ObjectModel.wasm +0 -0
- package/dist/wasm/_framework/System.Private.CoreLib.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Uri.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Xml.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Private.Xml.wasm +0 -0
- package/dist/wasm/_framework/System.Runtime.InteropServices.JavaScript.wasm +0 -0
- package/dist/wasm/_framework/System.Runtime.wasm +0 -0
- package/dist/wasm/_framework/System.Security.Cryptography.wasm +0 -0
- package/dist/wasm/_framework/System.Text.Encoding.Extensions.wasm +0 -0
- package/dist/wasm/_framework/System.Text.Encodings.Web.wasm +0 -0
- package/dist/wasm/_framework/System.Text.Json.wasm +0 -0
- package/dist/wasm/_framework/System.Text.RegularExpressions.wasm +0 -0
- package/dist/wasm/_framework/System.Threading.Thread.wasm +0 -0
- package/dist/wasm/_framework/System.Threading.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.Linq.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.ReaderWriter.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.XDocument.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.XPath.XDocument.wasm +0 -0
- package/dist/wasm/_framework/System.Xml.XPath.wasm +0 -0
- package/dist/wasm/_framework/System.wasm +0 -0
- package/dist/wasm/_framework/dotnet.boot.js +279 -0
- package/dist/wasm/_framework/dotnet.js +2 -2
- package/dist/wasm/_framework/dotnet.js.map +1 -1
- package/dist/wasm/_framework/dotnet.native.js +5628 -3
- package/dist/wasm/_framework/dotnet.native.js.symbols +5530 -3599
- package/dist/wasm/_framework/dotnet.native.wasm +0 -0
- package/dist/wasm/_framework/dotnet.runtime.js +1 -1
- package/dist/wasm/_framework/dotnet.runtime.js.map +1 -1
- package/package.json +8 -4
- package/dist/wasm/_framework/System.Diagnostics.DiagnosticSource.wasm +0 -0
- package/dist/wasm/_framework/blazor.boot.json +0 -66
- package/dist/wasm/_framework/supportFiles/0_runtimeconfig.bin +0 -1
package/dist/editor.js
ADDED
|
@@ -0,0 +1,1525 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DocxEditor — a framework-agnostic, in-browser DOCX block editor.
|
|
3
|
+
*
|
|
4
|
+
* Architecture (see docs/architecture/ir_editor_feasibility.md, "Option B"):
|
|
5
|
+
* - model-of-record: a live DocxSession in WASM (lossless save);
|
|
6
|
+
* - rendering: WmlToHtmlConverter HTML (faithful) stamped with data-anchor;
|
|
7
|
+
* - editing: each block is contenteditable; on commit, the edit goes through
|
|
8
|
+
* DocxSession by anchor, then ONLY that block is re-rendered from the live
|
|
9
|
+
* session (session-attached RenderBlockHtml) and patched into the DOM.
|
|
10
|
+
*
|
|
11
|
+
* The IR/anchor system is the addressing spine; the live OOXML is the truth.
|
|
12
|
+
* This is the pure-TypeScript core; a React wrapper can sit on top.
|
|
13
|
+
*
|
|
14
|
+
* MVP scope: per-block, commit-on-blur editing of paragraphs/headings. An edited
|
|
15
|
+
* block's content is replaced from its plain text (inline formatting within an
|
|
16
|
+
* edited block is not preserved — a documented MVP limit); UNTOUCHED blocks keep
|
|
17
|
+
* full fidelity, and save() is lossless for them.
|
|
18
|
+
*/
|
|
19
|
+
import { paginateHtml } from "./pagination.js";
|
|
20
|
+
const EDITABLE_TAGS = new Set(["P", "H1", "H2", "H3", "H4", "H5", "H6"]);
|
|
21
|
+
function fontWeightIsBold(w) {
|
|
22
|
+
if (w === "bold" || w === "bolder")
|
|
23
|
+
return true;
|
|
24
|
+
const n = parseInt(w, 10);
|
|
25
|
+
return !Number.isNaN(n) && n >= 600;
|
|
26
|
+
}
|
|
27
|
+
function escapeInlineMarkdown(text) {
|
|
28
|
+
// Escape the markdown the projector subset is sensitive to; keep it minimal.
|
|
29
|
+
return text.replace(/([\\`*_[\]])/g, "\\$1");
|
|
30
|
+
}
|
|
31
|
+
function collectInlineSegments(node, out) {
|
|
32
|
+
node.childNodes.forEach((child) => {
|
|
33
|
+
// Skip generated list-marker spans — they aren't part of the paragraph's content.
|
|
34
|
+
if (child.nodeType === 1 && child.hasAttribute?.("data-list-marker"))
|
|
35
|
+
return;
|
|
36
|
+
if (child.nodeType === 3 /* TEXT_NODE */) {
|
|
37
|
+
const text = child.textContent ?? "";
|
|
38
|
+
if (!text)
|
|
39
|
+
return;
|
|
40
|
+
const parent = child.parentElement;
|
|
41
|
+
let bold = false;
|
|
42
|
+
let italic = false;
|
|
43
|
+
let href = null;
|
|
44
|
+
if (parent && typeof getComputedStyle === "function") {
|
|
45
|
+
const cs = getComputedStyle(parent);
|
|
46
|
+
bold = fontWeightIsBold(cs.fontWeight);
|
|
47
|
+
italic = cs.fontStyle === "italic" || cs.fontStyle === "oblique";
|
|
48
|
+
const a = parent.closest("a");
|
|
49
|
+
href = a ? a.getAttribute("href") : null;
|
|
50
|
+
}
|
|
51
|
+
out.push({ text, bold, italic, href });
|
|
52
|
+
}
|
|
53
|
+
else if (child.nodeType === 1 /* ELEMENT_NODE */) {
|
|
54
|
+
const el = child;
|
|
55
|
+
if (el.tagName === "BR") {
|
|
56
|
+
out.push({ text: "\n", bold: false, italic: false, href: null });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
collectInlineSegments(el, out);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function segToMarkdown(seg) {
|
|
64
|
+
// A <br> segment is a hard line break → the canonical GFM " \n", which the
|
|
65
|
+
// DocxSession markdown parser turns into a real w:br (Word's intra-paragraph
|
|
66
|
+
// line break) instead of a literal newline in w:t.
|
|
67
|
+
if (seg.text === "\n")
|
|
68
|
+
return " \n";
|
|
69
|
+
let md = escapeInlineMarkdown(seg.text).replace(/[ \t]*\n/g, " \n");
|
|
70
|
+
if (/\S/.test(seg.text)) {
|
|
71
|
+
// Don't wrap pure whitespace — `** **` is not valid emphasis.
|
|
72
|
+
if (seg.bold && seg.italic)
|
|
73
|
+
md = `***${md}***`;
|
|
74
|
+
else if (seg.bold)
|
|
75
|
+
md = `**${md}**`;
|
|
76
|
+
else if (seg.italic)
|
|
77
|
+
md = `*${md}*`;
|
|
78
|
+
}
|
|
79
|
+
if (seg.href)
|
|
80
|
+
md = `[${md}](${seg.href})`;
|
|
81
|
+
return md;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Serialize a block's inline content to the projector's markdown subset, preserving
|
|
85
|
+
* bold / italic / links (emphasis detected via computed style). Used so an edit keeps
|
|
86
|
+
* the block's formatting instead of flattening it to plain text. Formatting the markdown
|
|
87
|
+
* subset cannot express (font size/color) is still dropped on an edited block.
|
|
88
|
+
*/
|
|
89
|
+
export function serializeInlineMarkdown(block) {
|
|
90
|
+
const segs = [];
|
|
91
|
+
collectInlineSegments(block, segs);
|
|
92
|
+
// Merge adjacent segments with identical formatting to avoid `**a****b**`.
|
|
93
|
+
const merged = [];
|
|
94
|
+
for (const s of segs) {
|
|
95
|
+
const prev = merged[merged.length - 1];
|
|
96
|
+
if (prev &&
|
|
97
|
+
prev.text !== "\n" &&
|
|
98
|
+
s.text !== "\n" &&
|
|
99
|
+
prev.bold === s.bold &&
|
|
100
|
+
prev.italic === s.italic &&
|
|
101
|
+
prev.href === s.href) {
|
|
102
|
+
prev.text += s.text;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
merged.push({ ...s });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return merged.map(segToMarkdown).join("").trim();
|
|
109
|
+
}
|
|
110
|
+
/** True if `block` renders as a list item (has a generated marker as its first child). */
|
|
111
|
+
function isListBlock(block) {
|
|
112
|
+
return !!block.querySelector(":scope > [data-list-marker]");
|
|
113
|
+
}
|
|
114
|
+
/** True if `node` is, or is inside, a generated list-marker span (not editable content). */
|
|
115
|
+
function isInMarker(node) {
|
|
116
|
+
let el = node && node.nodeType === 1 ? node : node?.parentElement ?? null;
|
|
117
|
+
while (el) {
|
|
118
|
+
if (el.hasAttribute && el.hasAttribute("data-list-marker"))
|
|
119
|
+
return true;
|
|
120
|
+
el = el.parentElement;
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Unicode bidi formatting marks the HTML converter injects to preserve visual order: LRM/RLM/ALM,
|
|
126
|
+
* the embedding/override controls, and the isolates (see WmlToHtmlConverter — a paragraph/run gets
|
|
127
|
+
* a leading U+200E or U+200F). They are presentation-only — NOT part of the paragraph's run text the
|
|
128
|
+
* session holds — so the editor must exclude them from its content-offset space, the same way it
|
|
129
|
+
* excludes generated list markers. Otherwise every caret offset is shifted by the leading mark and a
|
|
130
|
+
* caret at end-of-line overshoots the session's text length, so SplitParagraph/ApplyFormat reject the
|
|
131
|
+
* offset (symptom: Enter at the end of a Google-Docs-exported paragraph is silently dropped).
|
|
132
|
+
*/
|
|
133
|
+
// LRM, RLM, ALM; the embedding/override controls (LRE RLE PDF LRO RLO); the isolates (LRI RLI FSI PDI).
|
|
134
|
+
const BIDI_MARK_CLASS = "\u200E\u200F\u061C\u202A-\u202E\u2066-\u2069";
|
|
135
|
+
const BIDI_MARKS_RE_G = new RegExp(`[${BIDI_MARK_CLASS}]`, "g");
|
|
136
|
+
const BIDI_MARK_RE = new RegExp(`[${BIDI_MARK_CLASS}]`);
|
|
137
|
+
function stripBidi(s) {
|
|
138
|
+
return s.replace(BIDI_MARKS_RE_G, "");
|
|
139
|
+
}
|
|
140
|
+
/** Raw string index in `s` for content offset `n` (content = chars excluding bidi marks). */
|
|
141
|
+
function domOffsetForContentOffset(s, n) {
|
|
142
|
+
let content = 0;
|
|
143
|
+
for (let i = 0; i < s.length; i++) {
|
|
144
|
+
if (content >= n)
|
|
145
|
+
return i;
|
|
146
|
+
if (!BIDI_MARK_RE.test(s[i]))
|
|
147
|
+
content++;
|
|
148
|
+
}
|
|
149
|
+
return s.length;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Content-text offset of (container, offset) within `block`, EXCLUDING generated list-marker
|
|
153
|
+
* text and injected bidi marks. This is the offset DocxSession ops expect (the paragraph's run
|
|
154
|
+
* text, not the rendered number/bullet or bidi marks the converter injects).
|
|
155
|
+
*/
|
|
156
|
+
function contentOffsetOf(block, container, offset) {
|
|
157
|
+
let count = 0;
|
|
158
|
+
let done = false;
|
|
159
|
+
const walk = (node) => {
|
|
160
|
+
if (done)
|
|
161
|
+
return;
|
|
162
|
+
if (node.nodeType === 3 /* TEXT_NODE */) {
|
|
163
|
+
if (node === container) {
|
|
164
|
+
if (!isInMarker(node))
|
|
165
|
+
count += stripBidi((node.textContent ?? "").slice(0, offset)).length;
|
|
166
|
+
done = true;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (!isInMarker(node))
|
|
170
|
+
count += stripBidi(node.textContent ?? "").length;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
if (node === container) {
|
|
174
|
+
// Element container: `offset` is a child index — count content up to that child.
|
|
175
|
+
const kids = Array.from(node.childNodes);
|
|
176
|
+
for (let i = 0; i < offset && i < kids.length; i++)
|
|
177
|
+
walk(kids[i]);
|
|
178
|
+
done = true;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
node.childNodes.forEach(walk);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
walk(block);
|
|
185
|
+
return count;
|
|
186
|
+
}
|
|
187
|
+
/** Content-text offset of the collapsed caret within `block` (excludes markers), or null. */
|
|
188
|
+
function caretOffsetIn(block) {
|
|
189
|
+
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
190
|
+
if (!sel || sel.rangeCount === 0)
|
|
191
|
+
return null;
|
|
192
|
+
const range = sel.getRangeAt(0);
|
|
193
|
+
if (!block.contains(range.startContainer))
|
|
194
|
+
return null;
|
|
195
|
+
return contentOffsetOf(block, range.startContainer, range.startOffset);
|
|
196
|
+
}
|
|
197
|
+
/** Visible content text of `block`, excluding generated list-marker text (the same content
|
|
198
|
+
* caretOffsetIn/contentOffsetOf count). */
|
|
199
|
+
function blockContentText(block) {
|
|
200
|
+
let out = "";
|
|
201
|
+
const walk = (node) => {
|
|
202
|
+
if (node.nodeType === 3 /* TEXT_NODE */) {
|
|
203
|
+
if (!isInMarker(node))
|
|
204
|
+
out += stripBidi(node.textContent ?? "");
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
node.childNodes.forEach(walk);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
walk(block);
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Map a DOM caret offset (from caretOffsetIn) into the run-text offset the session holds after a
|
|
215
|
+
* commit. commitBlock/syncBlock commit `serializeInlineMarkdown(el)`, which `.trim()`s leading and
|
|
216
|
+
* trailing whitespace, so the session's paragraph text is shorter than the DOM text whenever the
|
|
217
|
+
* block has edge whitespace — e.g. a blank document renders its empty paragraph with a placeholder
|
|
218
|
+
* space, and typing lands after it. Without this adjustment the caret offset overshoots the
|
|
219
|
+
* committed length, SplitParagraph returns OffsetOutOfRange, and splitAtCaret silently drops the
|
|
220
|
+
* Enter (no new paragraph). Subtracting the leading whitespace before the caret and clamping to the
|
|
221
|
+
* trimmed length keeps the split offset consistent with what was committed.
|
|
222
|
+
*/
|
|
223
|
+
function trimmedSplitOffset(block, domOffset) {
|
|
224
|
+
const content = blockContentText(block);
|
|
225
|
+
const leading = content.length - content.replace(/^\s+/, "").length;
|
|
226
|
+
const trimmedLen = content.trim().length;
|
|
227
|
+
return Math.max(0, Math.min(domOffset - Math.min(domOffset, leading), trimmedLen));
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* DOM (node, offset) for content offset `offset` within `el` — the same content-offset
|
|
231
|
+
* space as contentOffsetOf (marker text and injected bidi marks excluded). Clamps past-end
|
|
232
|
+
* offsets to the end of the last text node (or the element itself when it has none), so a
|
|
233
|
+
* caller can always build a Range from the result.
|
|
234
|
+
*/
|
|
235
|
+
function contentPositionIn(el, offset) {
|
|
236
|
+
let remaining = offset;
|
|
237
|
+
let result = null;
|
|
238
|
+
let lastText = null;
|
|
239
|
+
const walk = (node) => {
|
|
240
|
+
if (result)
|
|
241
|
+
return;
|
|
242
|
+
if (node.nodeType === 3 /* TEXT_NODE */) {
|
|
243
|
+
if (isInMarker(node))
|
|
244
|
+
return;
|
|
245
|
+
const raw = node.textContent ?? "";
|
|
246
|
+
const len = stripBidi(raw).length;
|
|
247
|
+
lastText = node;
|
|
248
|
+
if (remaining <= len) {
|
|
249
|
+
result = { node, offset: domOffsetForContentOffset(raw, remaining) };
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
remaining -= len;
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
node.childNodes.forEach(walk);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
walk(el);
|
|
259
|
+
if (result)
|
|
260
|
+
return result;
|
|
261
|
+
if (lastText)
|
|
262
|
+
return { node: lastText, offset: (lastText.textContent ?? "").length };
|
|
263
|
+
return { node: el, offset: el.childNodes.length };
|
|
264
|
+
}
|
|
265
|
+
/** Place the caret at content offset `offset` within `el`, skipping marker text. */
|
|
266
|
+
function placeCaretAtOffset(el, offset) {
|
|
267
|
+
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
268
|
+
if (!sel)
|
|
269
|
+
return;
|
|
270
|
+
el.focus();
|
|
271
|
+
const range = document.createRange();
|
|
272
|
+
let remaining = offset;
|
|
273
|
+
let placed = false;
|
|
274
|
+
const walk = (node) => {
|
|
275
|
+
if (placed)
|
|
276
|
+
return;
|
|
277
|
+
if (node.nodeType === 3 /* TEXT_NODE */) {
|
|
278
|
+
if (isInMarker(node))
|
|
279
|
+
return; // never land the caret in the marker
|
|
280
|
+
const raw = node.textContent ?? "";
|
|
281
|
+
const len = stripBidi(raw).length; // content length excludes injected bidi marks
|
|
282
|
+
if (remaining <= len) {
|
|
283
|
+
range.setStart(node, domOffsetForContentOffset(raw, remaining));
|
|
284
|
+
placed = true;
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
remaining -= len;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
node.childNodes.forEach(walk);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
walk(el);
|
|
295
|
+
if (!placed) {
|
|
296
|
+
range.selectNodeContents(el);
|
|
297
|
+
range.collapse(false);
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
range.collapse(true);
|
|
301
|
+
}
|
|
302
|
+
sel.removeAllRanges();
|
|
303
|
+
sel.addRange(range);
|
|
304
|
+
}
|
|
305
|
+
/** The selection's content-text {start,length} within `block` (excludes markers), or null. */
|
|
306
|
+
function selectionSpanIn(block) {
|
|
307
|
+
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
308
|
+
if (!sel || sel.rangeCount === 0)
|
|
309
|
+
return null;
|
|
310
|
+
const range = sel.getRangeAt(0);
|
|
311
|
+
if (range.collapsed)
|
|
312
|
+
return null;
|
|
313
|
+
if (!block.contains(range.startContainer) || !block.contains(range.endContainer))
|
|
314
|
+
return null;
|
|
315
|
+
const start = contentOffsetOf(block, range.startContainer, range.startOffset);
|
|
316
|
+
const end = contentOffsetOf(block, range.endContainer, range.endOffset);
|
|
317
|
+
return { start: Math.min(start, end), length: Math.abs(end - start) };
|
|
318
|
+
}
|
|
319
|
+
/** Restore a content-text selection spanning [start, start+length) within `el` (skips markers). */
|
|
320
|
+
function selectRange(el, start, length) {
|
|
321
|
+
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
322
|
+
// The block may have been swapped out of the document by a re-render before this runs
|
|
323
|
+
// (e.g. a focus-stealing toolbar control firing twice). addRange on a detached range
|
|
324
|
+
// throws "the given range isn't in document" — skip rather than warn.
|
|
325
|
+
if (!sel || !el.isConnected)
|
|
326
|
+
return;
|
|
327
|
+
el.focus();
|
|
328
|
+
const range = document.createRange();
|
|
329
|
+
const end = start + length;
|
|
330
|
+
let pos = 0;
|
|
331
|
+
let startSet = false;
|
|
332
|
+
const walk = (node) => {
|
|
333
|
+
for (const child of Array.from(node.childNodes)) {
|
|
334
|
+
if (child.nodeType === 3 /* TEXT_NODE */) {
|
|
335
|
+
if (isInMarker(child))
|
|
336
|
+
continue; // marker text isn't part of the content offset space
|
|
337
|
+
const raw = child.textContent ?? "";
|
|
338
|
+
const len = stripBidi(raw).length; // content length excludes injected bidi marks
|
|
339
|
+
if (!startSet && pos + len >= start) {
|
|
340
|
+
range.setStart(child, domOffsetForContentOffset(raw, start - pos));
|
|
341
|
+
startSet = true;
|
|
342
|
+
}
|
|
343
|
+
if (startSet && pos + len >= end) {
|
|
344
|
+
range.setEnd(child, domOffsetForContentOffset(raw, end - pos));
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
pos += len;
|
|
348
|
+
}
|
|
349
|
+
else if (walk(child)) {
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return false;
|
|
354
|
+
};
|
|
355
|
+
if (walk(el) || startSet) {
|
|
356
|
+
sel.removeAllRanges();
|
|
357
|
+
sel.addRange(range);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* True when `el`'s immediate parent is a paragraph-border `<div>` the full render wrapped it in
|
|
362
|
+
* (CreateBorderDivs groups visibly-bordered paragraphs into a div). The body wrapper div has no
|
|
363
|
+
* border. Splitting/merging such a block must re-render the whole document so the converter can
|
|
364
|
+
* re-group the border boxes — an in-place node swap would leave the new (often borderless)
|
|
365
|
+
* paragraph stranded inside the stale border div, drawing the rule's line under its text.
|
|
366
|
+
*/
|
|
367
|
+
function inBorderWrapper(el) {
|
|
368
|
+
const style = el.parentElement?.getAttribute("style") ?? "";
|
|
369
|
+
return /border-(top|bottom|left|right):\s*(?!none)[^;]+/i.test(style);
|
|
370
|
+
}
|
|
371
|
+
/** Whether the current selection's start already carries `key`, read from computed style. */
|
|
372
|
+
function selectionHasFormat(key, fallback) {
|
|
373
|
+
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
374
|
+
let el = fallback;
|
|
375
|
+
if (sel && sel.rangeCount > 0) {
|
|
376
|
+
const n = sel.getRangeAt(0).startContainer;
|
|
377
|
+
el = n.nodeType === 3 ? n.parentElement : n;
|
|
378
|
+
}
|
|
379
|
+
if (!el || typeof getComputedStyle !== "function")
|
|
380
|
+
return false;
|
|
381
|
+
const cs = getComputedStyle(el);
|
|
382
|
+
switch (key) {
|
|
383
|
+
case "bold": return fontWeightIsBold(cs.fontWeight);
|
|
384
|
+
case "italic": return cs.fontStyle === "italic" || cs.fontStyle === "oblique";
|
|
385
|
+
case "underline": return cs.textDecorationLine.includes("underline");
|
|
386
|
+
case "strike": return cs.textDecorationLine.includes("line-through");
|
|
387
|
+
case "code": return /mono|courier|consolas/i.test(cs.fontFamily);
|
|
388
|
+
case "superscript": return cs.verticalAlign === "super" || !!el.closest("sup");
|
|
389
|
+
case "subscript": return cs.verticalAlign === "sub" || !!el.closest("sub");
|
|
390
|
+
default: return false;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/** Build the full ConvertDocxToHtmlComplete arg list (stampAnchors = last arg). */
|
|
394
|
+
function completeArgs(bytes, cssPrefix, fabricate, paginated, scale) {
|
|
395
|
+
return [
|
|
396
|
+
bytes, "Document", cssPrefix, fabricate, "", -1, "comment-",
|
|
397
|
+
/* paginationMode */ paginated ? 1 : 0, /* paginationScale */ scale, "page-",
|
|
398
|
+
false, 0, "annot-",
|
|
399
|
+
/* renderFootnotesAndEndnotes */ false, /* renderHeadersAndFooters */ paginated,
|
|
400
|
+
false, true, true, false, null, /* stampAnchors */ true,
|
|
401
|
+
];
|
|
402
|
+
}
|
|
403
|
+
export class DocxEditor {
|
|
404
|
+
constructor(container, exports, handle, options) {
|
|
405
|
+
/** Map a block's current bare unid → its full kind:scope:unid (DocxSession anchor). */
|
|
406
|
+
this.unidToFullId = new Map();
|
|
407
|
+
/** The most recently focused editable block — the target for ribbon/format commands. */
|
|
408
|
+
this.activeBlock = null;
|
|
409
|
+
this.closed = false;
|
|
410
|
+
/**
|
|
411
|
+
* Re-entrancy guard for node replacement. Replacing a contenteditable block that still holds
|
|
412
|
+
* focus removes the focused node, which fires a SYNCHRONOUS `blur` → re-enters commitBlock; the
|
|
413
|
+
* interleaved second replaceWith then throws NotFoundError ("node ... no longer a child") and the
|
|
414
|
+
* structural edit (split/merge/format) is lost. While this flag is set, commitBlock no-ops.
|
|
415
|
+
*/
|
|
416
|
+
this.replacing = false;
|
|
417
|
+
/**
|
|
418
|
+
* The last real (non-collapsed) text selection inside an editable block. A toolbar control that
|
|
419
|
+
* must take focus to be used — the font-size combobox — blurs the block and collapses the live
|
|
420
|
+
* selection, so without this an operation triggered from such a control could only target the
|
|
421
|
+
* whole paragraph (S-1 smoke-test finding 3). Refreshed whenever a non-empty selection sits in a
|
|
422
|
+
* block, and cleared when a caret is collapsed inside a block (so it never goes stale).
|
|
423
|
+
*/
|
|
424
|
+
this.lastSelection = null;
|
|
425
|
+
/** Track the last meaningful selection so focus-stealing toolbar controls can still target it. */
|
|
426
|
+
this.onSelectionChange = () => {
|
|
427
|
+
if (this.closed)
|
|
428
|
+
return;
|
|
429
|
+
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
430
|
+
if (!sel || sel.rangeCount === 0)
|
|
431
|
+
return; // no selection info — keep the cache as-is
|
|
432
|
+
const range = sel.getRangeAt(0);
|
|
433
|
+
const block = this.editableBlockOf(range.commonAncestorContainer);
|
|
434
|
+
if (!block)
|
|
435
|
+
return; // selection is outside the editor (e.g. a toolbar field) — keep the cache
|
|
436
|
+
const unid = block.getAttribute("data-anchor");
|
|
437
|
+
if (!unid)
|
|
438
|
+
return;
|
|
439
|
+
if (range.collapsed) {
|
|
440
|
+
this.lastSelection = null; // an explicit caret in a block — drop any stale selection
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const span = selectionSpanIn(block);
|
|
444
|
+
if (span)
|
|
445
|
+
this.lastSelection = { unid, span };
|
|
446
|
+
};
|
|
447
|
+
this.container = container;
|
|
448
|
+
this.exports = exports;
|
|
449
|
+
this.handle = handle;
|
|
450
|
+
this.options = options;
|
|
451
|
+
this.editRoot = container;
|
|
452
|
+
if (typeof document !== "undefined")
|
|
453
|
+
document.addEventListener("selectionchange", this.onSelectionChange);
|
|
454
|
+
}
|
|
455
|
+
/** The editable block (contenteditable [data-anchor]) containing `node`, if any, within this editor. */
|
|
456
|
+
editableBlockOf(node) {
|
|
457
|
+
if (!node)
|
|
458
|
+
return null;
|
|
459
|
+
const start = node.nodeType === 1 ? node : node.parentElement;
|
|
460
|
+
const block = start?.closest('[data-anchor][contenteditable="true"]') ?? null;
|
|
461
|
+
return block && this.editRoot.contains(block) ? block : null;
|
|
462
|
+
}
|
|
463
|
+
/** Open a document, render it into `container`, and wire up editing. */
|
|
464
|
+
static open(container, bytes, exports, options = {}) {
|
|
465
|
+
const opts = {
|
|
466
|
+
cssPrefix: options.cssPrefix ?? "docx-",
|
|
467
|
+
fabricateClasses: options.fabricateClasses ?? false,
|
|
468
|
+
editable: options.editable ?? true,
|
|
469
|
+
paginated: options.paginated ?? false,
|
|
470
|
+
scale: options.scale ?? 1,
|
|
471
|
+
onEdit: options.onEdit,
|
|
472
|
+
};
|
|
473
|
+
// persistAnchorIds=true keeps PtOpenXml:Unid attributes in Save() output, so a remount's
|
|
474
|
+
// full re-render keeps the SAME unids the live session uses (a content change like becoming
|
|
475
|
+
// a list otherwise re-derives a fresh unid, leaving the block unwired). The cost is that
|
|
476
|
+
// saved bytes carry the Unid attributes (Word ignores them).
|
|
477
|
+
const handle = exports.DocxSessionBridge.OpenSession(bytes, '{"persistAnchorIds":true}');
|
|
478
|
+
const editor = new DocxEditor(container, exports, handle, opts);
|
|
479
|
+
editor.refreshAnchorMap();
|
|
480
|
+
const fullHtml = exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, opts.cssPrefix, opts.fabricateClasses, opts.paginated, opts.scale));
|
|
481
|
+
if (opts.paginated)
|
|
482
|
+
editor.mountPaginated(fullHtml);
|
|
483
|
+
else
|
|
484
|
+
editor.mountHtml(fullHtml);
|
|
485
|
+
return editor;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Open a fresh, blank document (a "New document" — single empty paragraph, Normal style,
|
|
489
|
+
* US-Letter section) and wire up editing. The seed bytes come from the WASM bridge so the
|
|
490
|
+
* result opens cleanly in Word too.
|
|
491
|
+
*/
|
|
492
|
+
static openBlank(container, exports, options = {}) {
|
|
493
|
+
return DocxEditor.open(container, exports.DocxSessionBridge.CreateBlankDocx(), exports, options);
|
|
494
|
+
}
|
|
495
|
+
/** Lossless DOCX bytes reflecting all edits. */
|
|
496
|
+
save() {
|
|
497
|
+
this.assertOpen();
|
|
498
|
+
return this.exports.DocxSessionBridge.Save(this.handle);
|
|
499
|
+
}
|
|
500
|
+
/** Release the underlying WASM session. The editor is unusable afterward. */
|
|
501
|
+
close() {
|
|
502
|
+
if (this.closed)
|
|
503
|
+
return;
|
|
504
|
+
this.closed = true;
|
|
505
|
+
if (typeof document !== "undefined")
|
|
506
|
+
document.removeEventListener("selectionchange", this.onSelectionChange);
|
|
507
|
+
this.exports.DocxSessionBridge.CloseSession(this.handle);
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Switch between continuous and paginated rendering WITHOUT losing edits. Re-renders from the
|
|
511
|
+
* LIVE session, so every committed edit (and the undo/redo history) survives the toggle — unlike
|
|
512
|
+
* re-opening the original bytes, which silently discards session edits. No-op if already `value`.
|
|
513
|
+
*/
|
|
514
|
+
setPaginated(value) {
|
|
515
|
+
this.assertOpen();
|
|
516
|
+
if (this.options.paginated === value)
|
|
517
|
+
return;
|
|
518
|
+
this.options.paginated = value;
|
|
519
|
+
this.remount();
|
|
520
|
+
}
|
|
521
|
+
/** The editor's current DOM (for inspection/tests). */
|
|
522
|
+
get root() {
|
|
523
|
+
return this.container;
|
|
524
|
+
}
|
|
525
|
+
// ─── internals ───────────────────────────────────────────────────────
|
|
526
|
+
assertOpen() {
|
|
527
|
+
if (this.closed)
|
|
528
|
+
throw new Error("DocxEditor is closed");
|
|
529
|
+
}
|
|
530
|
+
/** Rebuild unid → full-anchor-id from the live session projection. */
|
|
531
|
+
refreshAnchorMap() {
|
|
532
|
+
const proj = JSON.parse(this.exports.DocxSessionBridge.Project(this.handle));
|
|
533
|
+
this.unidToFullId.clear();
|
|
534
|
+
for (const [fullId, target] of Object.entries(proj.anchorIndex)) {
|
|
535
|
+
this.unidToFullId.set(target.unid, fullId);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
/** Continuous (non-paginated) mount: inject the converter's styles + body, wire blocks. */
|
|
539
|
+
mountHtml(fullHtml) {
|
|
540
|
+
const parsed = new DOMParser().parseFromString(fullHtml, "text/html");
|
|
541
|
+
const styles = Array.from(parsed.querySelectorAll("style"))
|
|
542
|
+
.map((s) => s.outerHTML)
|
|
543
|
+
.join("");
|
|
544
|
+
this.container.innerHTML = styles + parsed.body.innerHTML;
|
|
545
|
+
this.editRoot = this.container;
|
|
546
|
+
if (this.options.editable)
|
|
547
|
+
this.wireBlocks(this.container);
|
|
548
|
+
}
|
|
549
|
+
/** Paginated mount: flow blocks into page boxes via pagination.ts, wire the page clones. */
|
|
550
|
+
mountPaginated(fullHtml) {
|
|
551
|
+
paginateHtml(fullHtml, this.container, { scale: this.options.scale, cssPrefix: "page-" });
|
|
552
|
+
// pagination.ts measures the hidden #pagination-staging subtree ONCE, then flows CLONES of its
|
|
553
|
+
// blocks into the visible page boxes. Leaving staging in the live DOM is a trap: every
|
|
554
|
+
// data-anchor exists twice (staging + page-box copy), so document.querySelector('[data-anchor]')
|
|
555
|
+
// is ambiguous (hits the hidden copy first), and the staging copy goes stale because edits land
|
|
556
|
+
// only on the page-box copy — a future reflow-from-staging would silently revert them. Staging
|
|
557
|
+
// is a transient measurement scaffold; drop it so the page-box copies are the single source of
|
|
558
|
+
// truth. A remount (setPaginated, list/undo edits) rebuilds staging fresh from the live session.
|
|
559
|
+
this.container.querySelector("#pagination-staging, .page-staging")?.remove();
|
|
560
|
+
const pageRoot = this.container.querySelector("#pagination-container") ?? this.container;
|
|
561
|
+
this.editRoot = pageRoot;
|
|
562
|
+
if (this.options.editable)
|
|
563
|
+
this.wireBlocks(pageRoot);
|
|
564
|
+
}
|
|
565
|
+
wireBlocks(root) {
|
|
566
|
+
root.querySelectorAll("[data-anchor]").forEach((el) => this.wireBlock(el));
|
|
567
|
+
}
|
|
568
|
+
wireBlock(el) {
|
|
569
|
+
if (!EDITABLE_TAGS.has(el.tagName))
|
|
570
|
+
return;
|
|
571
|
+
const unid = el.getAttribute("data-anchor");
|
|
572
|
+
// Only blocks the markdown projection addresses are editable via the text path. This INCLUDES
|
|
573
|
+
// table-cell paragraphs (the projection indexes them), so cell text IS editable — but structural
|
|
574
|
+
// keys are kept inert inside a cell (see onKeydown / GAP3) so single-block editing can't corrupt
|
|
575
|
+
// table structure. Anything the projection does not index (unstamped content) stays read-only.
|
|
576
|
+
if (!unid || !this.unidToFullId.has(unid))
|
|
577
|
+
return;
|
|
578
|
+
el.setAttribute("contenteditable", "true");
|
|
579
|
+
// Generated list markers (number/bullet + suffix) are not editable content — keep the
|
|
580
|
+
// caret out of them so offsets stay aligned with the paragraph's run text.
|
|
581
|
+
el.querySelectorAll("[data-list-marker]").forEach((m) => m.setAttribute("contenteditable", "false"));
|
|
582
|
+
// Baseline for the commit diff: CONTENT text (list markers + injected bidi marks excluded),
|
|
583
|
+
// matching the session's flat run-text offset space.
|
|
584
|
+
el.dataset.committedText = blockContentText(el);
|
|
585
|
+
el.addEventListener("focus", () => { this.activeBlock = el; });
|
|
586
|
+
el.addEventListener("blur", () => this.commitBlock(el));
|
|
587
|
+
el.addEventListener("keydown", (ev) => this.onKeydown(el, ev));
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Replace `oldEl` with `newNodes`, suppressing the re-entrant blur→commit that removing a focused
|
|
591
|
+
* block fires (see `replacing`), AND tolerating the case where a synchronous blur during focus
|
|
592
|
+
* transfer detaches `oldEl` between the caller's checks and here. `replaceWith` then throws
|
|
593
|
+
* NotFoundError ("node … no longer a child … moved in a blur event handler") — `isConnected`
|
|
594
|
+
* alone doesn't catch this race. The session is already updated, so a skipped/failed visual swap
|
|
595
|
+
* leaves correct content (the typed DOM); the next commit or remount reconciles it. This is why
|
|
596
|
+
* the catch is silent rather than rethrowing — there's no lost data, only a deferred re-render.
|
|
597
|
+
* Returns true if the swap happened.
|
|
598
|
+
*/
|
|
599
|
+
replaceNode(oldEl, ...newNodes) {
|
|
600
|
+
const prev = this.replacing;
|
|
601
|
+
this.replacing = true;
|
|
602
|
+
try {
|
|
603
|
+
if (!oldEl.parentNode)
|
|
604
|
+
return false;
|
|
605
|
+
oldEl.replaceWith(...newNodes);
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
finally {
|
|
612
|
+
this.replacing = prev;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
/** Commit a block edit on blur: diff → run-preserving session op → re-render only this block. */
|
|
616
|
+
commitBlock(el) {
|
|
617
|
+
if (this.closed || this.replacing)
|
|
618
|
+
return;
|
|
619
|
+
const unid = el.getAttribute("data-anchor");
|
|
620
|
+
if (!unid)
|
|
621
|
+
return;
|
|
622
|
+
const fullId = this.unidToFullId.get(unid);
|
|
623
|
+
if (!fullId)
|
|
624
|
+
return;
|
|
625
|
+
const result = this.commitTextChange(el, fullId);
|
|
626
|
+
if (!result)
|
|
627
|
+
return; // no change
|
|
628
|
+
if (!result.success) {
|
|
629
|
+
// Session unchanged — re-render this block from truth to discard the rejected DOM edit.
|
|
630
|
+
const fresh = this.renderInto(fullId);
|
|
631
|
+
if (fresh && this.replaceNode(el, fresh)) {
|
|
632
|
+
this.wireBlock(fresh);
|
|
633
|
+
if (this.activeBlock === el)
|
|
634
|
+
this.activeBlock = fresh;
|
|
635
|
+
}
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const newAnchor = result.modified?.[0]?.id ?? fullId;
|
|
639
|
+
const newUnid = result.modified?.[0]?.unid ?? unid;
|
|
640
|
+
// List items: do NOT re-render on a text commit. Re-rendering replaces the node *during* the
|
|
641
|
+
// blur, cancelling the browser's in-flight focus transfer when the user clicks straight to
|
|
642
|
+
// another bullet; numbering also needs whole-document context a single-block render lacks. The
|
|
643
|
+
// DOM already shows what the user typed with the correct marker — sync the baseline only.
|
|
644
|
+
if (el.querySelector(":scope > [data-list-marker]")) {
|
|
645
|
+
el.dataset.committedText = blockContentText(el);
|
|
646
|
+
this.options.onEdit?.({ anchorId: newAnchor, unid: newUnid });
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
// Plain block: re-render ONLY this block from the live session for canonical HTML. Swapping the
|
|
650
|
+
// just-blurred node here is safe (verified — focus stays on the newly-clicked block).
|
|
651
|
+
const html = this.exports.DocxSessionBridge.RenderBlockHtml(this.handle, newAnchor, this.options.cssPrefix, this.options.fabricateClasses);
|
|
652
|
+
if (html.charCodeAt(0) !== 0x7b /* not an error object */) {
|
|
653
|
+
const fresh = new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
|
|
654
|
+
if (fresh && this.replaceNode(el, fresh)) {
|
|
655
|
+
this.unidToFullId.delete(unid);
|
|
656
|
+
this.unidToFullId.set(newUnid, newAnchor);
|
|
657
|
+
this.wireBlock(fresh);
|
|
658
|
+
if (this.activeBlock === el)
|
|
659
|
+
this.activeBlock = fresh; // keep ribbon target valid
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
this.options.onEdit?.({ anchorId: newAnchor, unid: newUnid });
|
|
663
|
+
}
|
|
664
|
+
// ─── M2: structural editing ──────────────────────────────────────────
|
|
665
|
+
onKeydown(el, ev) {
|
|
666
|
+
if (this.closed)
|
|
667
|
+
return;
|
|
668
|
+
// Common formatting / history shortcuts.
|
|
669
|
+
if ((ev.ctrlKey || ev.metaKey) && !ev.altKey) {
|
|
670
|
+
const k = ev.key.toLowerCase();
|
|
671
|
+
const fmt = { b: "bold", i: "italic", u: "underline" };
|
|
672
|
+
if (fmt[k]) {
|
|
673
|
+
ev.preventDefault();
|
|
674
|
+
this.format(fmt[k]);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
if (k === "z") {
|
|
678
|
+
ev.preventDefault();
|
|
679
|
+
ev.shiftKey ? this.redo() : this.undo();
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (k === "y") {
|
|
683
|
+
ev.preventDefault();
|
|
684
|
+
this.redo();
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
// Inside a table cell, structural ops that change the TABLE GRID (cross-cell merge,
|
|
689
|
+
// list-nest, focus-jumping Tab) stay INERT — the single-block model can't give them
|
|
690
|
+
// whole-table context. Tab is swallowed (no focus escape / literal tab); Backspace at
|
|
691
|
+
// the cell's start does not merge across the cell boundary (mid-text Backspace still
|
|
692
|
+
// deletes normally). Enter, however, splits the cell paragraph into two paragraphs
|
|
693
|
+
// WITHIN the same cell — the engine keeps the new w:p in the w:tc, the grid is
|
|
694
|
+
// unchanged, so it's safe (it's how a cell holds stacked lines: value over a smaller
|
|
695
|
+
// label, multi-line addresses). (GAP3.)
|
|
696
|
+
const inTableCell = !!el.closest("table");
|
|
697
|
+
// Tab / Shift+Tab on a list item nests / un-nests it (changes list level).
|
|
698
|
+
if (ev.key === "Tab") {
|
|
699
|
+
if (inTableCell) {
|
|
700
|
+
ev.preventDefault();
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (isListBlock(el)) {
|
|
704
|
+
ev.preventDefault();
|
|
705
|
+
this.activeBlock = el;
|
|
706
|
+
this.setListLevel(ev.shiftKey ? -1 : 1);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
// Shift+Enter inserts an intra-paragraph line break (a real w:br on commit),
|
|
711
|
+
// not a paragraph split. Deterministic across browsers and allowed in cells
|
|
712
|
+
// (a line break changes no table structure).
|
|
713
|
+
if (ev.key === "Enter" && ev.shiftKey && !ev.isComposing) {
|
|
714
|
+
ev.preventDefault();
|
|
715
|
+
this.insertLineBreakAtCaret();
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (ev.key === "Enter" && !ev.shiftKey && !ev.isComposing) {
|
|
719
|
+
ev.preventDefault();
|
|
720
|
+
// Splits at the caret — in a cell this stacks a second paragraph within the same
|
|
721
|
+
// w:tc (grid unchanged); in the body it splits the paragraph as before.
|
|
722
|
+
this.splitAtCaret(el);
|
|
723
|
+
}
|
|
724
|
+
else if (ev.key === "Backspace") {
|
|
725
|
+
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
726
|
+
if (sel && sel.isCollapsed && caretOffsetIn(el) === 0 && !inTableCell) {
|
|
727
|
+
const prev = this.previousEditable(el);
|
|
728
|
+
if (prev) {
|
|
729
|
+
ev.preventDefault();
|
|
730
|
+
this.mergeWithPrevious(prev, el);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
/** Shift+Enter: insert an intra-paragraph line break at the caret. Delegates to the
|
|
736
|
+
* native `insertLineBreak` command, which inserts a <br> AND positions the caret
|
|
737
|
+
* after it correctly (handling the browser's bogus trailing-<br> rule) so typing
|
|
738
|
+
* continues on the new line. Commits (on blur) as a w:br via the " \n" hard break
|
|
739
|
+
* the serializer emits for a <br>. */
|
|
740
|
+
insertLineBreakAtCaret() {
|
|
741
|
+
if (typeof document !== "undefined" && typeof document.execCommand === "function") {
|
|
742
|
+
document.execCommand("insertLineBreak");
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
/** Enter: split the block at the caret into two paragraphs. */
|
|
746
|
+
splitAtCaret(el) {
|
|
747
|
+
const rawOffset = caretOffsetIn(el);
|
|
748
|
+
const unid = el.getAttribute("data-anchor");
|
|
749
|
+
if (rawOffset == null || !unid)
|
|
750
|
+
return;
|
|
751
|
+
let fullId = this.unidToFullId.get(unid);
|
|
752
|
+
if (!fullId)
|
|
753
|
+
return;
|
|
754
|
+
// The session commits trimmed text, so map the DOM caret offset into the trimmed run-text
|
|
755
|
+
// offset (else an overshoot — e.g. a placeholder leading space — is rejected and Enter is lost).
|
|
756
|
+
const offset = trimmedSplitOffset(el, rawOffset);
|
|
757
|
+
const idx = this.blockIndex(el); // capture before the op (for list remount focus)
|
|
758
|
+
// Whether this paragraph is rendered inside a border <div> — captured before the DOM mutates.
|
|
759
|
+
const wrappedInBorder = inBorderWrapper(el);
|
|
760
|
+
fullId = this.syncBlock(el, fullId); // flush any uncommitted typing first
|
|
761
|
+
const res = this.parseEdit(this.exports.DocxSessionBridge.SplitParagraph(this.handle, fullId, offset));
|
|
762
|
+
if (!res.success)
|
|
763
|
+
return;
|
|
764
|
+
const first = res.modified?.[0];
|
|
765
|
+
const second = res.created?.[0];
|
|
766
|
+
if (!first || !second)
|
|
767
|
+
return;
|
|
768
|
+
// Splitting a list item makes a continuing list item, and splitting a bordered paragraph (e.g.
|
|
769
|
+
// a horizontal rule) yields a new paragraph whose border status differs — both need a whole-
|
|
770
|
+
// document re-render so numbering continues / border <div>s regroup correctly. An in-place node
|
|
771
|
+
// swap would leave the new paragraph inside the old border div (the rule's line under its text).
|
|
772
|
+
if (this.affectsList(res) || wrappedInBorder) {
|
|
773
|
+
this.remount(idx + 1, false);
|
|
774
|
+
this.options.onEdit?.({ anchorId: second.id, unid: second.unid });
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
const firstEl = this.renderInto(first.id);
|
|
778
|
+
const secondEl = this.renderInto(second.id);
|
|
779
|
+
if (!firstEl || !secondEl)
|
|
780
|
+
return;
|
|
781
|
+
// el is the focused block — replaceNode guards the re-entrant blur→commit and tolerates a
|
|
782
|
+
// node detached mid-focus-transfer; replacing with both new blocks at once keeps them adjacent.
|
|
783
|
+
if (!this.replaceNode(el, firstEl, secondEl))
|
|
784
|
+
return;
|
|
785
|
+
this.unidToFullId.delete(unid);
|
|
786
|
+
this.unidToFullId.set(first.unid, first.id);
|
|
787
|
+
this.unidToFullId.set(second.unid, second.id);
|
|
788
|
+
this.wireBlock(firstEl);
|
|
789
|
+
this.wireBlock(secondEl);
|
|
790
|
+
placeCaretAtOffset(secondEl, 0);
|
|
791
|
+
this.options.onEdit?.({ anchorId: second.id, unid: second.unid });
|
|
792
|
+
}
|
|
793
|
+
/** Backspace at block start: merge this block into the previous one. */
|
|
794
|
+
mergeWithPrevious(prev, el) {
|
|
795
|
+
const prevUnid = prev.getAttribute("data-anchor");
|
|
796
|
+
const thisUnid = el.getAttribute("data-anchor");
|
|
797
|
+
if (!prevUnid || !thisUnid)
|
|
798
|
+
return;
|
|
799
|
+
let prevId = this.unidToFullId.get(prevUnid);
|
|
800
|
+
let thisId = this.unidToFullId.get(thisUnid);
|
|
801
|
+
if (!prevId || !thisId)
|
|
802
|
+
return;
|
|
803
|
+
const prevIdx = this.blockIndex(prev); // capture before the op
|
|
804
|
+
// Either side rendered inside a border <div> means the merge changes border grouping — captured
|
|
805
|
+
// before the DOM mutates so the post-merge branch can force a full re-render.
|
|
806
|
+
const wrappedInBorder = inBorderWrapper(prev) || inBorderWrapper(el);
|
|
807
|
+
prevId = this.syncBlock(prev, prevId);
|
|
808
|
+
thisId = this.syncBlock(el, thisId);
|
|
809
|
+
const caret = (prev.textContent ?? "").length; // merge boundary
|
|
810
|
+
const res = this.parseEdit(this.exports.DocxSessionBridge.MergeParagraphs(this.handle, prevId, thisId));
|
|
811
|
+
if (!res.success)
|
|
812
|
+
return;
|
|
813
|
+
const merged = res.modified?.[0];
|
|
814
|
+
if (!merged)
|
|
815
|
+
return;
|
|
816
|
+
// Merging list items renumbers the list, and merging across a border <div> boundary changes the
|
|
817
|
+
// border grouping — both need a whole-document re-render (caret at the merge boundary).
|
|
818
|
+
if (this.affectsList(res) || wrappedInBorder) {
|
|
819
|
+
this.remount(prevIdx, true);
|
|
820
|
+
this.options.onEdit?.({ anchorId: merged.id, unid: merged.unid });
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const mergedEl = this.renderInto(merged.id);
|
|
824
|
+
if (!mergedEl)
|
|
825
|
+
return;
|
|
826
|
+
// prev may be focused — replaceNode guards re-entrancy and tolerates a detached node.
|
|
827
|
+
if (!this.replaceNode(prev, mergedEl))
|
|
828
|
+
return;
|
|
829
|
+
el.remove();
|
|
830
|
+
this.unidToFullId.delete(prevUnid);
|
|
831
|
+
this.unidToFullId.delete(thisUnid);
|
|
832
|
+
this.unidToFullId.set(merged.unid, merged.id);
|
|
833
|
+
this.wireBlock(mergedEl);
|
|
834
|
+
placeCaretAtOffset(mergedEl, caret);
|
|
835
|
+
this.options.onEdit?.({ anchorId: merged.id, unid: merged.unid });
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Apply the block's pending text change to the session with full inline-formatting fidelity.
|
|
839
|
+
* Diffs the committed content text (markers + bidi excluded) against the current content text
|
|
840
|
+
* and rewrites only the changed span via ReplaceTextAtSpan — every untouched run keeps its exact
|
|
841
|
+
* rPr, and typed text inherits the boundary run's formatting. Returns the parsed EditResult, or
|
|
842
|
+
* null when there is no change. Empty/whitespace-only baselines (e.g. the placeholder space the
|
|
843
|
+
* converter renders for an empty paragraph, whose DOM text doesn't line up with the session's
|
|
844
|
+
* empty run text) are rebuilt via ReplaceText — there is no inline formatting to preserve there.
|
|
845
|
+
*/
|
|
846
|
+
commitTextChange(el, fullId) {
|
|
847
|
+
// `old` mirrors the session's flat run-text: strip bidi marks (blockContentText strips them,
|
|
848
|
+
// but wireBlock may have stored textContent before this Task 2 change, and the bidi test
|
|
849
|
+
// explicitly stores textContent). Using stripBidi keeps the baseline consistent with the
|
|
850
|
+
// session's offset space regardless of how committedText was stored.
|
|
851
|
+
const old = stripBidi(el.dataset.committedText ?? "");
|
|
852
|
+
const next = blockContentText(el);
|
|
853
|
+
if (old === next)
|
|
854
|
+
return null;
|
|
855
|
+
if (old.trim().length === 0) {
|
|
856
|
+
return this.parseEdit(this.exports.DocxSessionBridge.ReplaceText(this.handle, fullId, serializeInlineMarkdown(el)));
|
|
857
|
+
}
|
|
858
|
+
const minLen = Math.min(old.length, next.length);
|
|
859
|
+
let p = 0;
|
|
860
|
+
while (p < minLen && old[p] === next[p])
|
|
861
|
+
p++;
|
|
862
|
+
let s = 0;
|
|
863
|
+
while (s < minLen - p && old[old.length - 1 - s] === next[next.length - 1 - s])
|
|
864
|
+
s++;
|
|
865
|
+
let start = p;
|
|
866
|
+
let len = old.length - p - s;
|
|
867
|
+
let middle = next.slice(p, next.length - s);
|
|
868
|
+
// A pure insertion is a zero-length span, which resolves to no runs and is rejected. Anchor a
|
|
869
|
+
// neighbor char so the span is non-empty and the inserted text inherits an adjacent run's rPr
|
|
870
|
+
// (the LEFT run when there is one, matching contenteditable; the first run at the very start).
|
|
871
|
+
if (len === 0) {
|
|
872
|
+
if (start > 0) {
|
|
873
|
+
start -= 1;
|
|
874
|
+
len = 1;
|
|
875
|
+
middle = old[start] + middle;
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
len = 1;
|
|
879
|
+
middle = middle + old[0];
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return this.parseEdit(this.exports.DocxSessionBridge.ReplaceTextAtSpan(this.handle, fullId, start, len, middle));
|
|
883
|
+
}
|
|
884
|
+
/** Flush a block's current (uncommitted) text to the session; returns the live full id. */
|
|
885
|
+
syncBlock(el, fullId) {
|
|
886
|
+
const result = this.commitTextChange(el, fullId);
|
|
887
|
+
if (!result || !result.success)
|
|
888
|
+
return fullId;
|
|
889
|
+
el.dataset.committedText = blockContentText(el);
|
|
890
|
+
return result.modified?.[0]?.id ?? fullId;
|
|
891
|
+
}
|
|
892
|
+
/** Render a block by anchor and parse it into a detached element (null on error). */
|
|
893
|
+
renderInto(anchorId) {
|
|
894
|
+
const html = this.exports.DocxSessionBridge.RenderBlockHtml(this.handle, anchorId, this.options.cssPrefix, this.options.fabricateClasses);
|
|
895
|
+
if (html.charCodeAt(0) === 0x7b /* error object */)
|
|
896
|
+
return null;
|
|
897
|
+
return new DOMParser().parseFromString(html, "text/html").body.firstElementChild;
|
|
898
|
+
}
|
|
899
|
+
/** The editable block immediately before `el` in document order, or null. */
|
|
900
|
+
previousEditable(el) {
|
|
901
|
+
const all = Array.from(this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]'));
|
|
902
|
+
const i = all.indexOf(el);
|
|
903
|
+
return i > 0 ? all[i - 1] : null;
|
|
904
|
+
}
|
|
905
|
+
parseEdit(json) {
|
|
906
|
+
try {
|
|
907
|
+
return JSON.parse(json);
|
|
908
|
+
}
|
|
909
|
+
catch {
|
|
910
|
+
return { success: false };
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
// ─── M5: formatting commands (ribbon) ────────────────────────────────
|
|
914
|
+
// ─── Multi-block selection helpers (format a whole stack of paragraphs at once) ──────
|
|
915
|
+
/** Editable blocks the current selection covers, in document order. Uses Range.comparePoint
|
|
916
|
+
* (robust to a selection boundary that normalized onto a wrapper element rather than a block
|
|
917
|
+
* or text node — Range.intersectsNode misses the end block at a `(block, childCount)` boundary).
|
|
918
|
+
* A collapsed or single-block selection yields just the active block. */
|
|
919
|
+
selectedBlocks() {
|
|
920
|
+
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
921
|
+
const all = Array.from(this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]'));
|
|
922
|
+
if (sel && sel.rangeCount > 0 && !sel.isCollapsed) {
|
|
923
|
+
const range = sel.getRangeAt(0);
|
|
924
|
+
const hit = all.filter((b) => {
|
|
925
|
+
try {
|
|
926
|
+
const startsAfterEnd = range.comparePoint(b, 0) > 0; // block begins after the selection ends
|
|
927
|
+
const endsBeforeStart = range.comparePoint(b, b.childNodes.length) < 0; // block ends before it starts
|
|
928
|
+
return !startsAfterEnd && !endsBeforeStart;
|
|
929
|
+
}
|
|
930
|
+
catch {
|
|
931
|
+
return false;
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
if (hit.length > 1)
|
|
935
|
+
return hit;
|
|
936
|
+
}
|
|
937
|
+
return this.activeBlock ? [this.activeBlock] : [];
|
|
938
|
+
}
|
|
939
|
+
/** The selection's span within `block`, clipped to the block (for inline ops across blocks):
|
|
940
|
+
* the first block runs selection-start→end-of-block, middle blocks are whole, the last block
|
|
941
|
+
* runs start-of-block→selection-end. Returns null for a whole-block apply. */
|
|
942
|
+
blockSpanForSelection(block) {
|
|
943
|
+
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
944
|
+
if (!sel || sel.rangeCount === 0 || sel.isCollapsed)
|
|
945
|
+
return null;
|
|
946
|
+
const range = sel.getRangeAt(0);
|
|
947
|
+
const hasStart = block.contains(range.startContainer);
|
|
948
|
+
const hasEnd = block.contains(range.endContainer);
|
|
949
|
+
if (hasStart && hasEnd)
|
|
950
|
+
return selectionSpanIn(block);
|
|
951
|
+
const contentLen = blockContentText(block).length;
|
|
952
|
+
if (hasStart) {
|
|
953
|
+
const start = contentOffsetOf(block, range.startContainer, range.startOffset);
|
|
954
|
+
return { start, length: Math.max(0, contentLen - start) };
|
|
955
|
+
}
|
|
956
|
+
if (hasEnd) {
|
|
957
|
+
const end = contentOffsetOf(block, range.endContainer, range.endOffset);
|
|
958
|
+
return { start: 0, length: end };
|
|
959
|
+
}
|
|
960
|
+
return { start: 0, length: contentLen }; // fully-spanned middle block
|
|
961
|
+
}
|
|
962
|
+
/** Apply an inline ApplyFormat op to each block's slice of the selection, then reconcile
|
|
963
|
+
* the DOM incrementally (see {@link finishMultiBlockOp} — a full remount costs a whole-
|
|
964
|
+
* document convert, seconds on a large doc, where per-block swaps are ~10 ms each).
|
|
965
|
+
* Returns false (caller falls back to the single-block path) for a 1-block selection. */
|
|
966
|
+
applyInlineOpAcrossBlocks(blocks, op) {
|
|
967
|
+
if (blocks.length <= 1)
|
|
968
|
+
return false;
|
|
969
|
+
const targets = this.multiBlockTargets(blocks);
|
|
970
|
+
for (const t of targets) {
|
|
971
|
+
if (!t.fullId || (t.span && t.span.length === 0))
|
|
972
|
+
continue;
|
|
973
|
+
const synced = this.syncBlock(t.block, t.fullId);
|
|
974
|
+
const res = this.parseEdit(this.exports.DocxSessionBridge.ApplyFormat(this.handle, synced, t.span ? JSON.stringify(t.span) : "", JSON.stringify(op)));
|
|
975
|
+
if (res.success)
|
|
976
|
+
t.res = res;
|
|
977
|
+
}
|
|
978
|
+
this.finishMultiBlockOp(targets, false);
|
|
979
|
+
return true;
|
|
980
|
+
}
|
|
981
|
+
/** Apply a whole-block (paragraph-level) op to each selected block, then reconcile the DOM
|
|
982
|
+
* incrementally ({@link finishMultiBlockOp}). `forceRemount` is for ops whose rendering
|
|
983
|
+
* needs whole-document context (e.g. border-div regrouping after clearBorders). Returns
|
|
984
|
+
* false for a 1-block selection (caller uses the single-block path). */
|
|
985
|
+
applyParagraphOpAcrossBlocks(blocks, run, forceRemount = false) {
|
|
986
|
+
if (blocks.length <= 1)
|
|
987
|
+
return false;
|
|
988
|
+
const targets = this.multiBlockTargets(blocks);
|
|
989
|
+
for (const t of targets) {
|
|
990
|
+
if (!t.fullId)
|
|
991
|
+
continue;
|
|
992
|
+
const synced = this.syncBlock(t.block, t.fullId);
|
|
993
|
+
const res = this.parseEdit(run(synced));
|
|
994
|
+
if (res.success)
|
|
995
|
+
t.res = res;
|
|
996
|
+
}
|
|
997
|
+
this.finishMultiBlockOp(targets, forceRemount);
|
|
998
|
+
return true;
|
|
999
|
+
}
|
|
1000
|
+
/** Snapshot each selected block's identity + selection slice BEFORE any session op runs
|
|
1001
|
+
* (ops never mutate the DOM, so spans captured here stay valid until the swap phase). */
|
|
1002
|
+
multiBlockTargets(blocks) {
|
|
1003
|
+
return blocks.map((b) => {
|
|
1004
|
+
const unid = b.getAttribute("data-anchor");
|
|
1005
|
+
return {
|
|
1006
|
+
block: b,
|
|
1007
|
+
unid,
|
|
1008
|
+
fullId: unid ? this.unidToFullId.get(unid) : undefined,
|
|
1009
|
+
span: this.blockSpanForSelection(b),
|
|
1010
|
+
res: null,
|
|
1011
|
+
};
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Reconcile the DOM after a multi-block op. Fidelity-identical to the single-block path by
|
|
1016
|
+
* construction: each edited block is swapped for its own session-attached single-block
|
|
1017
|
+
* render — exactly what format()/setFontSize()/applyParagraphFormat() do for one block —
|
|
1018
|
+
* so a multi-block apply is N single-block applies, not one whole-document re-render
|
|
1019
|
+
* (which froze the UI for the full-document convert time on every ribbon action). Falls
|
|
1020
|
+
* back to ONE full remount when the op touched a list item (numbering continuation needs
|
|
1021
|
+
* whole-document context) or the caller forced it. Restores the cross-block selection so
|
|
1022
|
+
* consecutive ribbon actions (center, then bold) keep targeting the same range.
|
|
1023
|
+
*/
|
|
1024
|
+
finishMultiBlockOp(targets, forceRemount) {
|
|
1025
|
+
const edited = targets.filter((t) => t.res && t.unid);
|
|
1026
|
+
if (edited.length === 0)
|
|
1027
|
+
return;
|
|
1028
|
+
// Paginated mode: a multi-block format change can alter block heights, and page boxes
|
|
1029
|
+
// need a reflow — keep the (pre-existing) full remount there until M4 lands a scoped
|
|
1030
|
+
// re-paginate. Continuous mode reconciles incrementally.
|
|
1031
|
+
if (forceRemount || this.options.paginated || edited.some((t) => this.affectsList(t.res))) {
|
|
1032
|
+
this.remount();
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
const swapped = [];
|
|
1036
|
+
for (const t of edited) {
|
|
1037
|
+
const fresh = this.swapBlock(t.block, t.unid, t.res.modified?.[0]);
|
|
1038
|
+
swapped.push({ el: fresh ?? t.block, span: t.span });
|
|
1039
|
+
}
|
|
1040
|
+
const first = swapped[0];
|
|
1041
|
+
const last = swapped[swapped.length - 1];
|
|
1042
|
+
if (!first.el.isConnected || !last.el.isConnected)
|
|
1043
|
+
return;
|
|
1044
|
+
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
1045
|
+
if (!sel)
|
|
1046
|
+
return;
|
|
1047
|
+
const startOff = first.span?.start ?? 0;
|
|
1048
|
+
const endOff = last.span ? last.span.start + last.span.length : blockContentText(last.el).length;
|
|
1049
|
+
const startPos = contentPositionIn(first.el, startOff);
|
|
1050
|
+
const endPos = contentPositionIn(last.el, endOff);
|
|
1051
|
+
try {
|
|
1052
|
+
const range = document.createRange();
|
|
1053
|
+
range.setStart(startPos.node, startPos.offset);
|
|
1054
|
+
range.setEnd(endPos.node, endPos.offset);
|
|
1055
|
+
sel.removeAllRanges();
|
|
1056
|
+
sel.addRange(range);
|
|
1057
|
+
}
|
|
1058
|
+
catch {
|
|
1059
|
+
/* selection restore is best-effort — the edits themselves are already committed */
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Toggle (or set) an inline format on the current selection in the active block.
|
|
1064
|
+
* A selection spanning multiple blocks applies to each. With no selection, applies to
|
|
1065
|
+
* the whole paragraph. Routes through DocxSession (`ApplyFormat`) so it is lossless and
|
|
1066
|
+
* supports underline/strike, not just markdown.
|
|
1067
|
+
*/
|
|
1068
|
+
format(key, value) {
|
|
1069
|
+
const block = this.activeBlock;
|
|
1070
|
+
if (this.closed || !block)
|
|
1071
|
+
return;
|
|
1072
|
+
const blocks = this.selectedBlocks();
|
|
1073
|
+
if (blocks.length > 1) {
|
|
1074
|
+
const on0 = value ?? !selectionHasFormat(key, blocks[0]);
|
|
1075
|
+
const multiOp = key === "superscript" || key === "subscript"
|
|
1076
|
+
? { vertAlign: on0 ? key : "" }
|
|
1077
|
+
: { [key]: on0 };
|
|
1078
|
+
if (this.applyInlineOpAcrossBlocks(blocks, multiOp))
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
const unid = block.getAttribute("data-anchor");
|
|
1082
|
+
if (!unid)
|
|
1083
|
+
return;
|
|
1084
|
+
let fullId = this.unidToFullId.get(unid);
|
|
1085
|
+
if (!fullId)
|
|
1086
|
+
return;
|
|
1087
|
+
const span = selectionSpanIn(block);
|
|
1088
|
+
const on = value ?? !selectionHasFormat(key, block);
|
|
1089
|
+
// Super/subscript map to the single-valued w:vertAlign; the rest are boolean toggles.
|
|
1090
|
+
const op = key === "superscript" || key === "subscript"
|
|
1091
|
+
? { vertAlign: on ? key : "" }
|
|
1092
|
+
: { [key]: on };
|
|
1093
|
+
fullId = this.syncBlock(block, fullId); // don't clobber uncommitted typing
|
|
1094
|
+
const res = this.parseEdit(this.exports.DocxSessionBridge.ApplyFormat(this.handle, fullId, span ? JSON.stringify(span) : "", JSON.stringify(op)));
|
|
1095
|
+
if (!res.success)
|
|
1096
|
+
return;
|
|
1097
|
+
if (this.affectsList(res)) {
|
|
1098
|
+
this.remount(this.blockIndex(block), false);
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
const fresh = this.swapBlock(block, unid, res.modified?.[0]);
|
|
1102
|
+
if (fresh && span)
|
|
1103
|
+
selectRange(fresh, span.start, span.length);
|
|
1104
|
+
else
|
|
1105
|
+
fresh?.focus();
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Set the font size (in points) of the current selection in the active block; with no
|
|
1109
|
+
* selection, applies to the whole paragraph. `pts <= 0` clears the explicit size. Routes
|
|
1110
|
+
* through DocxSession `ApplyFormat` (`w:sz`), so it is lossless and survives save.
|
|
1111
|
+
*/
|
|
1112
|
+
setFontSize(pts) {
|
|
1113
|
+
const block = this.activeBlock;
|
|
1114
|
+
if (this.closed || !block)
|
|
1115
|
+
return;
|
|
1116
|
+
const blocks = this.selectedBlocks();
|
|
1117
|
+
if (blocks.length > 1 && this.applyInlineOpAcrossBlocks(blocks, { fontSizePts: pts }))
|
|
1118
|
+
return;
|
|
1119
|
+
const unid = block.getAttribute("data-anchor");
|
|
1120
|
+
if (!unid)
|
|
1121
|
+
return;
|
|
1122
|
+
let fullId = this.unidToFullId.get(unid);
|
|
1123
|
+
if (!fullId)
|
|
1124
|
+
return;
|
|
1125
|
+
// Use the live selection; if the font-size combobox stole focus and collapsed it, fall back to
|
|
1126
|
+
// the last real selection cached for this block so a sub-range still sizes (finding 3).
|
|
1127
|
+
let span = selectionSpanIn(block);
|
|
1128
|
+
if (!span && this.lastSelection && this.lastSelection.unid === unid)
|
|
1129
|
+
span = this.lastSelection.span;
|
|
1130
|
+
fullId = this.syncBlock(block, fullId);
|
|
1131
|
+
const res = this.parseEdit(this.exports.DocxSessionBridge.ApplyFormat(this.handle, fullId, span ? JSON.stringify(span) : "", JSON.stringify({ fontSizePts: pts })));
|
|
1132
|
+
if (!res.success)
|
|
1133
|
+
return;
|
|
1134
|
+
if (this.affectsList(res)) {
|
|
1135
|
+
this.remount(this.blockIndex(block), false);
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
const fresh = this.swapBlock(block, unid, res.modified?.[0]);
|
|
1139
|
+
if (fresh && span)
|
|
1140
|
+
selectRange(fresh, span.start, span.length);
|
|
1141
|
+
else
|
|
1142
|
+
fresh?.focus();
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* Set the font family of the current selection in the active block; with no selection,
|
|
1146
|
+
* applies to the whole paragraph. `""` clears the explicit font (inherits the style/default).
|
|
1147
|
+
* Routes through DocxSession `ApplyFormat` (`w:rFonts`), so it is lossless and survives save.
|
|
1148
|
+
* Multi-block + last-selection plumbing matches {@link setFontSize} (a focus-stealing font
|
|
1149
|
+
* dropdown still applies to the real sub-range).
|
|
1150
|
+
*/
|
|
1151
|
+
setFontFamily(name) {
|
|
1152
|
+
const block = this.activeBlock;
|
|
1153
|
+
if (this.closed || !block)
|
|
1154
|
+
return;
|
|
1155
|
+
const blocks = this.selectedBlocks();
|
|
1156
|
+
if (blocks.length > 1 && this.applyInlineOpAcrossBlocks(blocks, { fontFamily: name }))
|
|
1157
|
+
return;
|
|
1158
|
+
const unid = block.getAttribute("data-anchor");
|
|
1159
|
+
if (!unid)
|
|
1160
|
+
return;
|
|
1161
|
+
let fullId = this.unidToFullId.get(unid);
|
|
1162
|
+
if (!fullId)
|
|
1163
|
+
return;
|
|
1164
|
+
let span = selectionSpanIn(block);
|
|
1165
|
+
if (!span && this.lastSelection && this.lastSelection.unid === unid)
|
|
1166
|
+
span = this.lastSelection.span;
|
|
1167
|
+
fullId = this.syncBlock(block, fullId);
|
|
1168
|
+
const res = this.parseEdit(this.exports.DocxSessionBridge.ApplyFormat(this.handle, fullId, span ? JSON.stringify(span) : "", JSON.stringify({ fontFamily: name })));
|
|
1169
|
+
if (!res.success)
|
|
1170
|
+
return;
|
|
1171
|
+
if (this.affectsList(res)) {
|
|
1172
|
+
this.remount(this.blockIndex(block), false);
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
const fresh = this.swapBlock(block, unid, res.modified?.[0]);
|
|
1176
|
+
if (fresh && span)
|
|
1177
|
+
selectRange(fresh, span.start, span.length);
|
|
1178
|
+
else
|
|
1179
|
+
fresh?.focus();
|
|
1180
|
+
}
|
|
1181
|
+
/** Set paragraph alignment (left/center/right/justify) on the active block. */
|
|
1182
|
+
setAlignment(alignment) {
|
|
1183
|
+
this.applyParagraphFormat({ alignment });
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Insert an S-1-style horizontal rule (an empty paragraph with a bottom border) after the
|
|
1187
|
+
* active block. `weight` is the rule thickness in eighths of a point (default 12 ≈ 1.5pt).
|
|
1188
|
+
* Re-renders fully (a new block needs whole-document context to lay out).
|
|
1189
|
+
*/
|
|
1190
|
+
insertHorizontalRule(weight = 12, style = "single", position = "below") {
|
|
1191
|
+
const block = this.activeBlock;
|
|
1192
|
+
if (this.closed || !block)
|
|
1193
|
+
return;
|
|
1194
|
+
const unid = block.getAttribute("data-anchor");
|
|
1195
|
+
if (!unid)
|
|
1196
|
+
return;
|
|
1197
|
+
let fullId = this.unidToFullId.get(unid);
|
|
1198
|
+
if (!fullId)
|
|
1199
|
+
return;
|
|
1200
|
+
const idx = this.blockIndex(block);
|
|
1201
|
+
fullId = this.syncBlock(block, fullId);
|
|
1202
|
+
const res = this.parseEdit(this.exports.DocxSessionBridge.InsertHorizontalRule(this.handle, fullId, position === "above" ? "before" : "after", JSON.stringify({ style, size: weight, color: "auto" })));
|
|
1203
|
+
if (!res.success)
|
|
1204
|
+
return;
|
|
1205
|
+
// remount from the active block's index re-renders the new rule whether it landed just
|
|
1206
|
+
// above (at idx) or just below (at idx+1) the active block.
|
|
1207
|
+
this.remount(idx, false);
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Insert a `rows`×`cols` table after the active block. `options.cellContents` (row-major
|
|
1211
|
+
* markdown) seeds the cells, `options.borderless` makes an invisible layout table, and
|
|
1212
|
+
* `options.cellAlignment` aligns every cell. Re-renders fully (tables need document context).
|
|
1213
|
+
*/
|
|
1214
|
+
insertTable(rows, cols, options) {
|
|
1215
|
+
const block = this.activeBlock;
|
|
1216
|
+
if (this.closed || !block)
|
|
1217
|
+
return;
|
|
1218
|
+
const unid = block.getAttribute("data-anchor");
|
|
1219
|
+
if (!unid)
|
|
1220
|
+
return;
|
|
1221
|
+
let fullId = this.unidToFullId.get(unid);
|
|
1222
|
+
if (!fullId)
|
|
1223
|
+
return;
|
|
1224
|
+
const idx = this.blockIndex(block);
|
|
1225
|
+
fullId = this.syncBlock(block, fullId);
|
|
1226
|
+
// If the caret is on an empty paragraph (not a table cell), insert the table BEFORE it so the
|
|
1227
|
+
// empty paragraph becomes the editable line BELOW the table — no stray blank line above it, and
|
|
1228
|
+
// a reachable paragraph below (S-1 smoke-test findings 2 + 4). Otherwise insert after.
|
|
1229
|
+
const emptyHere = !block.closest("table") && blockContentText(block).replace(/[\s ]+/g, "").length === 0;
|
|
1230
|
+
const res = this.parseEdit(this.exports.DocxSessionBridge.InsertTable(this.handle, fullId, emptyHere ? "before" : "after", rows, cols, options ? JSON.stringify(options) : ""));
|
|
1231
|
+
if (!res.success)
|
|
1232
|
+
return;
|
|
1233
|
+
this.remount(idx, false);
|
|
1234
|
+
}
|
|
1235
|
+
// ─── Table row / column editing (active block must be inside a table cell) ──────────
|
|
1236
|
+
/** Run a table-structure op on the active cell (a cell-paragraph block) and re-render. */
|
|
1237
|
+
tableEdit(run) {
|
|
1238
|
+
const block = this.activeBlock;
|
|
1239
|
+
if (this.closed || !block || !block.closest("table"))
|
|
1240
|
+
return;
|
|
1241
|
+
const unid = block.getAttribute("data-anchor");
|
|
1242
|
+
if (!unid)
|
|
1243
|
+
return;
|
|
1244
|
+
let fullId = this.unidToFullId.get(unid);
|
|
1245
|
+
if (!fullId)
|
|
1246
|
+
return;
|
|
1247
|
+
const idx = this.blockIndex(block);
|
|
1248
|
+
fullId = this.syncBlock(block, fullId); // flush uncommitted cell text first
|
|
1249
|
+
const res = this.parseEdit(run(fullId));
|
|
1250
|
+
if (!res.success)
|
|
1251
|
+
return;
|
|
1252
|
+
this.remount(idx, false);
|
|
1253
|
+
}
|
|
1254
|
+
/** Insert a row above/below the active cell's row. No-op outside a table. */
|
|
1255
|
+
insertTableRow(where) {
|
|
1256
|
+
this.tableEdit((a) => this.exports.DocxSessionBridge.InsertTableRow(this.handle, a, where === "above" ? "before" : "after"));
|
|
1257
|
+
}
|
|
1258
|
+
/** Insert a column left/right of the active cell's column. No-op outside a table. */
|
|
1259
|
+
insertTableColumn(where) {
|
|
1260
|
+
this.tableEdit((a) => this.exports.DocxSessionBridge.InsertTableColumn(this.handle, a, where === "left" ? "before" : "after"));
|
|
1261
|
+
}
|
|
1262
|
+
/** Delete the active cell's row (deleting the last row removes the table). No-op outside a table. */
|
|
1263
|
+
deleteTableRow() {
|
|
1264
|
+
this.tableEdit((a) => this.exports.DocxSessionBridge.DeleteTableRow(this.handle, a));
|
|
1265
|
+
}
|
|
1266
|
+
/** Delete the active cell's column (deleting the last column removes the table). No-op outside a table. */
|
|
1267
|
+
deleteTableColumn() {
|
|
1268
|
+
this.tableEdit((a) => this.exports.DocxSessionBridge.DeleteTableColumn(this.handle, a));
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* Indent/outdent the active block. On a LIST item this changes the list NESTING LEVEL
|
|
1272
|
+
* (`SetListLevel`) so numbering nests (e.g. 1, 2 → a sub-level) rather than the item just
|
|
1273
|
+
* shifting sideways with flat numbering. On a plain paragraph it adjusts the left indent by
|
|
1274
|
+
* `deltaTwips` (default ±720 = 0.5"), clamped at 0.
|
|
1275
|
+
*/
|
|
1276
|
+
indent(deltaTwips = 720) {
|
|
1277
|
+
if (this.activeBlock && isListBlock(this.activeBlock)) {
|
|
1278
|
+
this.setListLevel(deltaTwips >= 0 ? 1 : -1);
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
this.applyParagraphFormat({ indentDelta: deltaTwips });
|
|
1282
|
+
}
|
|
1283
|
+
/** Change the active list item's nesting level by `delta` (+1 deeper, −1 shallower). */
|
|
1284
|
+
setListLevel(delta) {
|
|
1285
|
+
const block = this.activeBlock;
|
|
1286
|
+
if (this.closed || !block)
|
|
1287
|
+
return;
|
|
1288
|
+
const unid = block.getAttribute("data-anchor");
|
|
1289
|
+
if (!unid)
|
|
1290
|
+
return;
|
|
1291
|
+
let fullId = this.unidToFullId.get(unid);
|
|
1292
|
+
if (!fullId)
|
|
1293
|
+
return;
|
|
1294
|
+
const idx = this.blockIndex(block);
|
|
1295
|
+
fullId = this.syncBlock(block, fullId);
|
|
1296
|
+
const res = this.parseEdit(this.exports.DocxSessionBridge.SetListLevel(this.handle, fullId, delta));
|
|
1297
|
+
if (!res.success)
|
|
1298
|
+
return;
|
|
1299
|
+
// A level change ripples through the whole list's numbering — re-render with full document
|
|
1300
|
+
// context (a single-block render can't compute nested numbering), keeping the caret in place.
|
|
1301
|
+
this.remount(idx, false);
|
|
1302
|
+
}
|
|
1303
|
+
/** Toggle (or set) page-break-before on the active block. */
|
|
1304
|
+
pageBreakBefore(value = true) {
|
|
1305
|
+
this.applyParagraphFormat({ pageBreakBefore: value });
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Toggle the active block between a bullet/numbered list item and a plain paragraph.
|
|
1309
|
+
* Clicking the same kind it already is removes the list; any other state applies the kind.
|
|
1310
|
+
*/
|
|
1311
|
+
toggleList(kind) {
|
|
1312
|
+
const block = this.activeBlock;
|
|
1313
|
+
if (this.closed || !block)
|
|
1314
|
+
return;
|
|
1315
|
+
const unid = block.getAttribute("data-anchor");
|
|
1316
|
+
if (!unid)
|
|
1317
|
+
return;
|
|
1318
|
+
let fullId = this.unidToFullId.get(unid);
|
|
1319
|
+
if (!fullId)
|
|
1320
|
+
return;
|
|
1321
|
+
let membership = null;
|
|
1322
|
+
try {
|
|
1323
|
+
membership = JSON.parse(this.exports.DocxSessionBridge.GetListMembership(this.handle, fullId));
|
|
1324
|
+
}
|
|
1325
|
+
catch { /* treat as not-a-list */ }
|
|
1326
|
+
const isThisKind = !!membership && typeof membership.format === "string" &&
|
|
1327
|
+
membership.format.toLowerCase().startsWith(kind === "bullet" ? "bullet" : "decimal");
|
|
1328
|
+
const idx = this.blockIndex(block); // capture before the op
|
|
1329
|
+
fullId = this.syncBlock(block, fullId);
|
|
1330
|
+
const res = this.parseEdit(this.exports.DocxSessionBridge.ApplyListFormat(this.handle, fullId, isThisKind ? "none" : kind));
|
|
1331
|
+
if (!res.success)
|
|
1332
|
+
return;
|
|
1333
|
+
// Numbering continuation across the list needs whole-document context — re-render fully
|
|
1334
|
+
// (a single-block render would show every numbered item as "1.").
|
|
1335
|
+
this.remount(idx, false);
|
|
1336
|
+
}
|
|
1337
|
+
/** Clear all paragraph borders (e.g. remove an inserted horizontal rule) on the active block —
|
|
1338
|
+
* or every block in a multi-block selection. The engine/wire already accept `clearBorders`;
|
|
1339
|
+
* this surfaces it on the editor so an HR border is removable (S-1 smoke-test finding 1b). */
|
|
1340
|
+
clearParagraphBorders() {
|
|
1341
|
+
this.applyParagraphFormat({ clearBorders: true });
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Delete the active block (e.g. a stray empty paragraph left above/below a table). Routes
|
|
1345
|
+
* through DocxSession `DeleteBlock` + re-render, focusing the previous block. No-op when the
|
|
1346
|
+
* caret is inside a table (remove cells via the table toolbar's delete row/column instead) and
|
|
1347
|
+
* no-op when it is the only editable block (don't empty the document). Closes the S-1
|
|
1348
|
+
* smoke-test "no block-delete affordance" gap.
|
|
1349
|
+
*/
|
|
1350
|
+
deleteBlock() {
|
|
1351
|
+
const block = this.activeBlock;
|
|
1352
|
+
if (this.closed || !block)
|
|
1353
|
+
return;
|
|
1354
|
+
if (block.closest("table"))
|
|
1355
|
+
return; // cells are removed via the table toolbar, not here
|
|
1356
|
+
if (this.editableList().length <= 1)
|
|
1357
|
+
return; // never delete the last editable block
|
|
1358
|
+
const unid = block.getAttribute("data-anchor");
|
|
1359
|
+
if (!unid)
|
|
1360
|
+
return;
|
|
1361
|
+
const fullId = this.unidToFullId.get(unid);
|
|
1362
|
+
if (!fullId)
|
|
1363
|
+
return;
|
|
1364
|
+
const idx = this.blockIndex(block);
|
|
1365
|
+
const res = this.parseEdit(this.exports.DocxSessionBridge.DeleteBlock(this.handle, fullId));
|
|
1366
|
+
if (!res.success)
|
|
1367
|
+
return;
|
|
1368
|
+
this.remount(Math.max(0, idx - 1), true);
|
|
1369
|
+
}
|
|
1370
|
+
applyParagraphFormat(op) {
|
|
1371
|
+
const block = this.activeBlock;
|
|
1372
|
+
if (this.closed || !block)
|
|
1373
|
+
return;
|
|
1374
|
+
const blocks = this.selectedBlocks();
|
|
1375
|
+
if (blocks.length > 1 &&
|
|
1376
|
+
this.applyParagraphOpAcrossBlocks(blocks, (id) => this.exports.DocxSessionBridge.SetParagraphFormat(this.handle, id, JSON.stringify(op)),
|
|
1377
|
+
// A border change regroups the wrapping border <div>s — whole-document context.
|
|
1378
|
+
!!op.clearBorders))
|
|
1379
|
+
return;
|
|
1380
|
+
const unid = block.getAttribute("data-anchor");
|
|
1381
|
+
if (!unid)
|
|
1382
|
+
return;
|
|
1383
|
+
let fullId = this.unidToFullId.get(unid);
|
|
1384
|
+
if (!fullId)
|
|
1385
|
+
return;
|
|
1386
|
+
const idx = this.blockIndex(block);
|
|
1387
|
+
fullId = this.syncBlock(block, fullId);
|
|
1388
|
+
const res = this.parseEdit(this.exports.DocxSessionBridge.SetParagraphFormat(this.handle, fullId, JSON.stringify(op)));
|
|
1389
|
+
if (!res.success)
|
|
1390
|
+
return;
|
|
1391
|
+
// A border change adds/removes the wrapping border <div>, so a single-block swap can't restructure
|
|
1392
|
+
// it correctly — re-render fully (like list edits) so the wrapper appears/disappears cleanly.
|
|
1393
|
+
if (this.affectsList(res) || op.clearBorders) {
|
|
1394
|
+
this.remount(idx, false);
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
this.swapBlock(block, unid, res.modified?.[0])?.focus();
|
|
1398
|
+
}
|
|
1399
|
+
/** Set the paragraph style of the active block — or of every block in a multi-block selection
|
|
1400
|
+
* (e.g. "Heading1", "Heading2", "Normal"). */
|
|
1401
|
+
setParagraphStyle(styleId) {
|
|
1402
|
+
const block = this.activeBlock;
|
|
1403
|
+
if (this.closed || !block)
|
|
1404
|
+
return;
|
|
1405
|
+
const blocks = this.selectedBlocks();
|
|
1406
|
+
if (blocks.length > 1 &&
|
|
1407
|
+
this.applyParagraphOpAcrossBlocks(blocks, (id) => this.exports.DocxSessionBridge.SetParagraphStyle(this.handle, id, styleId)))
|
|
1408
|
+
return;
|
|
1409
|
+
const unid = block.getAttribute("data-anchor");
|
|
1410
|
+
if (!unid)
|
|
1411
|
+
return;
|
|
1412
|
+
let fullId = this.unidToFullId.get(unid);
|
|
1413
|
+
if (!fullId)
|
|
1414
|
+
return;
|
|
1415
|
+
const idx = this.blockIndex(block);
|
|
1416
|
+
fullId = this.syncBlock(block, fullId);
|
|
1417
|
+
const res = this.parseEdit(this.exports.DocxSessionBridge.SetParagraphStyle(this.handle, fullId, styleId));
|
|
1418
|
+
if (!res.success)
|
|
1419
|
+
return;
|
|
1420
|
+
if (this.affectsList(res)) {
|
|
1421
|
+
this.remount(idx, false);
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
this.swapBlock(block, unid, res.modified?.[0])?.focus();
|
|
1425
|
+
}
|
|
1426
|
+
/** Undo the last edit (re-renders the document). */
|
|
1427
|
+
undo() {
|
|
1428
|
+
if (this.closed)
|
|
1429
|
+
return;
|
|
1430
|
+
if (this.exports.DocxSessionBridge.Undo(this.handle))
|
|
1431
|
+
this.remount();
|
|
1432
|
+
}
|
|
1433
|
+
/** Redo the last undone edit (re-renders the document). */
|
|
1434
|
+
redo() {
|
|
1435
|
+
if (this.closed)
|
|
1436
|
+
return;
|
|
1437
|
+
if (this.exports.DocxSessionBridge.Redo(this.handle))
|
|
1438
|
+
this.remount();
|
|
1439
|
+
}
|
|
1440
|
+
/** Which inline formats the current selection carries — for ribbon button highlighting. */
|
|
1441
|
+
queryFormatState() {
|
|
1442
|
+
const block = this.activeBlock ?? this.editRoot;
|
|
1443
|
+
return {
|
|
1444
|
+
bold: selectionHasFormat("bold", block),
|
|
1445
|
+
italic: selectionHasFormat("italic", block),
|
|
1446
|
+
underline: selectionHasFormat("underline", block),
|
|
1447
|
+
strike: selectionHasFormat("strike", block),
|
|
1448
|
+
code: selectionHasFormat("code", block),
|
|
1449
|
+
superscript: selectionHasFormat("superscript", block),
|
|
1450
|
+
subscript: selectionHasFormat("subscript", block),
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
/** Re-render one block from the live session by EditResult ref, swapping it in place. */
|
|
1454
|
+
swapBlock(oldEl, oldUnid, ref) {
|
|
1455
|
+
const anchorId = ref?.id ?? this.unidToFullId.get(oldUnid);
|
|
1456
|
+
const newUnid = ref?.unid ?? oldUnid;
|
|
1457
|
+
if (!anchorId)
|
|
1458
|
+
return null;
|
|
1459
|
+
const fresh = this.renderInto(anchorId);
|
|
1460
|
+
if (!fresh || !this.replaceNode(oldEl, fresh))
|
|
1461
|
+
return null;
|
|
1462
|
+
this.unidToFullId.delete(oldUnid);
|
|
1463
|
+
this.unidToFullId.set(newUnid, anchorId);
|
|
1464
|
+
this.wireBlock(fresh);
|
|
1465
|
+
this.activeBlock = fresh;
|
|
1466
|
+
this.options.onEdit?.({ anchorId, unid: newUnid });
|
|
1467
|
+
return fresh;
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Full-document HTML from the live session. Prefers the session-attached
|
|
1471
|
+
* `RenderHtml` bridge — the saved bytes never cross the JS/WASM boundary
|
|
1472
|
+
* (two multi-MB copies per remount on a large doc) — and falls back to
|
|
1473
|
+
* Save + ConvertDocxToHtmlComplete for older WASM bundles. Both paths use
|
|
1474
|
+
* the same option profile, so the rendered HTML is identical.
|
|
1475
|
+
*/
|
|
1476
|
+
renderFullHtml() {
|
|
1477
|
+
const bridge = this.exports.DocxSessionBridge;
|
|
1478
|
+
if (typeof bridge.RenderHtml === "function") {
|
|
1479
|
+
const html = bridge.RenderHtml(this.handle, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale);
|
|
1480
|
+
if (html.charCodeAt(0) !== 0x7b /* not an error object */)
|
|
1481
|
+
return html;
|
|
1482
|
+
}
|
|
1483
|
+
const bytes = bridge.Save(this.handle);
|
|
1484
|
+
return this.exports.DocumentConverter.ConvertDocxToHtmlComplete(...completeArgs(bytes, this.options.cssPrefix, this.options.fabricateClasses, this.options.paginated, this.options.scale));
|
|
1485
|
+
}
|
|
1486
|
+
/** Editable blocks in document order. */
|
|
1487
|
+
editableList() {
|
|
1488
|
+
return Array.from(this.editRoot.querySelectorAll('[data-anchor][contenteditable="true"]'));
|
|
1489
|
+
}
|
|
1490
|
+
blockIndex(el) {
|
|
1491
|
+
return this.editableList().indexOf(el);
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* True when an edit produced or touched a list item (kind "li"). List markers and
|
|
1495
|
+
* numbering CONTINUATION need whole-document context, which a single-block render lacks
|
|
1496
|
+
* (every item would render as "1."), so such edits re-render the whole document.
|
|
1497
|
+
*/
|
|
1498
|
+
affectsList(res) {
|
|
1499
|
+
return [...(res.modified ?? []), ...(res.created ?? [])].some((r) => r.kind === "li");
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* Full re-render from current session state (after undo/redo, and after list edits where
|
|
1503
|
+
* single-block rendering can't compute numbering). Optionally focus the editable block at
|
|
1504
|
+
* `focusIndex` (caret at start, or end if `caretAtEnd`) — addressed by index because a
|
|
1505
|
+
* block's content-hashed unid changes across the save/reproject a remount performs.
|
|
1506
|
+
*/
|
|
1507
|
+
remount(focusIndex = -1, caretAtEnd = false) {
|
|
1508
|
+
this.refreshAnchorMap();
|
|
1509
|
+
const fullHtml = this.renderFullHtml();
|
|
1510
|
+
this.activeBlock = null;
|
|
1511
|
+
if (this.options.paginated)
|
|
1512
|
+
this.mountPaginated(fullHtml);
|
|
1513
|
+
else
|
|
1514
|
+
this.mountHtml(fullHtml);
|
|
1515
|
+
if (focusIndex >= 0) {
|
|
1516
|
+
const blocks = this.editableList();
|
|
1517
|
+
const target = blocks[Math.min(focusIndex, blocks.length - 1)];
|
|
1518
|
+
if (target) {
|
|
1519
|
+
this.activeBlock = target;
|
|
1520
|
+
placeCaretAtOffset(target, caretAtEnd ? (target.textContent ?? "").length : 0);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
//# sourceMappingURL=editor.js.map
|