@stll/folio-core 0.50.0 → 0.52.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.
@@ -8,12 +8,13 @@ import { isUnsafePackagePath } from "./packageParts.js";
8
8
  import { RELATIONSHIP_TYPES } from "./relsParser.js";
9
9
  import { COMMENTS_CONTENT_TYPE, COMMENTS_EXTENDED_PART_LOWER, addCommentsExtendedOverride, addCommentsExtendedRelationship, applyUpdatesToZip, collectHeaderFooterUpdates, findMaxRId, hasModelDrivenPictureWatermark, hasUnmaterializedHeaderFooter, hasUnmaterializedInlineResources, updateCoreProperties, withoutAttachedTemplate } from "./rezip.js";
10
10
  import "./selectiveSaveFlags.js";
11
- import { buildPatchedDocumentXml, buildPatchedNoteXml, collectParaIds, patchNumberingDefinitions } from "./selectiveXmlPatch.js";
11
+ import { buildPatchedDocumentXml, buildPatchedNoteXml, collectAddedNumberingDefs, collectParaIds, patchNumberingDefinitions } from "./selectiveXmlPatch.js";
12
12
  import { planCommentParts, serializeComments, serializeCommentsExtended } from "./serializer/commentSerializer.js";
13
13
  import { serializeDocument } from "./serializer/documentSerializer.js";
14
14
  import { serializeEndnotes, serializeFootnotes } from "./serializer/noteSerializer.js";
15
15
  import { serializeNumberingXml } from "./serializer/numberingSerializer.js";
16
16
  import { readRootNamespaceBindings } from "./serializer/partNamespaces.js";
17
+ import { buildStructuralDocumentPatch } from "./structuralXmlPatch.js";
17
18
  import { DOCX_CONFORMANCE_CLASSES } from "@stll/docx-core/model";
18
19
  //#region src/docx/selectiveSave.ts
19
20
  /**
@@ -174,7 +175,6 @@ const queueSettingsUpdates = async (zip, updates) => {
174
175
  async function attemptSelectiveSave(doc, originalBuffer, options) {
175
176
  const { changedParaIds, structuralChange, hasUntrackedChanges } = options;
176
177
  const maxBytes = options.maxBytes ?? 104857600;
177
- if (structuralChange) return null;
178
178
  if (hasUntrackedChanges) return null;
179
179
  if (originalBuffer.byteLength > maxBytes) return null;
180
180
  const content = doc.package.document.content;
@@ -197,22 +197,32 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
197
197
  const zip = await (await import("jszip")).default.loadAsync(originalBuffer);
198
198
  for (const [path, file] of Object.entries(zip.files)) if (!file.dir && isUnsafePackagePath(path)) return null;
199
199
  const updates = /* @__PURE__ */ new Map();
200
- if (changedParaIds.size > 0) {
200
+ if (changedParaIds.size > 0 || structuralChange) {
201
201
  const docXmlFile = zip.file("word/document.xml");
202
202
  if (!docXmlFile) return null;
203
203
  const originalDocXml = await docXmlFile.async("text");
204
204
  const serializedDocXml = serializeDocument(doc, readRootNamespaceBindings(originalDocXml));
205
205
  const bodyParaIds = collectParaIds(serializedDocXml);
206
+ const originalBodyParaIds = structuralChange ? collectParaIds(originalDocXml) : void 0;
207
+ if (structuralChange && doc.package.numbering) {
208
+ const numberingFile = findZipEntryCaseInsensitive(zip, "word/numbering.xml");
209
+ const added = collectAddedNumberingDefs(numberingFile ? serializeNumberingXml(parseNumbering(await numberingFile.async("text")).definitions) : "", serializeNumberingXml(doc.package.numbering));
210
+ if (added.abstractNums.size > 0 || added.nums.size > 0) return null;
211
+ }
206
212
  const bodyChangedIds = /* @__PURE__ */ new Set();
207
213
  const noteCandidateIds = /* @__PURE__ */ new Set();
208
- for (const id of changedParaIds) if (bodyParaIds.has(id)) bodyChangedIds.add(id);
214
+ for (const id of changedParaIds) if (bodyParaIds.has(id) || originalBodyParaIds?.has(id)) bodyChangedIds.add(id);
209
215
  else noteCandidateIds.add(id);
210
216
  if (noteCandidateIds.size > 0) {
211
217
  const unrouted = await patchNoteParts(zip, doc, noteCandidateIds, updates);
212
218
  if (unrouted === null || unrouted.size > 0) return null;
213
219
  }
214
- if (bodyChangedIds.size > 0) {
215
- const patchedDocXml = buildPatchedDocumentXml(originalDocXml, serializedDocXml, bodyChangedIds);
220
+ if (bodyChangedIds.size > 0 || structuralChange) {
221
+ const patchedDocXml = structuralChange ? buildStructuralDocumentPatch({
222
+ originalXml: originalDocXml,
223
+ serializedXml: serializedDocXml,
224
+ changedIds: bodyChangedIds
225
+ }) : buildPatchedDocumentXml(originalDocXml, serializedDocXml, bodyChangedIds);
216
226
  if (!patchedDocXml) return null;
217
227
  updates.set("word/document.xml", patchedDocXml);
218
228
  }
@@ -0,0 +1,14 @@
1
+ //#region src/docx/structuralXmlPatch.d.ts
2
+ type StructuralPatchOptions = {
3
+ originalXml: string;
4
+ serializedXml: string;
5
+ changedIds: ReadonlySet<string>;
6
+ };
7
+ /**
8
+ * Insert/delete direct body paragraphs between surviving paragraph/table anchors.
9
+ * Tables are opaque barriers: their modeled content and order must stay identical.
10
+ * Id-less sources need an explicit ensureParaIds ingest before structural editing.
11
+ */
12
+ declare const buildStructuralDocumentPatch: ({ originalXml, serializedXml, changedIds }: StructuralPatchOptions) => string | null;
13
+ //#endregion
14
+ export { buildStructuralDocumentPatch };
@@ -0,0 +1,209 @@
1
+ import { canonicalJson } from "../utils/canonicalJson.js";
2
+ import { parseDocumentBody } from "./documentParser.js";
3
+ import { paraIdAttribute } from "./paraIdAttribute.js";
4
+ import { spliceXml } from "./selectiveXmlPatch.js";
5
+ import { readRootNamespaceBindings, serializePartElement } from "./serializer/partNamespaces.js";
6
+ import { NAMESPACES, WORDPROCESSINGML_NAMESPACE_URIS, getChildElements, getLocalName, getNamespaceUri, parseXmlDocument } from "./xmlParser.js";
7
+ //#region src/docx/structuralXmlPatch.ts
8
+ /** Conservative body-paragraph splices; source offsets never come from reserialization. */
9
+ const isWordElement = (element, name) => getLocalName(element.name) === name && WORDPROCESSINGML_NAMESPACE_URIS.has(getNamespaceUri(element) ?? "");
10
+ /** Match XML tokens, skipping quoted delimiters, comments, CDATA and processing instructions. */
11
+ const XML_TOKEN = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<\?[\s\S]*?\?>|<(?:"[^"]*"|'[^']*'|[^'">])*>/gu;
12
+ const readBody = (xml) => {
13
+ const root = parseXmlDocument(xml);
14
+ if (!root || !isWordElement(root, "document")) return null;
15
+ if (getNamespaceUri(root) !== NAMESPACES.w) return null;
16
+ const body = getChildElements(root).find((child) => isWordElement(child, "body"));
17
+ if (!body) return null;
18
+ const children = getChildElements(body);
19
+ const blocks = [];
20
+ const stack = [];
21
+ let bodyDepth = -1;
22
+ let bodyEnd = -1;
23
+ let start = -1;
24
+ for (const token of xml.matchAll(XML_TOKEN)) {
25
+ const tag = token[0];
26
+ if (tag.startsWith("<!") || tag.startsWith("<?")) continue;
27
+ const closing = tag.startsWith("</");
28
+ const name = tag.slice(closing ? 2 : 1).split(/[\s/>]/u).at(0);
29
+ if (!name) return null;
30
+ if (closing) {
31
+ if (stack.pop() !== name) return null;
32
+ if (stack.length === bodyDepth) {
33
+ const element = children[blocks.length];
34
+ if (!element || start < 0) return null;
35
+ blocks.push({
36
+ element,
37
+ start,
38
+ end: token.index + tag.length
39
+ });
40
+ start = -1;
41
+ }
42
+ if (name === body.name && stack.length === bodyDepth - 1) bodyEnd = token.index;
43
+ continue;
44
+ }
45
+ if (name === body.name && stack.length === 1) bodyDepth = 2;
46
+ if (stack.length === bodyDepth) {
47
+ if (children[blocks.length]?.name !== name) return null;
48
+ start = token.index;
49
+ if (tag.endsWith("/>")) {
50
+ const element = children[blocks.length];
51
+ if (!element) return null;
52
+ blocks.push({
53
+ element,
54
+ start,
55
+ end: token.index + tag.length
56
+ });
57
+ start = -1;
58
+ }
59
+ }
60
+ if (!tag.endsWith("/>")) stack.push(name);
61
+ }
62
+ if (stack.length > 0 || blocks.length !== children.length || bodyEnd < 0) return null;
63
+ return {
64
+ root,
65
+ blocks,
66
+ bodyEnd
67
+ };
68
+ };
69
+ function* descendants(element) {
70
+ yield element;
71
+ for (const child of getChildElements(element)) yield* descendants(child);
72
+ }
73
+ /** Cross-paragraph ranges require a wider edit contract than paragraph identity. */
74
+ const RANGE_ELEMENTS = /* @__PURE__ */ new Set([
75
+ "commentRangeStart",
76
+ "commentRangeEnd",
77
+ "commentReference",
78
+ "bookmarkStart",
79
+ "bookmarkEnd",
80
+ "permStart",
81
+ "permEnd",
82
+ "fldChar",
83
+ "moveFromRangeStart",
84
+ "moveFromRangeEnd",
85
+ "moveToRangeStart",
86
+ "moveToRangeEnd"
87
+ ]);
88
+ const DEPENDENT_ELEMENTS = /* @__PURE__ */ new Set([
89
+ "sectPr",
90
+ "footnoteReference",
91
+ "endnoteReference",
92
+ "drawing",
93
+ "pict",
94
+ "object"
95
+ ]);
96
+ const paragraphIsSafe = (element, touched) => {
97
+ for (const child of descendants(element)) {
98
+ if (!WORDPROCESSINGML_NAMESPACE_URIS.has(getNamespaceUri(child) ?? "")) continue;
99
+ const name = getLocalName(child.name);
100
+ if (touched && DEPENDENT_ELEMENTS.has(name)) return false;
101
+ }
102
+ return true;
103
+ };
104
+ const paragraphIds = (root) => {
105
+ const ids = /* @__PURE__ */ new Set();
106
+ for (const element of descendants(root)) {
107
+ if (!isWordElement(element, "p")) continue;
108
+ const id = paraIdAttribute(element);
109
+ if (!id || !/^[0-9a-f]{8}$/iu.test(id) || /^0+$/u.test(id) || ids.has(id.toUpperCase())) return null;
110
+ ids.add(id.toUpperCase());
111
+ }
112
+ return ids;
113
+ };
114
+ /** Bind fragment prefixes locally: the source root may use entirely different aliases. */
115
+ const paragraphFragment = ({ xml, block, bindings }) => {
116
+ const fragment = xml.slice(block.start, block.end);
117
+ const openEnd = fragment.indexOf(">");
118
+ const name = block.element.name ?? "w:p";
119
+ const selfClosing = fragment[openEnd - 1] === "/";
120
+ return serializePartElement({
121
+ partPath: "word/document.xml",
122
+ rootName: name,
123
+ rootAttributes: fragment.slice(name.length + 1, selfClosing ? openEnd - 1 : openEnd).trim(),
124
+ baselinePrefixes: [],
125
+ sourceBindings: bindings,
126
+ body: selfClosing ? "" : fragment.slice(openEnd + 1, fragment.lastIndexOf("</"))
127
+ });
128
+ };
129
+ /**
130
+ * Insert/delete direct body paragraphs between surviving paragraph/table anchors.
131
+ * Tables are opaque barriers: their modeled content and order must stay identical.
132
+ * Id-less sources need an explicit ensureParaIds ingest before structural editing.
133
+ */
134
+ const buildStructuralDocumentPatch = ({ originalXml, serializedXml, changedIds }) => {
135
+ const source = readBody(originalXml);
136
+ const current = readBody(serializedXml);
137
+ if (!source || !current) return null;
138
+ for (const root of [source.root, current.root]) for (const element of descendants(root)) if (WORDPROCESSINGML_NAMESPACE_URIS.has(getNamespaceUri(element) ?? "") && RANGE_ELEMENTS.has(getLocalName(element.name))) return null;
139
+ const allSourceIds = paragraphIds(source.root);
140
+ const allCurrentIds = paragraphIds(current.root);
141
+ if (!allSourceIds || !allCurrentIds) return null;
142
+ const changed = new Set([...changedIds].map((id) => id.toUpperCase()));
143
+ for (const { element } of [...source.blocks, ...current.blocks]) if (isWordElement(element, "p")) {
144
+ const id = paraIdAttribute(element)?.toUpperCase();
145
+ if (!id) return null;
146
+ const touched = changed.has(id) || !allSourceIds.has(id) || !allCurrentIds.has(id);
147
+ if (!paragraphIsSafe(element, touched)) return null;
148
+ } else if (!isWordElement(element, "tbl") && !isWordElement(element, "sectPr")) return null;
149
+ const sourceBody = parseDocumentBody(originalXml);
150
+ const currentBody = parseDocumentBody(serializedXml);
151
+ const barriers = (body) => ({
152
+ blocks: body.content.filter((block) => block.type !== "paragraph"),
153
+ finalSectionProperties: body.finalSectionProperties,
154
+ background: body.background
155
+ });
156
+ if (canonicalJson(barriers(sourceBody)) !== canonicalJson(barriers(currentBody))) return null;
157
+ const indexBlocks = (blocks) => {
158
+ let barrier = 0;
159
+ const indexed = /* @__PURE__ */ new Map();
160
+ for (const block of blocks) {
161
+ const key = isWordElement(block.element, "p") ? `p:${paraIdAttribute(block.element)?.toUpperCase()}` : `barrier:${barrier++}`;
162
+ indexed.set(key, block);
163
+ }
164
+ return indexed;
165
+ };
166
+ const before = indexBlocks(source.blocks);
167
+ const after = indexBlocks(current.blocks);
168
+ const survivingBefore = [...before.keys()].filter((key) => after.has(key));
169
+ const survivingAfter = [...after.keys()].filter((key) => before.has(key));
170
+ if (canonicalJson(survivingBefore) !== canonicalJson(survivingAfter)) return null;
171
+ const bindings = readRootNamespaceBindings(serializedXml);
172
+ const fragmentFor = (block) => paragraphFragment({
173
+ xml: serializedXml,
174
+ block,
175
+ bindings
176
+ });
177
+ const splices = [];
178
+ for (const [key, block] of before) if (!after.has(key)) splices.push({
179
+ start: block.start,
180
+ end: block.end,
181
+ newXml: ""
182
+ });
183
+ let pending = [];
184
+ for (const [key, block] of after) {
185
+ const original = before.get(key);
186
+ if (!original) {
187
+ const id = paraIdAttribute(block.element)?.toUpperCase();
188
+ if (!id || allSourceIds.has(id)) return null;
189
+ pending.push(fragmentFor(block));
190
+ continue;
191
+ }
192
+ const id = paraIdAttribute(block.element)?.toUpperCase();
193
+ const replacement = id !== void 0 && changed.has(id) ? fragmentFor(block) : originalXml.slice(original.start, original.end);
194
+ if (pending.length > 0 || replacement !== originalXml.slice(original.start, original.end)) splices.push({
195
+ start: original.start,
196
+ end: original.end,
197
+ newXml: pending.join("") + replacement
198
+ });
199
+ pending = [];
200
+ }
201
+ if (pending.length > 0) splices.push({
202
+ start: source.bodyEnd,
203
+ end: source.bodyEnd,
204
+ newXml: pending.join("")
205
+ });
206
+ return spliceXml(originalXml, splices);
207
+ };
208
+ //#endregion
209
+ export { buildStructuralDocumentPatch };
@@ -1,6 +1,32 @@
1
1
  // @ts-nocheck -- generated and verified by build-shaper-wasm.ts
2
2
  /* @ts-self-types="./text_shaper.d.ts" */
3
3
 
4
+ /**
5
+ * Resolve the bidirectional levels and visual order of one line.
6
+ *
7
+ * `right_to_left` is the paragraph direction, or `None` for rules P2 and P3.
8
+ * Returns `[paragraphLevel, charCount, level * charCount, visualIndex *
9
+ * charCount]`, counting characters as Unicode scalar values.
10
+ * @param {string} text
11
+ * @param {boolean | null} [right_to_left]
12
+ * @returns {Int32Array}
13
+ */
14
+ export function resolveBidi(text, right_to_left) {
15
+ try {
16
+ const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
17
+ const ptr0 = passStringToWasm0(text, wasm.__wbindgen_export, wasm.__wbindgen_export2);
18
+ const len0 = WASM_VECTOR_LEN;
19
+ wasm.resolveBidi(retptr, ptr0, len0, isLikeNone(right_to_left) ? 0xFFFFFF : right_to_left ? 1 : 0);
20
+ var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
21
+ var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
22
+ var v2 = getArrayI32FromWasm0(r0, r1).slice();
23
+ wasm.__wbindgen_export3(r0, r1 * 4, 4);
24
+ return v2;
25
+ } finally {
26
+ wasm.__wbindgen_add_to_stack_pointer(16);
27
+ }
28
+ }
29
+
4
30
  /**
5
31
  * Shape one run of text with one face.
6
32
  *
Binary file
@@ -1,3 +1,4 @@
1
+ import { HistoryShortcutOwner } from "../prosemirror/extensions/core/HistoryExtension.js";
1
2
  import { EditorState, Transaction } from "prosemirror-state";
2
3
  //#region src/managers/editorShortcuts.d.ts
3
4
  declare function isMacPlatform(): boolean;
@@ -17,19 +18,31 @@ type EditorKeydownIntent = {
17
18
  } | {
18
19
  type: "none";
19
20
  };
21
+ /**
22
+ * An editor shortcut the host binds itself. The editor leaves its keys alone,
23
+ * so the host's binding (an IDE's undo stack, its own print command) is the
24
+ * only one that runs.
25
+ * - `"history"`: undo and redo (Mod-z, Mod-y, Mod-Shift-z).
26
+ * - `"print"`: Cmd/Ctrl+P.
27
+ */
28
+ type HostShortcut = "history" | "print";
29
+ /** Who answers the undo and redo keys when the host owns `hostShortcuts`. */
30
+ declare const historyShortcutOwner: (hostShortcuts: readonly HostShortcut[]) => HistoryShortcutOwner;
20
31
  type ClassifyEditorKeydownOptions = {
21
32
  /** Whether the platform uses Cmd (Mac) rather than Ctrl as the primary modifier. */
22
33
  isMac: boolean;
23
34
  /** Whether focus is in a non-editor input/textarea/contenteditable. */
24
35
  isInputLike: boolean;
36
+ /** Shortcuts the host binds itself; their keys map to `none`. */
37
+ hostShortcuts: readonly HostShortcut[];
25
38
  };
26
39
  /**
27
40
  * Map a keydown to an editor intent:
28
41
  * - Cmd/Ctrl+F or Cmd/Ctrl+H → open find
29
- * - Cmd/Ctrl+P (no auto-repeat) → custom print
42
+ * - Cmd/Ctrl+P (no auto-repeat) → custom print, unless the host owns print
30
43
  * - Delete/Backspace (no modifiers, focus not in an input) → delete selected table
31
44
  */
32
- declare function classifyEditorKeydown(e: KeyboardEvent, { isMac, isInputLike }: ClassifyEditorKeydownOptions): EditorKeydownIntent;
45
+ declare function classifyEditorKeydown(e: Pick<KeyboardEvent, "key" | "metaKey" | "ctrlKey" | "shiftKey" | "altKey" | "repeat">, { isMac, isInputLike, hostShortcuts }: ClassifyEditorKeydownOptions): EditorKeydownIntent;
33
46
  /**
34
47
  * Which key presses the editor's page-level shortcuts answer.
35
48
  * - `"document"`: every press on the page.
@@ -69,4 +82,4 @@ declare function isKeydownInShortcutScope(event: {
69
82
  */
70
83
  declare function deleteSelectedTable(state: EditorState, dispatch: (tr: Transaction) => void): boolean;
71
84
  //#endregion
72
- export { ClassifyEditorKeydownOptions, EditorKeydownIntent, IsKeydownInShortcutScopeOptions, KeyboardShortcutScope, ShortcutScopeRoot, classifyEditorKeydown, deleteSelectedTable, isFocusInInputLike, isKeydownInShortcutScope, isMacPlatform };
85
+ export { ClassifyEditorKeydownOptions, EditorKeydownIntent, HostShortcut, IsKeydownInShortcutScopeOptions, KeyboardShortcutScope, ShortcutScopeRoot, classifyEditorKeydown, deleteSelectedTable, historyShortcutOwner, isFocusInInputLike, isKeydownInShortcutScope, isMacPlatform };
@@ -16,19 +16,21 @@ function isFocusInInputLike(target, editorDom) {
16
16
  if (target.isContentEditable && target !== editorDom) return true;
17
17
  return false;
18
18
  }
19
+ /** Who answers the undo and redo keys when the host owns `hostShortcuts`. */
20
+ const historyShortcutOwner = (hostShortcuts) => hostShortcuts.includes("history") ? "host" : "editor";
19
21
  /**
20
22
  * Map a keydown to an editor intent:
21
23
  * - Cmd/Ctrl+F or Cmd/Ctrl+H → open find
22
- * - Cmd/Ctrl+P (no auto-repeat) → custom print
24
+ * - Cmd/Ctrl+P (no auto-repeat) → custom print, unless the host owns print
23
25
  * - Delete/Backspace (no modifiers, focus not in an input) → delete selected table
24
26
  */
25
- function classifyEditorKeydown(e, { isMac, isInputLike }) {
27
+ function classifyEditorKeydown(e, { isMac, isInputLike, hostShortcuts }) {
26
28
  const cmdOrCtrl = isMac ? e.metaKey : e.ctrlKey;
27
29
  if (!cmdOrCtrl && !e.shiftKey && !e.altKey && (e.key === "Delete" || e.key === "Backspace") && !isInputLike) return { type: "deleteSelectedTable" };
28
30
  if (cmdOrCtrl && !e.shiftKey && !e.altKey) {
29
31
  const key = e.key.toLowerCase();
30
32
  if (key === "f" || key === "h") return { type: "openFind" };
31
- if (key === "p" && !e.repeat) return { type: "print" };
33
+ if (key === "p" && !e.repeat && !hostShortcuts.includes("print")) return { type: "print" };
32
34
  }
33
35
  return { type: "none" };
34
36
  }
@@ -76,4 +78,4 @@ function deleteSelectedTable(state, dispatch) {
76
78
  return false;
77
79
  }
78
80
  //#endregion
79
- export { classifyEditorKeydown, deleteSelectedTable, isFocusInInputLike, isKeydownInShortcutScope, isMacPlatform };
81
+ export { classifyEditorKeydown, deleteSelectedTable, historyShortcutOwner, isFocusInInputLike, isKeydownInShortcutScope, isMacPlatform };
@@ -1,4 +1,5 @@
1
1
  import { AnyExtension } from "./types.js";
2
+ import { HistoryShortcutOwner } from "./core/HistoryExtension.js";
2
3
  import { SelectionChangeCallback } from "../plugins/selectionTracker.js";
3
4
  //#region src/prosemirror/extensions/StarterKit.d.ts
4
5
  type StarterKitOptions = {
@@ -8,6 +9,8 @@ type StarterKitOptions = {
8
9
  historyDepth?: number;
9
10
  /** History new group delay (default: 500) */
10
11
  historyNewGroupDelay?: number;
12
+ /** Who answers the undo and redo keys (default: `"editor"`). */
13
+ historyShortcuts?: HistoryShortcutOwner;
11
14
  /** Selection change callback */
12
15
  onSelectionChange?: SelectionChangeCallback;
13
16
  };
@@ -61,7 +61,8 @@ function createStarterKit(options = {}) {
61
61
  add("paragraph", ParagraphExtension());
62
62
  add("history", HistoryExtension({
63
63
  ...options.historyDepth !== void 0 ? { depth: options.historyDepth } : {},
64
- ...options.historyNewGroupDelay !== void 0 ? { newGroupDelay: options.historyNewGroupDelay } : {}
64
+ ...options.historyNewGroupDelay !== void 0 ? { newGroupDelay: options.historyNewGroupDelay } : {},
65
+ ...options.historyShortcuts !== void 0 ? { shortcuts: options.historyShortcuts } : {}
65
66
  }));
66
67
  for (const name of MARK_NESTING_ORDER) add(name, MARK_EXTENSIONS[name]());
67
68
  add("bookmarkBoundary", BookmarkBoundaryExtension({ getInternalClipboardToken }));
@@ -3,10 +3,18 @@ import { Extension } from "../types.js";
3
3
  /**
4
4
  * History Extension — undo/redo via prosemirror-history
5
5
  */
6
+ /**
7
+ * Who answers the undo and redo keys (Mod-z, Mod-y, Mod-Shift-z).
8
+ * - `"editor"`: the extension binds them. Default.
9
+ * - `"host"`: nothing binds them. The host keeps its own undo stack and runs
10
+ * the `undo` / `redo` commands itself, so one press never undoes twice.
11
+ */
12
+ type HistoryShortcutOwner = "editor" | "host";
6
13
  type HistoryOptions = {
7
14
  depth: number;
8
15
  newGroupDelay: number;
16
+ shortcuts: HistoryShortcutOwner;
9
17
  };
10
18
  declare const HistoryExtension: (options?: Partial<HistoryOptions> | undefined) => Extension;
11
19
  //#endregion
12
- export { HistoryExtension };
20
+ export { HistoryExtension, HistoryShortcutOwner };
@@ -4,7 +4,8 @@ const HistoryExtension = createExtension({
4
4
  name: "history",
5
5
  defaultOptions: {
6
6
  depth: 100,
7
- newGroupDelay: 500
7
+ newGroupDelay: 500,
8
+ shortcuts: "editor"
8
9
  },
9
10
  onSchemaReady(_ctx, options) {
10
11
  return {
@@ -16,11 +17,11 @@ const HistoryExtension = createExtension({
16
17
  undo: () => undo,
17
18
  redo: () => redo
18
19
  },
19
- keyboardShortcuts: {
20
+ ...options.shortcuts === "editor" && { keyboardShortcuts: {
20
21
  "Mod-z": undo,
21
22
  "Mod-y": redo,
22
23
  "Mod-Shift-z": redo
23
- }
24
+ } }
24
25
  };
25
26
  }
26
27
  });
@@ -3,15 +3,15 @@ import { RemovedSectionReference, TrackedSectionEndpointRemoval } from "../../..
3
3
  import { EditorState, PluginKey, Transaction } from "prosemirror-state";
4
4
  import { Node } from "prosemirror-model";
5
5
  //#region src/prosemirror/extensions/features/ParagraphChangeTrackerExtension.d.ts
6
- declare const paragraphChangeTrackerKey: PluginKey<ParagraphChangeTrackerState>;
6
+ declare const paragraphChangeTrackerKey: PluginKey<InternalParagraphChangeTrackerState>;
7
7
  type ParagraphChangeTrackerState = {
8
8
  /** Set of paraIds that were modified since last clear */
9
9
  changedParaIds: Set<string>;
10
- /** Whether paragraphs were added or deleted (structural change) */
10
+ /** Whether paragraph order, membership, or block structure changed. */
11
11
  structuralChange: boolean;
12
- /** Whether any edited paragraph lacked a paraId */
12
+ /** Whether edited paragraphs still lack IDs or source identity was unavailable. */
13
13
  hasUntrackedChanges: boolean;
14
- /** Cached paragraph count to avoid full doc traversal on every transaction */
14
+ /** Paragraph count in the current tracked document. */
15
15
  paragraphCount: number;
16
16
  /** Cached section-endpoint count used to invalidate stale save authorization. */
17
17
  sectionEndpointCount: number;
@@ -20,6 +20,13 @@ type ParagraphChangeTrackerState = {
20
20
  /** Exact tracked-resolution transition that may reduce the saved section count. */
21
21
  sectionEndpointRemoval: TrackedSectionEndpointRemoval | null;
22
22
  };
23
+ type InternalParagraphChangeTrackerState = ParagraphChangeTrackerState & {
24
+ /** Edited paragraph positions retained across allocator-only transactions. */
25
+ affectedParagraphPositions: Set<number>;
26
+ /** Editing an already unidentified source paragraph cannot become selective. */
27
+ hasUntrackedSourceChanges: boolean;
28
+ blockStructureFingerprint: string;
29
+ };
23
30
  /**
24
31
  * Get the change tracker state from an EditorState
25
32
  */
@@ -29,7 +36,7 @@ declare function getChangeTrackerState(state: EditorState): ParagraphChangeTrack
29
36
  */
30
37
  declare function getChangedParagraphIds(state: EditorState): Set<string>;
31
38
  /**
32
- * Check if structural changes (paragraph add/delete) occurred
39
+ * Check if paragraph membership, order, or block structure changed
33
40
  */
34
41
  declare function hasStructuralChanges(state: EditorState): boolean;
35
42
  /**