@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.
- package/dist/ai-edits/word-diff.js +134 -1
- package/dist/compare/content-alignment.js +220 -5
- package/dist/compare/content.d.ts +1 -0
- package/dist/compare/content.js +51 -20
- package/dist/compare/inline-provenance.js +18 -22
- package/dist/compare/section-boundary-properties.js +13 -0
- package/dist/docx/selectiveSave.d.ts +1 -1
- package/dist/docx/selectiveSave.js +16 -6
- package/dist/docx/structuralXmlPatch.d.ts +14 -0
- package/dist/docx/structuralXmlPatch.js +209 -0
- package/dist/generated/text_shaper.js +26 -0
- package/dist/generated/text_shaper_bg.wasm +0 -0
- package/dist/managers/editorShortcuts.d.ts +16 -3
- package/dist/managers/editorShortcuts.js +6 -4
- package/dist/prosemirror/extensions/StarterKit.d.ts +3 -0
- package/dist/prosemirror/extensions/StarterKit.js +2 -1
- package/dist/prosemirror/extensions/core/HistoryExtension.d.ts +9 -1
- package/dist/prosemirror/extensions/core/HistoryExtension.js +4 -3
- package/dist/prosemirror/extensions/features/ParagraphChangeTrackerExtension.d.ts +12 -5
- package/dist/prosemirror/extensions/features/ParagraphChangeTrackerExtension.js +91 -33
- package/dist/prosemirror/runStyleFormatting.d.ts +7 -1
- package/dist/prosemirror/runStyleFormatting.js +13 -7
- package/dist/shaping/shaper.d.ts +47 -4
- package/dist/shaping/shaper.js +46 -15
- package/dist/text-shaping.d.ts +4 -0
- package/dist/text-shaping.js +4 -0
- package/package.json +6 -1
|
@@ -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 =
|
|
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<
|
|
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
|
|
10
|
+
/** Whether paragraph order, membership, or block structure changed. */
|
|
11
11
|
structuralChange: boolean;
|
|
12
|
-
/** Whether
|
|
12
|
+
/** Whether edited paragraphs still lack IDs or source identity was unavailable. */
|
|
13
13
|
hasUntrackedChanges: boolean;
|
|
14
|
-
/**
|
|
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
|
|
39
|
+
* Check if paragraph membership, order, or block structure changed
|
|
33
40
|
*/
|
|
34
41
|
declare function hasStructuralChanges(state: EditorState): boolean;
|
|
35
42
|
/**
|