@openpresentation/opf-editor 0.10.5 → 0.11.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/README.md +333 -8
- package/dist/annotations.d.ts +71 -0
- package/dist/annotations.js +281 -0
- package/dist/assets.d.ts +67 -0
- package/dist/assets.js +176 -0
- package/dist/background-options.d.ts +48 -0
- package/dist/background-options.js +134 -0
- package/dist/block-convert.d.ts +64 -0
- package/dist/block-convert.js +142 -0
- package/dist/canvas.d.ts +16 -0
- package/dist/canvas.js +82 -21
- package/dist/chart-data.d.ts +32 -0
- package/dist/chart-data.js +101 -0
- package/dist/chart-options-panel.d.ts +16 -0
- package/dist/chart-options-panel.js +127 -0
- package/dist/chart-options.d.ts +49 -0
- package/dist/chart-options.js +157 -0
- package/dist/content-actions.d.ts +91 -0
- package/dist/content-actions.js +207 -0
- package/dist/content-controls.js +326 -0
- package/dist/data-grid.d.ts +37 -0
- package/dist/data-grid.js +1035 -0
- package/dist/design-controls.d.ts +43 -0
- package/dist/design-controls.js +1077 -0
- package/dist/design-options.d.ts +108 -0
- package/dist/design-options.js +412 -0
- package/dist/edit-helpers.js +52 -0
- package/dist/export.d.ts +77 -0
- package/dist/export.js +216 -0
- package/dist/find-panel.d.ts +44 -0
- package/dist/find-panel.js +431 -0
- package/dist/find-replace.d.ts +100 -0
- package/dist/find-replace.js +374 -0
- package/dist/grid-model.d.ts +135 -0
- package/dist/grid-model.js +836 -0
- package/dist/grid-text.d.ts +33 -0
- package/dist/grid-text.js +251 -0
- package/dist/image-crop.d.ts +59 -0
- package/dist/image-crop.js +336 -0
- package/dist/image-cropper.d.ts +29 -0
- package/dist/image-cropper.js +519 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +104 -171
- package/dist/numbering-panel.d.ts +21 -0
- package/dist/numbering-panel.js +200 -0
- package/dist/numbering.d.ts +62 -0
- package/dist/numbering.js +223 -0
- package/dist/outline-view.d.ts +17 -0
- package/dist/outline-view.js +278 -0
- package/dist/outline.d.ts +56 -0
- package/dist/outline.js +271 -0
- package/dist/persistence-ui.d.ts +24 -0
- package/dist/persistence-ui.js +81 -0
- package/dist/persistence.d.ts +105 -0
- package/dist/persistence.js +429 -0
- package/dist/review-panel.d.ts +44 -0
- package/dist/review-panel.js +359 -0
- package/dist/review.d.ts +75 -0
- package/dist/review.js +170 -0
- package/dist/slide-manager.d.ts +44 -0
- package/dist/slide-manager.js +695 -0
- package/dist/slides.d.ts +96 -0
- package/dist/slides.js +433 -0
- package/dist/switches.d.ts +26 -0
- package/dist/switches.js +127 -43
- package/dist/table-options.d.ts +80 -0
- package/dist/table-options.js +419 -0
- package/dist/table-structure.d.ts +30 -0
- package/dist/table-structure.js +92 -0
- package/dist/template-panel.d.ts +31 -0
- package/dist/template-panel.js +377 -0
- package/dist/templates.d.ts +126 -0
- package/dist/templates.js +331 -0
- package/dist/zip.d.ts +4 -0
- package/dist/zip.js +71 -0
- package/package.json +150 -10
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// Deck-wide find and replace (RR-25): the headless model. `collectSearchFields` lists every piece of text in a
|
|
2
|
+
// document (titles, subtitles, tags, text and its runs, list items, quotes, metrics, timelines, table cells, chart
|
|
3
|
+
// labels, image alt text, header and footer text, speaker notes); `findMatches` searches them; `planReplace`,
|
|
4
|
+
// `replaceMatch` and `replaceAll` build and apply the replacement as one undoable session change.
|
|
5
|
+
//
|
|
6
|
+
// Matching runs on a field's text. A field is either a plain string or a run array (rich text); the text of a run
|
|
7
|
+
// array is its runs joined, so a phrase that crosses a bold/plain boundary is still found. Replacement behaviour:
|
|
8
|
+
// - a match inside one run: only that run's text changes; every run keeps its formatting;
|
|
9
|
+
// - a match that spans several runs: the replacement takes the formatting of the run that holds the FIRST
|
|
10
|
+
// matched character (the Word and Google Docs rule). The matched characters of the later runs are removed and
|
|
11
|
+
// the text before and after the match keeps the formatting it had. A run left empty by the removal is dropped
|
|
12
|
+
// (a field never ends up with no run: its first run stays, empty).
|
|
13
|
+
// - a run written as a plain string stays a plain string when its text is the only thing that changed.
|
|
14
|
+
// Fields that are numbers, ids, asset references, URLs, colours and layout names are never searched or changed.
|
|
15
|
+
import { OPFEditorError, getValueAtPath, opfPathToJsonPointer } from "./index.js";
|
|
16
|
+
|
|
17
|
+
/** At most this many matches are collected; `truncated` says so. */
|
|
18
|
+
export const MAX_MATCHES = 10000;
|
|
19
|
+
|
|
20
|
+
const REGION_KEY = /^(?:top|middle|bottom|left|center|right)(?:[+:]|$)/;
|
|
21
|
+
const WORD_BEFORE = "(?<![\\p{L}\\p{N}_])";
|
|
22
|
+
const WORD_AFTER = "(?![\\p{L}\\p{N}_])";
|
|
23
|
+
|
|
24
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
25
|
+
const runText = (run) => (typeof run === "string" ? run : isObject(run) && typeof run.text === "string" ? run.text : "");
|
|
26
|
+
const isRuns = (value) => Array.isArray(value) && value.length > 0 && value.every((run) => typeof run === "string" || (isObject(run) && typeof run.text === "string"));
|
|
27
|
+
|
|
28
|
+
/** The plain text of a string or a run array. */
|
|
29
|
+
export function textOf(value) {
|
|
30
|
+
return typeof value === "string" ? value : Array.isArray(value) ? value.map(runText).join("") : "";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function field(fields, state, segments, value, label, kind = "body") {
|
|
34
|
+
if (typeof value === "string" ? value === "" : !isRuns(value)) return;
|
|
35
|
+
const text = textOf(value);
|
|
36
|
+
if (!text) return;
|
|
37
|
+
fields.push({
|
|
38
|
+
pointer: opfPathToJsonPointer(segments),
|
|
39
|
+
path: segments.join("."),
|
|
40
|
+
slideIndex: state.slideIndex,
|
|
41
|
+
label,
|
|
42
|
+
kind,
|
|
43
|
+
runs: typeof value !== "string",
|
|
44
|
+
text,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function textField(fields, state, segments, value, label, kind) {
|
|
49
|
+
if (typeof value === "string" || isRuns(value)) field(fields, state, segments, value, label, kind);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function itemFields(fields, state, base, items, noun) {
|
|
53
|
+
if (!Array.isArray(items)) return;
|
|
54
|
+
items.forEach((item, index) => {
|
|
55
|
+
const label = `${noun} ${index + 1}`;
|
|
56
|
+
if (typeof item === "string" || isRuns(item)) textField(fields, state, [...base, String(index)], item, label);
|
|
57
|
+
else if (isObject(item)) textField(fields, state, [...base, String(index), "text"], item.text, label);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function cellFields(fields, state, base, cell, label) {
|
|
62
|
+
if (isObject(cell) && "value" in cell) cellFields(fields, state, [...base, "value"], cell.value, label);
|
|
63
|
+
else if (typeof cell === "string" || isRuns(cell)) textField(fields, state, base, cell, label, "table");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function payloadFields(fields, state, base, payload, prefix = "") {
|
|
67
|
+
if (!isObject(payload)) return;
|
|
68
|
+
const at = (...rest) => [...base, ...rest];
|
|
69
|
+
textField(fields, state, at("text"), payload.text, `${prefix}Text`);
|
|
70
|
+
itemFields(fields, state, at("items"), payload.items, `${prefix}List item`);
|
|
71
|
+
itemFields(fields, state, at("bullets"), payload.bullets, `${prefix}Bullet`);
|
|
72
|
+
if (typeof payload.code === "string") field(fields, state, at("code"), payload.code, `${prefix}Code`, "code");
|
|
73
|
+
else if (isObject(payload.code)) field(fields, state, at("code", "source"), payload.code.source, `${prefix}Code`, "code");
|
|
74
|
+
if (typeof payload.metric === "string") field(fields, state, at("metric"), payload.metric, `${prefix}Metric`);
|
|
75
|
+
else if (isObject(payload.metric)) {
|
|
76
|
+
for (const key of ["value", "label", "unit", "delta"]) {
|
|
77
|
+
if (typeof payload.metric[key] === "string") field(fields, state, at("metric", key), payload.metric[key], `${prefix}Metric ${key}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (typeof payload.quote === "string") field(fields, state, at("quote"), payload.quote, `${prefix}Quote`);
|
|
81
|
+
else if (isObject(payload.quote)) {
|
|
82
|
+
field(fields, state, at("quote", "text"), payload.quote.text, `${prefix}Quote`);
|
|
83
|
+
field(fields, state, at("quote", "attribution"), payload.quote.attribution, `${prefix}Quote attribution`);
|
|
84
|
+
field(fields, state, at("quote", "source"), payload.quote.source, `${prefix}Quote source`);
|
|
85
|
+
}
|
|
86
|
+
const timeline = payload.timeline;
|
|
87
|
+
const events = Array.isArray(timeline) ? timeline : isObject(timeline) ? timeline.events : undefined;
|
|
88
|
+
const timelineBase = Array.isArray(timeline) ? at("timeline") : at("timeline", "events");
|
|
89
|
+
if (isObject(timeline)) field(fields, state, at("timeline", "name"), timeline.name, `${prefix}Timeline name`);
|
|
90
|
+
if (Array.isArray(events)) {
|
|
91
|
+
events.forEach((event, index) => {
|
|
92
|
+
if (!isObject(event)) return;
|
|
93
|
+
field(fields, state, [...timelineBase, String(index), "when"], event.when, `${prefix}Timeline event ${index + 1} date`, "timeline");
|
|
94
|
+
field(fields, state, [...timelineBase, String(index), "what"], event.what, `${prefix}Timeline event ${index + 1}`, "timeline");
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const chart = payload.chart;
|
|
98
|
+
if (isObject(chart) && isObject(chart.data)) {
|
|
99
|
+
const { columns, rows } = chart.data;
|
|
100
|
+
if (Array.isArray(columns)) columns.forEach((value, index) => field(fields, state, at("chart", "data", "columns", String(index)), value, `${prefix}Chart label ${index + 1}`, "chart"));
|
|
101
|
+
if (Array.isArray(rows)) rows.forEach((row, r) => Array.isArray(row) && row.forEach((value, c) => {
|
|
102
|
+
if (typeof value === "string") field(fields, state, at("chart", "data", "rows", String(r), String(c)), value, `${prefix}Chart row ${r + 1}, column ${c + 1}`, "chart");
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
const table = payload.table;
|
|
106
|
+
if (isObject(table)) {
|
|
107
|
+
if (Array.isArray(table.columns)) table.columns.forEach((cell, c) => cellFields(fields, state, at("table", "columns", String(c)), cell, `${prefix}Table header ${c + 1}`));
|
|
108
|
+
if (Array.isArray(table.rows)) table.rows.forEach((row, r) => Array.isArray(row) && row.forEach((cell, c) => cellFields(fields, state, at("table", "rows", String(r), String(c)), cell, `${prefix}Table row ${r + 1}, column ${c + 1}`)));
|
|
109
|
+
}
|
|
110
|
+
if (isObject(payload.image)) field(fields, state, at("image", "alt"), payload.image.alt, `${prefix}Image alt text`, "alt");
|
|
111
|
+
if (isObject(payload.video)) field(fields, state, at("video", "alt"), payload.video.alt, `${prefix}Video alt text`, "alt");
|
|
112
|
+
if (Array.isArray(payload.blocks)) payload.blocks.forEach((block, index) => payloadFields(fields, state, at("blocks", String(index)), block, `${prefix}Block ${index + 1} · `));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function furnitureFields(fields, state, base, design) {
|
|
116
|
+
if (!isObject(design)) return;
|
|
117
|
+
for (const part of ["header", "footer"]) {
|
|
118
|
+
const zones = design[part];
|
|
119
|
+
if (!isObject(zones)) continue;
|
|
120
|
+
for (const zone of ["left", "center", "right"]) {
|
|
121
|
+
if (isObject(zones[zone])) field(fields, state, [...base, "design", part, zone, "text"], zones[zone].text, `${part === "header" ? "Header" : "Footer"} ${zone}`, "furniture");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Every searchable text field of a document, in reading order: deck name and description, header and footer text, then
|
|
128
|
+
* each slide's own fields. A field is `{ pointer, path, slideIndex, label, kind, runs, text }`; `slideIndex` is -1 for
|
|
129
|
+
* the deck, `pointer` is the JSON pointer of the string or run array, and `runs` says it is a run array. Options:
|
|
130
|
+
* `notes: false` leaves speaker notes out, `slideIndex` limits the list to one slide.
|
|
131
|
+
*/
|
|
132
|
+
export function collectSearchFields(document, options = {}) {
|
|
133
|
+
const fields = [];
|
|
134
|
+
if (!isObject(document)) return fields;
|
|
135
|
+
const only = Number.isInteger(options.slideIndex) ? options.slideIndex : undefined;
|
|
136
|
+
if (only === undefined) {
|
|
137
|
+
const deck = { slideIndex: -1 };
|
|
138
|
+
field(fields, deck, ["name"], document.name, "Presentation name", "deck");
|
|
139
|
+
field(fields, deck, ["description"], document.description, "Presentation description", "deck");
|
|
140
|
+
furnitureFields(fields, deck, [], document.design);
|
|
141
|
+
}
|
|
142
|
+
(Array.isArray(document.slides) ? document.slides : []).forEach((slide, slideIndex) => {
|
|
143
|
+
if (only !== undefined && slideIndex !== only) return;
|
|
144
|
+
if (!isObject(slide)) return;
|
|
145
|
+
const state = { slideIndex };
|
|
146
|
+
const base = ["slides", String(slideIndex)];
|
|
147
|
+
field(fields, state, [...base, "title"], slide.title, "Title");
|
|
148
|
+
field(fields, state, [...base, "subtitle"], slide.subtitle, "Subtitle");
|
|
149
|
+
field(fields, state, [...base, "tag"], slide.tag, "Tag");
|
|
150
|
+
field(fields, state, [...base, "section"], slide.section, "Section");
|
|
151
|
+
payloadFields(fields, state, base, slide);
|
|
152
|
+
for (const key of Object.keys(slide)) if (REGION_KEY.test(key) && isObject(slide[key])) payloadFields(fields, state, [...base, key], slide[key], `${key} · `);
|
|
153
|
+
furnitureFields(fields, state, base, slide.design);
|
|
154
|
+
if (options.notes !== false) field(fields, state, [...base, "notes"], slide.notes, "Speaker notes", "notes");
|
|
155
|
+
});
|
|
156
|
+
return fields;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Compile a search into a global RegExp. Options: `matchCase` (default false), `wholeWord` (default false) and `regex`
|
|
163
|
+
* (default false: the query is literal text). Returns `{ regex }`, or `{ error }` with a readable message for an empty
|
|
164
|
+
* query or an invalid regular expression. Whole word means the match is not touched by a letter, digit or underscore.
|
|
165
|
+
*/
|
|
166
|
+
export function compileSearch(query, options = {}) {
|
|
167
|
+
if (typeof query !== "string" || query === "") return { error: undefined, empty: true };
|
|
168
|
+
let source = options.regex ? query : escapeRegExp(query);
|
|
169
|
+
if (options.wholeWord) source = `${WORD_BEFORE}(?:${source})${WORD_AFTER}`;
|
|
170
|
+
try {
|
|
171
|
+
return { regex: new RegExp(source, `g${options.matchCase ? "" : "i"}mu`) };
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return { error: `Not a valid regular expression: ${String(error.message).replace(/^Invalid regular expression: /, "").replace(/^\/.*\/[a-z]*: /, "")}` };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function snippet(text, start, end) {
|
|
178
|
+
const lead = Math.max(0, start - 28);
|
|
179
|
+
const tail = Math.min(text.length, end + 40);
|
|
180
|
+
const clean = (value) => value.replace(/\s+/g, " ");
|
|
181
|
+
return {
|
|
182
|
+
before: (lead > 0 ? "…" : "") + clean(text.slice(lead, start)),
|
|
183
|
+
match: clean(text.slice(start, end)),
|
|
184
|
+
after: clean(text.slice(end, tail)) + (tail < text.length ? "…" : ""),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Search a document. Returns `{ matches, fieldCount, slideCount, truncated, error }`. A match is `{ id, pointer, path,
|
|
190
|
+
* slideIndex, label, runs, start, end, text, groups, named, context: { before, match, after } }` where `start` and `end`
|
|
191
|
+
* are offsets into the field's text. Options: those of `compileSearch`, plus `notes` and `slideIndex` of
|
|
192
|
+
* `collectSearchFields`. Matches that are empty (a regular expression that matches nothing) are skipped.
|
|
193
|
+
*/
|
|
194
|
+
export function findMatches(document, query, options = {}) {
|
|
195
|
+
const compiled = compileSearch(query, options);
|
|
196
|
+
const result = { matches: [], fieldCount: 0, slideCount: 0, truncated: false, error: compiled.error };
|
|
197
|
+
if (!compiled.regex) return result;
|
|
198
|
+
const slides = new Set();
|
|
199
|
+
const fields = collectSearchFields(document, options);
|
|
200
|
+
outer: for (const item of fields) {
|
|
201
|
+
compiled.regex.lastIndex = 0;
|
|
202
|
+
let found;
|
|
203
|
+
let hit = false;
|
|
204
|
+
while ((found = compiled.regex.exec(item.text))) {
|
|
205
|
+
if (found[0] === "") {
|
|
206
|
+
compiled.regex.lastIndex += 1;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (result.matches.length >= MAX_MATCHES) {
|
|
210
|
+
result.truncated = true;
|
|
211
|
+
break outer;
|
|
212
|
+
}
|
|
213
|
+
hit = true;
|
|
214
|
+
result.matches.push({
|
|
215
|
+
id: `${item.pointer}@${found.index}`,
|
|
216
|
+
pointer: item.pointer,
|
|
217
|
+
path: item.path,
|
|
218
|
+
slideIndex: item.slideIndex,
|
|
219
|
+
label: item.label,
|
|
220
|
+
kind: item.kind,
|
|
221
|
+
runs: item.runs,
|
|
222
|
+
start: found.index,
|
|
223
|
+
end: found.index + found[0].length,
|
|
224
|
+
text: found[0],
|
|
225
|
+
groups: found.slice(1).map((group) => group ?? ""),
|
|
226
|
+
named: found.groups ? { ...found.groups } : undefined,
|
|
227
|
+
context: snippet(item.text, found.index, found.index + found[0].length),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
if (hit) {
|
|
231
|
+
result.fieldCount += 1;
|
|
232
|
+
slides.add(item.slideIndex);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
result.slideCount = slides.size;
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Expand a replacement for one match. With `regex: true` the template reads `$&` (the match), `$1`..`$99` (groups),
|
|
241
|
+
* `$<name>`, `` $` `` and `$'` (the text before and after the match in its field) and `$$` (a dollar sign); anything
|
|
242
|
+
* else is literal. Without it the replacement is literal text.
|
|
243
|
+
*/
|
|
244
|
+
export function expandReplacement(template, match, fieldText, regex) {
|
|
245
|
+
if (!regex) return template;
|
|
246
|
+
return template.replace(/\$(\$|&|`|'|\d{1,2}|<[^>]*>)/g, (all, token) => {
|
|
247
|
+
if (token === "$") return "$";
|
|
248
|
+
if (token === "&") return match.text;
|
|
249
|
+
if (token === "`") return fieldText.slice(0, match.start);
|
|
250
|
+
if (token === "'") return fieldText.slice(match.end);
|
|
251
|
+
if (token[0] === "<") return match.named && Object.hasOwn(match.named, token.slice(1, -1)) ? match.named[token.slice(1, -1)] ?? "" : all;
|
|
252
|
+
const index = Number(token);
|
|
253
|
+
if (index >= 1 && index <= match.groups.length) return match.groups[index - 1];
|
|
254
|
+
if (token.length === 2 && Number(token[0]) >= 1 && Number(token[0]) <= match.groups.length) return match.groups[Number(token[0]) - 1] + token[1];
|
|
255
|
+
return all;
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Apply replacements to a string or run array. `edits` is a list of `{ start, end, replacement }` over the field's
|
|
261
|
+
* text, sorted and not overlapping. See the file header for what happens to formatting when a range spans runs.
|
|
262
|
+
*/
|
|
263
|
+
export function applyTextEdits(value, edits) {
|
|
264
|
+
if (!edits.length) return value;
|
|
265
|
+
if (typeof value === "string") {
|
|
266
|
+
let out = "";
|
|
267
|
+
let cursor = 0;
|
|
268
|
+
for (const edit of edits) {
|
|
269
|
+
out += value.slice(cursor, edit.start) + edit.replacement;
|
|
270
|
+
cursor = edit.end;
|
|
271
|
+
}
|
|
272
|
+
return out + value.slice(cursor);
|
|
273
|
+
}
|
|
274
|
+
const result = [];
|
|
275
|
+
let offset = 0;
|
|
276
|
+
let nextEdit = 0;
|
|
277
|
+
for (const run of value) {
|
|
278
|
+
const original = runText(run);
|
|
279
|
+
const runStart = offset;
|
|
280
|
+
const runEnd = offset + original.length;
|
|
281
|
+
let text = "";
|
|
282
|
+
let cursor = runStart;
|
|
283
|
+
let touched = false;
|
|
284
|
+
// Skip edits that ended before this run began.
|
|
285
|
+
while (nextEdit < edits.length && edits[nextEdit].end <= runStart) nextEdit += 1;
|
|
286
|
+
for (let index = nextEdit; index < edits.length && edits[index].start < runEnd; index += 1) {
|
|
287
|
+
const edit = edits[index];
|
|
288
|
+
if (edit.end <= runStart) continue;
|
|
289
|
+
touched = true;
|
|
290
|
+
const from = Math.max(edit.start, runStart);
|
|
291
|
+
text += original.slice(cursor - runStart, Math.max(from, cursor) - runStart);
|
|
292
|
+
// The edit's replacement belongs to the run that holds its first matched character.
|
|
293
|
+
if (edit.start >= runStart) text += edit.replacement;
|
|
294
|
+
cursor = Math.max(cursor, Math.min(edit.end, runEnd));
|
|
295
|
+
}
|
|
296
|
+
text += original.slice(cursor - runStart);
|
|
297
|
+
offset = runEnd;
|
|
298
|
+
if (!touched) {
|
|
299
|
+
result.push(run);
|
|
300
|
+
} else if (text !== "") {
|
|
301
|
+
result.push(typeof run === "string" ? text : { ...run, text });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (!result.length) {
|
|
305
|
+
const first = value[0];
|
|
306
|
+
result.push(typeof first === "string" ? "" : { ...first, text: "" });
|
|
307
|
+
}
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function readField(document, match) {
|
|
312
|
+
const value = getValueAtPath(document, match.pointer);
|
|
313
|
+
return typeof value === "string" || Array.isArray(value) ? value : undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The patches that replace matches. `matches` is the list to replace (from `findMatches` on this document). Returns
|
|
318
|
+
* `{ patches, count, fieldCount }`; a replacement that leaves a field unchanged adds no patch. Throws `stale-match`
|
|
319
|
+
* when a match no longer reads the same text in the document.
|
|
320
|
+
*/
|
|
321
|
+
export function planReplace(document, matches, replacement, options = {}) {
|
|
322
|
+
const byField = new Map();
|
|
323
|
+
for (const match of matches) {
|
|
324
|
+
if (!byField.has(match.pointer)) byField.set(match.pointer, []);
|
|
325
|
+
byField.get(match.pointer).push(match);
|
|
326
|
+
}
|
|
327
|
+
const patches = [];
|
|
328
|
+
let count = 0;
|
|
329
|
+
for (const [pointer, list] of byField) {
|
|
330
|
+
const value = readField(document, list[0]);
|
|
331
|
+
const text = value === undefined ? undefined : textOf(value);
|
|
332
|
+
const ordered = [...list].sort((a, b) => a.start - b.start);
|
|
333
|
+
if (text === undefined || ordered.some((match) => text.slice(match.start, match.end) !== match.text)) {
|
|
334
|
+
throw new OPFEditorError("stale-match", "The text changed since this search. Search again.", { path: list[0].path });
|
|
335
|
+
}
|
|
336
|
+
const edits = ordered.map((match) => ({ start: match.start, end: match.end, replacement: expandReplacement(replacement, match, text, options.regex) }));
|
|
337
|
+
const next = applyTextEdits(value, edits);
|
|
338
|
+
count += ordered.length;
|
|
339
|
+
if (JSON.stringify(next) !== JSON.stringify(value)) patches.push({ op: "replace", path: pointer, value: next });
|
|
340
|
+
}
|
|
341
|
+
return { patches, count, fieldCount: byField.size };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function search(document, query, options) {
|
|
345
|
+
const found = findMatches(document, query, options);
|
|
346
|
+
if (found.error) throw new OPFEditorError("invalid-search", found.error, {});
|
|
347
|
+
return found;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Replace every match of a search in one undoable session change. Returns `{ count, fieldCount, change }`; `change` is
|
|
352
|
+
* the session's result (or null when nothing changed). One undo restores every replaced field.
|
|
353
|
+
*/
|
|
354
|
+
export function replaceAll(editor, query, replacement, options = {}) {
|
|
355
|
+
const found = search(editor.document, query, options);
|
|
356
|
+
const plan = planReplace(editor.document, found.matches, String(replacement ?? ""), options);
|
|
357
|
+
if (!plan.patches.length) return { count: 0, fieldCount: 0, change: null };
|
|
358
|
+
const change = editor.applyPatch(plan.patches, { source: "find-replace", rejectInvalid: true, replaced: plan.count });
|
|
359
|
+
return { count: plan.count, fieldCount: plan.fieldCount, change };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Replace one match (an element of `findMatches(...).matches`) as one undoable change. The match is found again in the
|
|
364
|
+
* current document first: `stale-match` is thrown when the text changed underneath it.
|
|
365
|
+
*/
|
|
366
|
+
export function replaceMatch(editor, match, query, replacement, options = {}) {
|
|
367
|
+
const found = search(editor.document, query, options);
|
|
368
|
+
const current = found.matches.find((candidate) => candidate.pointer === match.pointer && candidate.start === match.start && candidate.end === match.end && candidate.text === match.text);
|
|
369
|
+
if (!current) throw new OPFEditorError("stale-match", "That match is no longer in the document. Search again.", { path: match.path });
|
|
370
|
+
const plan = planReplace(editor.document, [current], String(replacement ?? ""), options);
|
|
371
|
+
if (!plan.patches.length) return { count: 0, change: null };
|
|
372
|
+
const change = editor.applyPatch(plan.patches, { source: "find-replace", rejectInvalid: true, replaced: 1 });
|
|
373
|
+
return { count: 1, change };
|
|
374
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
|
|
2
|
+
import type { GridDelimiter } from "./grid-text.js";
|
|
3
|
+
|
|
4
|
+
/** A cell of a chart's data or of a table: the header line is `{ section: "header", column }`; a body row is `{ section: "body", row, column }` (rows count from 0). */
|
|
5
|
+
export interface GridAddress {
|
|
6
|
+
section: "header" | "body";
|
|
7
|
+
/** Body row; ignored for the header section. */
|
|
8
|
+
row?: number;
|
|
9
|
+
column: number;
|
|
10
|
+
}
|
|
11
|
+
/** One cell edit: the new `text` (read by the cell's rules: a chart series cell is a number in the number format, blank is a gap) or a typed `value`. */
|
|
12
|
+
export type GridCellEdit = GridAddress & ({ text: string; value?: never } | { value: unknown; text?: never });
|
|
13
|
+
export interface GridOptions {
|
|
14
|
+
/** "." or ",": how chart numbers are written and read (default "."). */
|
|
15
|
+
decimal?: "." | ",";
|
|
16
|
+
/** Alternative to `decimal`: "auto" (from `locale`), "." or ",". */
|
|
17
|
+
numberFormat?: "auto" | "." | ",";
|
|
18
|
+
locale?: string;
|
|
19
|
+
/** Metadata recorded with the session event. */
|
|
20
|
+
meta?: Record<string, unknown>;
|
|
21
|
+
/** Refuse unless the path is this kind of content. */
|
|
22
|
+
kind?: "chart" | "table";
|
|
23
|
+
delimiter?: GridDelimiter;
|
|
24
|
+
}
|
|
25
|
+
export interface GridSortOptions extends GridOptions {
|
|
26
|
+
direction?: "asc" | "desc";
|
|
27
|
+
/** "auto" (numbers, then ISO dates, then text), "text", "number" or "date". */
|
|
28
|
+
type?: "auto" | "text" | "number" | "date";
|
|
29
|
+
}
|
|
30
|
+
export interface PreparedGridChange {
|
|
31
|
+
action: string;
|
|
32
|
+
kind: "chart" | "table";
|
|
33
|
+
/** The chart or table path. */
|
|
34
|
+
path: string;
|
|
35
|
+
document: unknown;
|
|
36
|
+
patches: JsonPatchOperation[];
|
|
37
|
+
changed: boolean;
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
}
|
|
40
|
+
export interface GridChange extends Omit<EditorChange, "document" | "patches"> {
|
|
41
|
+
action: string;
|
|
42
|
+
kind: "chart" | "table";
|
|
43
|
+
path: string;
|
|
44
|
+
document: unknown;
|
|
45
|
+
patches: JsonPatchOperation[];
|
|
46
|
+
changed: boolean;
|
|
47
|
+
[key: string]: unknown;
|
|
48
|
+
}
|
|
49
|
+
export interface GridIssue extends GridAddress {
|
|
50
|
+
message: string;
|
|
51
|
+
}
|
|
52
|
+
export interface GridCell {
|
|
53
|
+
/** Index in `lines` (the header line first when the grid has one). */
|
|
54
|
+
line: number;
|
|
55
|
+
column: number;
|
|
56
|
+
section: "header" | "body";
|
|
57
|
+
row: number;
|
|
58
|
+
/** What the cell shows. A table cell shows what the slide draws; a chart number shows in the number format. "" for a gap or a covered cell. */
|
|
59
|
+
text: string;
|
|
60
|
+
rich: boolean;
|
|
61
|
+
runs?: unknown[];
|
|
62
|
+
rowSpan: number;
|
|
63
|
+
colSpan: number;
|
|
64
|
+
/** Covered by a merged cell: not a cell of its own. */
|
|
65
|
+
covered: boolean;
|
|
66
|
+
owner?: { line: number; column: number };
|
|
67
|
+
style?: Record<string, unknown>;
|
|
68
|
+
warning?: string;
|
|
69
|
+
}
|
|
70
|
+
export interface DataGridDescription {
|
|
71
|
+
kind: "chart" | "table";
|
|
72
|
+
path: string;
|
|
73
|
+
chartType?: string;
|
|
74
|
+
hasHeader: boolean;
|
|
75
|
+
/** Body rows. */
|
|
76
|
+
rowCount: number;
|
|
77
|
+
columnCount: number;
|
|
78
|
+
/** For a chart: "category", "series", and for scatter "label" and "x". Empty for a table. */
|
|
79
|
+
columnRoles: string[];
|
|
80
|
+
lines: GridCell[][];
|
|
81
|
+
warnings: Array<GridAddress & { message: string }>;
|
|
82
|
+
merges: Array<GridAddress & { rowSpan: number; colSpan: number }>;
|
|
83
|
+
}
|
|
84
|
+
export interface DataGridTarget {
|
|
85
|
+
kind: "chart" | "table";
|
|
86
|
+
path: string;
|
|
87
|
+
/** false for a chart whose data comes from a source. */
|
|
88
|
+
editable?: false;
|
|
89
|
+
reason?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Spreadsheet column letters: 0 is A, 26 is AA. */
|
|
93
|
+
export declare function columnLabel(index: number): string;
|
|
94
|
+
/** "Header, column B" or "Row 3, column A". */
|
|
95
|
+
export declare function describeAddress(address: GridAddress): string;
|
|
96
|
+
/** What each chart column is for. */
|
|
97
|
+
export declare function chartColumnRoles(columnCount: number, chartType?: string): string[];
|
|
98
|
+
/** The text a cell shows. */
|
|
99
|
+
export declare function cellText(raw: unknown, kind: "chart" | "table", decimal?: "." | ","): string;
|
|
100
|
+
/** Find the data a path points at (`…chart`, `…chart.data`, `…table`). Throws `grid-target-not-found`, `table-not-found` or `chart-data-source`. */
|
|
101
|
+
export declare function locateGridData(document: unknown, path: string | string[]): { kind: "chart" | "table"; parts: string[]; dataParts: string[]; data: Record<string, unknown>; chartType?: string };
|
|
102
|
+
/** The chart or table a selection path belongs to, or undefined. */
|
|
103
|
+
export declare function resolveDataGridTarget(document: unknown, selectedPath: string): DataGridTarget | undefined;
|
|
104
|
+
/** The grid view of a chart or table: lines of cells with spans, covered positions, text, warnings. */
|
|
105
|
+
export declare function describeDataGrid(document: unknown, path: string, options?: GridOptions): DataGridDescription;
|
|
106
|
+
/** The smallest guarded patch from `before` to `after` at `parts`. */
|
|
107
|
+
export declare function gridPatches(parts: string[], before: unknown, after: unknown): JsonPatchOperation[];
|
|
108
|
+
/** The problems the edits would have, without applying them or validating the document. */
|
|
109
|
+
export declare function gridCellIssues(document: unknown, path: string, edits: GridCellEdit[], options?: GridOptions): GridIssue[];
|
|
110
|
+
/** The text of a range (`{ from, to }` addresses; the whole grid when omitted) as TSV, or another delimiter. */
|
|
111
|
+
export declare function gridRangeText(document: unknown, path: string, range?: { from: GridAddress; to: GridAddress }, options?: GridOptions): string;
|
|
112
|
+
|
|
113
|
+
export declare function prepareGridCells(document: unknown, path: string, edits: GridCellEdit[], options?: GridOptions): PreparedGridChange;
|
|
114
|
+
export declare function preparePaste(document: unknown, path: string, anchor: GridAddress, source: string | string[][], options?: GridOptions): PreparedGridChange;
|
|
115
|
+
export declare function prepareInsertRows(document: unknown, path: string, at: number, count?: number, options?: GridOptions): PreparedGridChange;
|
|
116
|
+
export declare function prepareDeleteRows(document: unknown, path: string, indices: number[], options?: GridOptions): PreparedGridChange;
|
|
117
|
+
export declare function prepareMoveRows(document: unknown, path: string, from: number, to: number, count?: number, options?: GridOptions): PreparedGridChange;
|
|
118
|
+
export declare function prepareInsertColumns(document: unknown, path: string, at: number, count?: number, options?: GridOptions): PreparedGridChange;
|
|
119
|
+
export declare function prepareDeleteColumns(document: unknown, path: string, indices: number[], options?: GridOptions): PreparedGridChange;
|
|
120
|
+
export declare function prepareMoveColumns(document: unknown, path: string, from: number, to: number, count?: number, options?: GridOptions): PreparedGridChange;
|
|
121
|
+
export declare function prepareSortRows(document: unknown, path: string, column: number, options?: GridSortOptions): PreparedGridChange;
|
|
122
|
+
export declare function prepareSetHeader(document: unknown, path: string, enabled: boolean, options?: { use?: "first-row" | "new" }): PreparedGridChange;
|
|
123
|
+
export declare function prepareTranspose(document: unknown, path: string): PreparedGridChange;
|
|
124
|
+
|
|
125
|
+
export declare function setGridCells(editor: EditorSession, path: string, edits: GridCellEdit[], options?: GridOptions): GridChange;
|
|
126
|
+
export declare function pasteGridText(editor: EditorSession, path: string, anchor: GridAddress, source: string | string[][], options?: GridOptions): GridChange;
|
|
127
|
+
export declare function insertGridRows(editor: EditorSession, path: string, at: number, count?: number, options?: GridOptions): GridChange;
|
|
128
|
+
export declare function deleteGridRows(editor: EditorSession, path: string, indices: number[], options?: GridOptions): GridChange;
|
|
129
|
+
export declare function moveGridRows(editor: EditorSession, path: string, from: number, to: number, count?: number, options?: GridOptions): GridChange;
|
|
130
|
+
export declare function insertGridColumns(editor: EditorSession, path: string, at: number, count?: number, options?: GridOptions): GridChange;
|
|
131
|
+
export declare function deleteGridColumns(editor: EditorSession, path: string, indices: number[], options?: GridOptions): GridChange;
|
|
132
|
+
export declare function moveGridColumns(editor: EditorSession, path: string, from: number, to: number, count?: number, options?: GridOptions): GridChange;
|
|
133
|
+
export declare function sortGridRows(editor: EditorSession, path: string, column: number, options?: GridSortOptions): GridChange;
|
|
134
|
+
export declare function setGridHeader(editor: EditorSession, path: string, enabled: boolean, options?: { use?: "first-row" | "new"; meta?: Record<string, unknown> }): GridChange;
|
|
135
|
+
export declare function transposeGridData(editor: EditorSession, path: string, options?: { meta?: Record<string, unknown> }): GridChange;
|