@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.
Files changed (76) hide show
  1. package/README.md +333 -8
  2. package/dist/annotations.d.ts +71 -0
  3. package/dist/annotations.js +281 -0
  4. package/dist/assets.d.ts +67 -0
  5. package/dist/assets.js +176 -0
  6. package/dist/background-options.d.ts +48 -0
  7. package/dist/background-options.js +134 -0
  8. package/dist/block-convert.d.ts +64 -0
  9. package/dist/block-convert.js +142 -0
  10. package/dist/canvas.d.ts +16 -0
  11. package/dist/canvas.js +82 -21
  12. package/dist/chart-data.d.ts +32 -0
  13. package/dist/chart-data.js +101 -0
  14. package/dist/chart-options-panel.d.ts +16 -0
  15. package/dist/chart-options-panel.js +127 -0
  16. package/dist/chart-options.d.ts +49 -0
  17. package/dist/chart-options.js +157 -0
  18. package/dist/content-actions.d.ts +91 -0
  19. package/dist/content-actions.js +207 -0
  20. package/dist/content-controls.js +326 -0
  21. package/dist/data-grid.d.ts +37 -0
  22. package/dist/data-grid.js +1035 -0
  23. package/dist/design-controls.d.ts +43 -0
  24. package/dist/design-controls.js +1077 -0
  25. package/dist/design-options.d.ts +108 -0
  26. package/dist/design-options.js +412 -0
  27. package/dist/edit-helpers.js +52 -0
  28. package/dist/export.d.ts +77 -0
  29. package/dist/export.js +216 -0
  30. package/dist/find-panel.d.ts +44 -0
  31. package/dist/find-panel.js +431 -0
  32. package/dist/find-replace.d.ts +100 -0
  33. package/dist/find-replace.js +374 -0
  34. package/dist/grid-model.d.ts +135 -0
  35. package/dist/grid-model.js +836 -0
  36. package/dist/grid-text.d.ts +33 -0
  37. package/dist/grid-text.js +251 -0
  38. package/dist/image-crop.d.ts +59 -0
  39. package/dist/image-crop.js +336 -0
  40. package/dist/image-cropper.d.ts +29 -0
  41. package/dist/image-cropper.js +519 -0
  42. package/dist/index.d.ts +11 -1
  43. package/dist/index.js +104 -171
  44. package/dist/numbering-panel.d.ts +21 -0
  45. package/dist/numbering-panel.js +200 -0
  46. package/dist/numbering.d.ts +62 -0
  47. package/dist/numbering.js +223 -0
  48. package/dist/outline-view.d.ts +17 -0
  49. package/dist/outline-view.js +278 -0
  50. package/dist/outline.d.ts +56 -0
  51. package/dist/outline.js +271 -0
  52. package/dist/persistence-ui.d.ts +24 -0
  53. package/dist/persistence-ui.js +81 -0
  54. package/dist/persistence.d.ts +105 -0
  55. package/dist/persistence.js +429 -0
  56. package/dist/review-panel.d.ts +44 -0
  57. package/dist/review-panel.js +359 -0
  58. package/dist/review.d.ts +75 -0
  59. package/dist/review.js +170 -0
  60. package/dist/slide-manager.d.ts +44 -0
  61. package/dist/slide-manager.js +695 -0
  62. package/dist/slides.d.ts +96 -0
  63. package/dist/slides.js +433 -0
  64. package/dist/switches.d.ts +26 -0
  65. package/dist/switches.js +127 -43
  66. package/dist/table-options.d.ts +80 -0
  67. package/dist/table-options.js +419 -0
  68. package/dist/table-structure.d.ts +30 -0
  69. package/dist/table-structure.js +92 -0
  70. package/dist/template-panel.d.ts +31 -0
  71. package/dist/template-panel.js +377 -0
  72. package/dist/templates.d.ts +126 -0
  73. package/dist/templates.js +331 -0
  74. package/dist/zip.d.ts +4 -0
  75. package/dist/zip.js +71 -0
  76. package/package.json +150 -10
@@ -0,0 +1,431 @@
1
+ // Find and replace panel (RR-25): the DOM over the find-replace model. A docked, non-modal panel with a find field,
2
+ // a replace field, match case / whole word / regular expression options, a "this slide only" scope, previous and next,
3
+ // replace one and replace all, and a results list whose entries go to the match on the canvas. Replace all is one
4
+ // undo step and the panel offers it right there. Keyboard: Ctrl/Cmd+F opens find, Ctrl/Cmd+H (and Ctrl/Cmd+Shift+H)
5
+ // opens find and replace (see `installFindShortcuts`), Enter / Shift+Enter in the find field go to the next / previous
6
+ // match, F3 and Shift+F3 do the same anywhere in the panel, Enter in the replace field replaces the current match,
7
+ // Ctrl/Cmd+Alt+Enter replaces all, Esc closes and returns focus. Importing this module does not need a DOM.
8
+ import { getValueAtPath } from "./index.js";
9
+ import { collectSearchFields, expandReplacement, findMatches, replaceAll, replaceMatch, textOf } from "./find-replace.js";
10
+
11
+ const MAX_LISTED = 300;
12
+ let panelCounter = 0;
13
+
14
+ const STYLE_ID = "opf-find-panel-style";
15
+ const CSS = `
16
+ .opf-find{box-sizing:border-box;display:flex;flex-direction:column;gap:8px;width:100%;max-width:560px;padding:10px 12px 12px;background:#fff;color:#2c2838;border:1px solid #dcd5eb;border-radius:8px;box-shadow:0 8px 30px #28203922;font:13px/1.45 system-ui,sans-serif}
17
+ .opf-find[hidden]{display:none}
18
+ .opf-find *{box-sizing:border-box}
19
+ .opf-find-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
20
+ .opf-find-head strong{font-size:13px}
21
+ .opf-find-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px 8px}
22
+ .opf-find-row[hidden]{display:none}
23
+ .opf-find-field{flex:1 1 180px;display:flex;flex-direction:column;gap:2px;min-width:0}
24
+ .opf-find-field>span{font-size:11px;color:#6d6580}
25
+ .opf-find input[type=text]{width:100%;min-height:34px;padding:6px 8px;font:inherit;border:1px solid #cfc8de;border-radius:6px;background:#fff;color:inherit}
26
+ .opf-find input[type=text][aria-invalid=true]{border-color:#b4381f}
27
+ .opf-find button{min-height:34px;padding:5px 10px;font:inherit;border:1px solid #cfc8de;border-radius:6px;background:#f6f3fc;color:#463a63;cursor:pointer}
28
+ .opf-find button:disabled{opacity:.45;cursor:default}
29
+ .opf-find button.opf-find-primary{background:#6559cf;border-color:#6559cf;color:#fff}
30
+ .opf-find button.opf-find-close{min-width:34px;padding:0 8px;font-size:18px;line-height:1;background:transparent;border-color:transparent}
31
+ .opf-find button:focus-visible,.opf-find input:focus-visible,.opf-find-result:focus-visible{outline:2px solid #6559cf;outline-offset:2px}
32
+ .opf-find-opt{display:inline-flex;align-items:center;gap:5px;min-height:30px;padding:2px 8px;border:1px solid #cfc8de;border-radius:6px;font-size:12px;cursor:pointer;user-select:none}
33
+ .opf-find-opt:has(input:checked){background:#e9e5fa;border-color:#6559cf}
34
+ .opf-find-opt input{margin:0}
35
+ .opf-find-count{font-size:12px;color:#5d5666;min-height:1.4em}
36
+ .opf-find-error{font-size:12px;color:#b4381f}
37
+ .opf-find-error:empty{display:none}
38
+ .opf-find-undo{min-height:26px;padding:1px 8px}
39
+ .opf-find-results{list-style:none;margin:0;padding:0;max-height:min(30vh,240px);overflow:auto;border:1px solid #ebe7f4;border-radius:6px}
40
+ .opf-find-results:empty{display:none}
41
+ .opf-find-results li{margin:0;padding:0;border-top:1px solid #f1eef8}
42
+ .opf-find-results li:first-child{border-top:0}
43
+ .opf-find-result{display:block;width:100%;min-height:44px;padding:6px 10px;text-align:left;border:0;border-radius:0;background:transparent;color:inherit;font:inherit;cursor:pointer}
44
+ .opf-find-result[aria-current=true]{background:#efebfc}
45
+ .opf-find-where{display:block;font-size:11px;color:#6d6580}
46
+ .opf-find-text{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
47
+ .opf-find-text mark{background:#ffe08a;color:inherit;border-radius:2px;padding:0 1px}
48
+ .opf-find-more{font-size:11px;color:#6d6580;padding:6px 10px}
49
+ @media (pointer:coarse){.opf-find input[type=text]{font-size:16px}.opf-find button,.opf-find-opt{min-height:44px}.opf-find button.opf-find-close{min-width:44px}}
50
+ `;
51
+
52
+ function h(doc, tag, attributes = {}, ...children) {
53
+ const element = doc.createElement(tag);
54
+ for (const [key, value] of Object.entries(attributes)) {
55
+ if (value === undefined || value === false) continue;
56
+ if (key === "class") element.className = value;
57
+ else if (key === "text") element.textContent = value;
58
+ else element.setAttribute(key, value === true ? "" : String(value));
59
+ }
60
+ for (const child of children) if (child) element.append(child);
61
+ return element;
62
+ }
63
+
64
+ const plural = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
65
+ const whereLabel = (match) => (match.slideIndex < 0 ? `Presentation · ${match.label}` : `Slide ${match.slideIndex + 1} · ${match.label}`);
66
+
67
+ /**
68
+ * Mount the find and replace panel in `container` (it is hidden until `open()`).
69
+ *
70
+ * Options: `editor` (a session; required), `canvas` (a CanvasEditor: matches are shown and selected on it), `goToSlide(index)`
71
+ * (a host that owns slide navigation shows the slide; defaults to `canvas.setSlide`), `onGoTo(match, selectedPath)` (called after a
72
+ * go to, with the path selected on the canvas or null, and `{ via }`: "list" for a click on a result, "step" for next, previous and
73
+ * after a replacement; a host puts speaker notes or a deck field in view here, and moves focus only for "list"), `getSlideIndex`,
74
+ * `getSelectionText()` (seeds the find field when the panel opens), `commit()` (default `canvas.commit`: finishes an
75
+ * inline edit before the document is replaced), `onStatus(message, { error })`, `onClose()`.
76
+ */
77
+ export function createFindPanel(container, options) {
78
+ const { editor } = options;
79
+ // `canvas` may be a CanvasEditor or a function returning the current one (a host can recreate its canvas).
80
+ const getCanvas = () => (typeof options.canvas === "function" ? options.canvas() : options.canvas);
81
+ if (!editor || typeof editor.applyPatch !== "function") throw new TypeError("createFindPanel needs an editor session.");
82
+ const doc = container.ownerDocument;
83
+ const id = `opf-find-${++panelCounter}`;
84
+ const state = { query: "", replacement: "", matchCase: false, wholeWord: false, regex: false, thisSlide: false, replaceMode: false, open: false };
85
+ let result = { matches: [], fieldCount: 0, slideCount: 0, truncated: false };
86
+ let current = -1;
87
+ let destroyed = false;
88
+ let opener = null;
89
+ let lastReplaceAll = null;
90
+
91
+ if (!doc.getElementById(STYLE_ID)) {
92
+ const style = h(doc, "style", { id: STYLE_ID });
93
+ style.textContent = CSS;
94
+ (doc.head ?? container).append(style);
95
+ }
96
+
97
+ const root = h(doc, "section", { class: "opf-find", id, role: "dialog", "aria-modal": "false", "aria-label": "Find and replace", "data-opf-component": "find-panel", hidden: true });
98
+ const title = h(doc, "strong", { text: "Find" });
99
+ const closeButton = h(doc, "button", { type: "button", class: "opf-find-close", "aria-label": "Close find and replace", title: "Close (Esc)", text: "×" });
100
+ const head = h(doc, "div", { class: "opf-find-head" }, title, closeButton);
101
+
102
+ const findInput = h(doc, "input", { type: "text", id: `${id}-find`, autocomplete: "off", autocapitalize: "off", spellcheck: "false", enterkeyhint: "search", "aria-keyshortcuts": "Control+F Meta+F", "aria-describedby": `${id}-count ${id}-error` });
103
+ const findField = h(doc, "label", { class: "opf-find-field", for: `${id}-find` }, h(doc, "span", { text: "Find" }), findInput);
104
+ const previousButton = h(doc, "button", { type: "button", "aria-label": "Previous match", title: "Previous match (Shift+Enter)", text: "↑" });
105
+ const nextButton = h(doc, "button", { type: "button", "aria-label": "Next match", title: "Next match (Enter)", text: "↓" });
106
+ const findRow = h(doc, "div", { class: "opf-find-row" }, findField, previousButton, nextButton);
107
+
108
+ const replaceInput = h(doc, "input", { type: "text", id: `${id}-replace`, autocomplete: "off", autocapitalize: "off", spellcheck: "false", enterkeyhint: "go", "aria-keyshortcuts": "Control+H Meta+H" });
109
+ const replaceField = h(doc, "label", { class: "opf-find-field", for: `${id}-replace` }, h(doc, "span", { text: "Replace with" }), replaceInput);
110
+ const replaceOneButton = h(doc, "button", { type: "button", text: "Replace" });
111
+ const replaceAllButton = h(doc, "button", { type: "button", class: "opf-find-primary", "aria-keyshortcuts": "Control+Alt+Enter", text: "Replace all" });
112
+ const replaceRow = h(doc, "div", { class: "opf-find-row", hidden: true }, replaceField, replaceOneButton, replaceAllButton);
113
+
114
+ const option = (key, label) => {
115
+ const input = h(doc, "input", { type: "checkbox", "data-option": key });
116
+ return { input, label: h(doc, "label", { class: "opf-find-opt" }, input, h(doc, "span", { text: label })) };
117
+ };
118
+ const optCase = option("matchCase", "Match case");
119
+ const optWord = option("wholeWord", "Whole word");
120
+ const optRegex = option("regex", "Regex");
121
+ const optSlide = option("thisSlide", "This slide only");
122
+ const optionsRow = h(doc, "div", { class: "opf-find-row", role: "group", "aria-label": "Search options" }, optCase.label, optWord.label, optRegex.label, optSlide.label);
123
+
124
+ const count = h(doc, "p", { class: "opf-find-count", id: `${id}-count`, role: "status", "aria-live": "polite" });
125
+ const undoButton = h(doc, "button", { type: "button", class: "opf-find-undo", text: "Undo", hidden: true });
126
+ const statusRow = h(doc, "div", { class: "opf-find-row" }, count, undoButton);
127
+ const errorLine = h(doc, "p", { class: "opf-find-error", id: `${id}-error`, role: "alert" });
128
+ const list = h(doc, "ul", { class: "opf-find-results", "aria-label": "Matches" });
129
+ root.append(head, findRow, replaceRow, optionsRow, statusRow, errorLine, list);
130
+ container.append(root);
131
+
132
+ const report = (message, error = false) => options.onStatus?.(message, { error });
133
+ const setError = (message) => {
134
+ errorLine.textContent = message ?? "";
135
+ findInput.setAttribute("aria-invalid", message ? "true" : "false");
136
+ };
137
+ const sessionOptions = () => ({
138
+ matchCase: state.matchCase,
139
+ wholeWord: state.wholeWord,
140
+ regex: state.regex,
141
+ slideIndex: state.thisSlide ? (options.getSlideIndex?.() ?? getCanvas()?.slideIndex ?? 0) : undefined,
142
+ });
143
+
144
+ function renderResults() {
145
+ const { matches } = result;
146
+ list.replaceChildren();
147
+ matches.slice(0, MAX_LISTED).forEach((match, index) => {
148
+ const mark = h(doc, "mark", { text: match.context.match });
149
+ const text = h(doc, "span", { class: "opf-find-text" }, doc.createTextNode(match.context.before), mark, doc.createTextNode(match.context.after));
150
+ const button = h(doc, "button", { type: "button", class: "opf-find-result", "aria-current": index === current ? "true" : "false", "data-index": String(index), "aria-label": `${whereLabel(match)}: ${match.context.before}${match.context.match}${match.context.after}` },
151
+ h(doc, "span", { class: "opf-find-where", text: whereLabel(match) }), text);
152
+ list.append(h(doc, "li", {}, button));
153
+ });
154
+ if (matches.length > MAX_LISTED) list.append(h(doc, "li", { class: "opf-find-more", text: `Showing the first ${MAX_LISTED} of ${matches.length}${result.truncated ? "+" : ""} matches.` }));
155
+ }
156
+
157
+ function renderCount() {
158
+ const { matches } = result;
159
+ if (state.query === "") count.textContent = "Type to search the whole presentation.";
160
+ else if (result.error) count.textContent = "";
161
+ else if (!matches.length) count.textContent = `No matches${state.thisSlide ? " on this slide" : ""}.`;
162
+ else {
163
+ const slides = new Set(matches.filter((match) => match.slideIndex >= 0).map((match) => match.slideIndex)).size;
164
+ const deck = matches.some((match) => match.slideIndex < 0);
165
+ const where = [slides ? plural(slides, "slide") : "", deck ? "the presentation settings" : ""].filter(Boolean).join(" and ");
166
+ count.textContent = `${current >= 0 ? `${current + 1} of ` : ""}${matches.length}${result.truncated ? "+" : ""} ${matches.length === 1 ? "match" : "matches"} in ${where}`;
167
+ }
168
+ }
169
+
170
+ function renderButtons() {
171
+ const any = result.matches.length > 0 && !result.error;
172
+ previousButton.disabled = nextButton.disabled = !any;
173
+ replaceOneButton.disabled = !any;
174
+ replaceAllButton.disabled = !any;
175
+ root.toggleAttribute("data-has-matches", any);
176
+ }
177
+
178
+ function search(keep) {
179
+ const before = current >= 0 ? result.matches[current] : undefined;
180
+ result = findMatches(editor.document, state.query, sessionOptions());
181
+ setError(result.error);
182
+ if (!result.matches.length) current = -1;
183
+ else if (keep && before) {
184
+ // Keep the same place after a document change: the match at the same field and offset, else the first at or after it.
185
+ const same = result.matches.findIndex((match) => match.pointer === before.pointer && match.start >= before.start);
186
+ current = same >= 0 ? same : Math.min(current, result.matches.length - 1);
187
+ } else {
188
+ const slide = options.getSlideIndex?.() ?? getCanvas()?.slideIndex ?? 0;
189
+ const first = result.matches.findIndex((match) => match.slideIndex >= slide);
190
+ current = first >= 0 ? first : 0;
191
+ }
192
+ renderCount();
193
+ renderResults();
194
+ renderButtons();
195
+ }
196
+
197
+ async function goTo(index, { focusResult = false, via = "list" } = {}) {
198
+ const match = result.matches[index];
199
+ if (!match) return;
200
+ current = index;
201
+ renderCount();
202
+ for (const button of list.querySelectorAll(".opf-find-result")) button.setAttribute("aria-current", button.dataset.index === String(index) ? "true" : "false");
203
+ list.querySelector(`[data-index="${index}"]`)?.scrollIntoView?.({ block: "nearest" });
204
+ if (focusResult) list.querySelector(`[data-index="${index}"]`)?.focus();
205
+ if (options.commit ? !options.commit() : getCanvas() && !getCanvas().commit()) return;
206
+ let selected = null;
207
+ if (match.slideIndex >= 0) {
208
+ if (options.goToSlide) await options.goToSlide(match.slideIndex);
209
+ else if (getCanvas() && getCanvas().slideIndex !== match.slideIndex) getCanvas().setSlide(match.slideIndex);
210
+ if (destroyed) return;
211
+ selected = getCanvas()?.reveal(match.path) ?? null;
212
+ }
213
+ options.onGoTo?.(match, selected, { via });
214
+ report(`${whereLabel(match)}: ${match.context.before}${match.context.match}${match.context.after}`);
215
+ }
216
+
217
+ const step = (delta) => {
218
+ if (!result.matches.length) return;
219
+ const total = result.matches.length;
220
+ return goTo((((current < 0 ? (delta > 0 ? -1 : 0) : current) + delta) % total + total) % total, { via: "step" });
221
+ };
222
+
223
+ function finishCommit() {
224
+ if (options.commit ? !options.commit() : getCanvas() && !getCanvas().commit()) {
225
+ setError("Finish or cancel the edit in progress first.");
226
+ return false;
227
+ }
228
+ return true;
229
+ }
230
+
231
+ function doReplaceOne() {
232
+ const match = result.matches[current];
233
+ if (!match || !finishCommit()) return;
234
+ const order = new Map(collectSearchFields(editor.document, sessionOptions()).map((field, index) => [field.pointer, index]));
235
+ const fieldText = textOf(getValueAtPath(editor.document, match.pointer));
236
+ const written = match.start + expandReplacement(state.replacement, match, fieldText, state.regex).length;
237
+ try {
238
+ replaceMatch(editor, match, state.query, state.replacement, sessionOptions());
239
+ } catch (error) {
240
+ setError(error.message);
241
+ search(true);
242
+ return;
243
+ }
244
+ lastReplaceAll = null;
245
+ undoButton.hidden = true;
246
+ setError("");
247
+ // Move on to the next match after the replaced text, in reading order (never the text just written).
248
+ const replacedAt = order.get(match.pointer) ?? -1;
249
+ result = findMatches(editor.document, state.query, sessionOptions());
250
+ const nextOrder = new Map(collectSearchFields(editor.document, sessionOptions()).map((field, index) => [field.pointer, index]));
251
+ let next = result.matches.findIndex((candidate) => {
252
+ const position = nextOrder.get(candidate.pointer) ?? Infinity;
253
+ return position > replacedAt || (position === replacedAt && candidate.start >= written);
254
+ });
255
+ if (next < 0 && result.matches.length) next = 0;
256
+ current = next;
257
+ renderCount();
258
+ renderResults();
259
+ renderButtons();
260
+ report(`Replaced one match${result.matches.length ? `; ${plural(result.matches.length, "match", "matches")} left` : "; no matches left"}`);
261
+ if (current >= 0) goTo(current, { via: "step" });
262
+ }
263
+
264
+ function doReplaceAll() {
265
+ if (!result.matches.length || !finishCommit()) return;
266
+ let done;
267
+ try {
268
+ done = replaceAll(editor, state.query, state.replacement, sessionOptions());
269
+ } catch (error) {
270
+ setError(error.issues?.[0]?.message ?? error.message);
271
+ search(true);
272
+ return;
273
+ }
274
+ setError("");
275
+ search(false);
276
+ const message = done.count ? `Replaced ${plural(done.count, "match", "matches")} in ${plural(done.fieldCount, "text field")}. One undo restores them.` : "Nothing to replace.";
277
+ lastReplaceAll = done.count ? done : null;
278
+ undoButton.hidden = !lastReplaceAll;
279
+ count.textContent = message;
280
+ report(message);
281
+ }
282
+
283
+ function setMode(replace) {
284
+ state.replaceMode = replace;
285
+ replaceRow.hidden = !replace;
286
+ title.textContent = replace ? "Find and replace" : "Find";
287
+ }
288
+
289
+ function readOptions() {
290
+ state.query = findInput.value;
291
+ state.replacement = replaceInput.value;
292
+ state.matchCase = optCase.input.checked;
293
+ state.wholeWord = optWord.input.checked;
294
+ state.regex = optRegex.input.checked;
295
+ state.thisSlide = optSlide.input.checked;
296
+ }
297
+ const onInput = () => {
298
+ readOptions();
299
+ lastReplaceAll = null;
300
+ undoButton.hidden = true;
301
+ search(false);
302
+ };
303
+ findInput.addEventListener("input", onInput);
304
+ replaceInput.addEventListener("input", readOptions);
305
+ for (const item of [optCase, optWord, optRegex, optSlide]) item.input.addEventListener("change", onInput);
306
+ previousButton.addEventListener("click", () => step(-1));
307
+ nextButton.addEventListener("click", () => step(1));
308
+ replaceOneButton.addEventListener("click", doReplaceOne);
309
+ replaceAllButton.addEventListener("click", doReplaceAll);
310
+ undoButton.addEventListener("click", () => {
311
+ if (!lastReplaceAll) return;
312
+ lastReplaceAll = null;
313
+ undoButton.hidden = true;
314
+ editor.undo();
315
+ search(false);
316
+ count.textContent = "Undid the replacement.";
317
+ report("Undid the replacement");
318
+ });
319
+ closeButton.addEventListener("click", () => api.close());
320
+ list.addEventListener("click", (event) => {
321
+ const button = event.target.closest?.(".opf-find-result");
322
+ if (button) goTo(Number(button.dataset.index));
323
+ });
324
+ list.addEventListener("keydown", (event) => {
325
+ const buttons = [...list.querySelectorAll(".opf-find-result")];
326
+ const at = buttons.indexOf(doc.activeElement);
327
+ if (at < 0 || !["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
328
+ event.preventDefault();
329
+ const to = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : Math.max(0, Math.min(buttons.length - 1, at + (event.key === "ArrowDown" ? 1 : -1)));
330
+ buttons[to]?.focus();
331
+ });
332
+ root.addEventListener("keydown", (event) => {
333
+ const meta = event.ctrlKey || event.metaKey;
334
+ if (event.key === "Escape") {
335
+ event.preventDefault();
336
+ event.stopPropagation();
337
+ api.close();
338
+ } else if (event.key === "F3") {
339
+ event.preventDefault();
340
+ step(event.shiftKey ? -1 : 1);
341
+ } else if (event.key === "Enter" && event.target === findInput) {
342
+ event.preventDefault();
343
+ step(event.shiftKey ? -1 : 1);
344
+ } else if (event.key === "Enter" && event.target === replaceInput) {
345
+ event.preventDefault();
346
+ if (meta && event.altKey) doReplaceAll();
347
+ else doReplaceOne();
348
+ } else if (event.key === "Enter" && meta && event.altKey) {
349
+ event.preventDefault();
350
+ doReplaceAll();
351
+ }
352
+ });
353
+ const unsubscribe = editor.subscribe(() => {
354
+ if (destroyed || !state.open) return;
355
+ search(true);
356
+ });
357
+
358
+ const api = {
359
+ element: root,
360
+ get isOpen() { return state.open; },
361
+ get matches() { return result.matches; },
362
+ get current() { return current; },
363
+ /** Show the panel (find, or find and replace with `{ replace: true }`), focus the find field and select its text. `{ query }` seeds the search. */
364
+ open({ replace = state.replaceMode, query } = {}) {
365
+ if (destroyed) return;
366
+ opener = state.open ? opener : doc.activeElement;
367
+ const seed = query ?? options.getSelectionText?.();
368
+ if (typeof seed === "string" && seed.trim() && !/[\r\n]/.test(seed) && seed.length <= 200) findInput.value = seed;
369
+ state.open = true;
370
+ root.hidden = false;
371
+ setMode(replace);
372
+ readOptions();
373
+ search(false);
374
+ findInput.focus();
375
+ findInput.select();
376
+ },
377
+ close() {
378
+ if (!state.open) return;
379
+ state.open = false;
380
+ root.hidden = true;
381
+ const restore = opener && doc.contains(opener) ? opener : null;
382
+ opener = null;
383
+ restore?.focus?.({ preventScroll: true });
384
+ options.onClose?.();
385
+ },
386
+ /** Set the search programmatically (tests and hosts). */
387
+ set(values = {}) {
388
+ if (typeof values.query === "string") findInput.value = values.query;
389
+ if (typeof values.replacement === "string") replaceInput.value = values.replacement;
390
+ for (const [key, item] of [["matchCase", optCase], ["wholeWord", optWord], ["regex", optRegex], ["thisSlide", optSlide]]) if (typeof values[key] === "boolean") item.input.checked = values[key];
391
+ readOptions();
392
+ search(false);
393
+ },
394
+ next: () => step(1),
395
+ previous: () => step(-1),
396
+ goTo,
397
+ replaceOne: doReplaceOne,
398
+ replaceAll: doReplaceAll,
399
+ refresh: () => search(true),
400
+ destroy() {
401
+ destroyed = true;
402
+ unsubscribe();
403
+ root.remove();
404
+ },
405
+ };
406
+ return api;
407
+ }
408
+
409
+ /**
410
+ * Wire the find shortcuts on a document: Ctrl/Cmd+F opens find, Ctrl/Cmd+H and Ctrl/Cmd+Shift+H open find and replace
411
+ * (macOS reserves Cmd+H for hiding the window, so use Ctrl+H or Cmd+Shift+H there). The browser's own find is only
412
+ * replaced while the editor has the focus; a modal dialog that is open keeps its own handling. Returns a function that
413
+ * removes the listener. `isEnabled()` can turn the shortcuts off.
414
+ */
415
+ export function installFindShortcuts(panel, { document: doc = globalThis.document, isEnabled } = {}) {
416
+ const handler = (event) => {
417
+ if (!(event.ctrlKey || event.metaKey) || event.altKey || isEnabled?.() === false) return;
418
+ const key = event.key.toLowerCase();
419
+ const wantsReplace = key === "h";
420
+ const wantsFind = key === "f" && !event.shiftKey;
421
+ if (!wantsFind && !wantsReplace) return;
422
+ // A modal dialog (the source editor, import, export) keeps its own find.
423
+ const modal = doc.querySelector?.("dialog[open]");
424
+ if (modal && !modal.contains(panel.element)) return;
425
+ event.preventDefault();
426
+ event.stopPropagation();
427
+ panel.open(wantsReplace ? { replace: true } : {});
428
+ };
429
+ doc.addEventListener("keydown", handler, true);
430
+ return () => doc.removeEventListener("keydown", handler, true);
431
+ }
@@ -0,0 +1,100 @@
1
+ import type { EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
2
+
3
+ /** At most this many matches are collected; `truncated` says so. */
4
+ export declare const MAX_MATCHES: number;
5
+
6
+ /** One searchable piece of text: a string or a run array. */
7
+ export interface SearchField {
8
+ /** JSON pointer of the string or the run array. */
9
+ pointer: string;
10
+ /** Dotted OPF path, as the canvas traces it (`slides.2.items.1`). */
11
+ path: string;
12
+ /** -1 for the deck (name, description, header and footer text). */
13
+ slideIndex: number;
14
+ /** For example `Title`, `List item 2`, `Table row 1, column 3`, `Speaker notes`. */
15
+ label: string;
16
+ kind: string;
17
+ /** True for a run array (rich text). */
18
+ runs: boolean;
19
+ /** The text: a string field's value, or a run array's runs joined. */
20
+ text: string;
21
+ }
22
+ export interface CollectOptions {
23
+ /** Leave speaker notes out (default: searched). */
24
+ notes?: boolean;
25
+ /** Search one slide only (deck fields are then left out). */
26
+ slideIndex?: number;
27
+ }
28
+ export interface SearchOptions extends CollectOptions {
29
+ /** Default false. */
30
+ matchCase?: boolean;
31
+ /** The match is not touched by a letter, digit or underscore. Default false. */
32
+ wholeWord?: boolean;
33
+ /** Treat the query as a regular expression (`$1`, `$&`, `$<name>` work in the replacement). Default false: literal text. */
34
+ regex?: boolean;
35
+ }
36
+ export interface SearchMatch {
37
+ /** `<pointer>@<start>`: stable while the document does not change. */
38
+ id: string;
39
+ pointer: string;
40
+ path: string;
41
+ slideIndex: number;
42
+ label: string;
43
+ kind: string;
44
+ runs: boolean;
45
+ /** Offsets into the field's text. */
46
+ start: number;
47
+ end: number;
48
+ text: string;
49
+ groups: string[];
50
+ named?: Record<string, string>;
51
+ context: { before: string; match: string; after: string };
52
+ }
53
+ export interface SearchResult {
54
+ matches: SearchMatch[];
55
+ /** Fields with at least one match. */
56
+ fieldCount: number;
57
+ /** Slides (and the deck) with at least one match. */
58
+ slideCount: number;
59
+ truncated: boolean;
60
+ /** A readable message for an invalid regular expression. */
61
+ error?: string;
62
+ }
63
+ export interface ReplacePlan {
64
+ patches: JsonPatchOperation[];
65
+ count: number;
66
+ fieldCount: number;
67
+ }
68
+ export interface ReplaceResult {
69
+ count: number;
70
+ fieldCount?: number;
71
+ /** The session's change, or null when nothing changed. One undo reverses it. */
72
+ change: EditorChange | null;
73
+ }
74
+ export interface TextEdit {
75
+ start: number;
76
+ end: number;
77
+ replacement: string;
78
+ }
79
+
80
+ /** The plain text of a string or a run array. */
81
+ export declare function textOf(value: unknown): string;
82
+ /** Every searchable text field of a document, in reading order. */
83
+ export declare function collectSearchFields(document: unknown, options?: CollectOptions): SearchField[];
84
+ /** Compile a search into a global RegExp; `{ error }` for an invalid regular expression, `{ empty: true }` for an empty query. */
85
+ export declare function compileSearch(query: string, options?: SearchOptions): { regex?: RegExp; error?: string; empty?: boolean };
86
+ /** Search a document's text. */
87
+ export declare function findMatches(document: unknown, query: string, options?: SearchOptions): SearchResult;
88
+ /** Expand a replacement for one match (`$&`, `$1`, `$<name>`, `$$` in regex mode; literal otherwise). */
89
+ export declare function expandReplacement(template: string, match: SearchMatch, fieldText: string, regex?: boolean): string;
90
+ /**
91
+ * Apply sorted, non-overlapping edits to a string or run array. A match inside one run keeps every run's formatting; a
92
+ * match across runs takes the formatting of the run that holds its first character.
93
+ */
94
+ export declare function applyTextEdits<T extends string | unknown[]>(value: T, edits: TextEdit[]): T;
95
+ /** The patches that replace the given matches. Throws `stale-match` when the text changed. */
96
+ export declare function planReplace(document: unknown, matches: SearchMatch[], replacement: string, options?: { regex?: boolean }): ReplacePlan;
97
+ /** Replace every match as one undoable session change. Throws `invalid-search` for a bad regular expression. */
98
+ export declare function replaceAll(editor: EditorSession, query: string, replacement: string, options?: SearchOptions): ReplaceResult;
99
+ /** Replace one match as one undoable change. Throws `stale-match` when it is no longer in the document. */
100
+ export declare function replaceMatch(editor: EditorSession, match: SearchMatch, query: string, replacement: string, options?: SearchOptions): ReplaceResult;