superdoc-macros 0.3.0 → 0.5.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/manager.js CHANGED
@@ -13,7 +13,9 @@ import { createIframeRunner } from './scripting/iframe-runner.js';
13
13
  import { MacroRecorder, replayMacro } from './recorder/recorder.js';
14
14
  import { AutoText } from './snippets/autotext.js';
15
15
  import { expandSnippet } from './snippets/snippets.js';
16
- import { bindShortcuts } from './shortcuts.js';
16
+ import { IMPORT_LIMITS, isPersistableState } from './storage.js';
17
+ import { bindShortcuts, hasBindingModifier, parseShortcut, shortcutSignatures, } from './shortcuts.js';
18
+ import { MacroError } from './scripting/macro-api.js';
17
19
  import { createLocalStorage, emptyState, parsePersistedState } from './storage.js';
18
20
  import { macroMessages } from './messages.js';
19
21
  let idCounter = 0;
@@ -35,6 +37,8 @@ export class MacroKit {
35
37
  state;
36
38
  recorder;
37
39
  autoText;
40
+ reservedSignatures;
41
+ scriptsEnabled;
38
42
  running = false;
39
43
  constructor(options) {
40
44
  this.host = options.host;
@@ -44,15 +48,86 @@ export class MacroKit {
44
48
  const runner = options.runner ?? 'iframe';
45
49
  this.runner =
46
50
  runner === 'iframe' ? createIframeRunner() : runner === 'eval' ? createEvalRunner() : runner;
51
+ this.scriptsEnabled = options.scriptsEnabled ?? true;
47
52
  this.state = this.storage.load() ?? emptyState();
48
- this.recorder = new MacroRecorder(this.host);
49
- this.autoText = new AutoText(this.host, () => this.state.snippets, options.autoText);
53
+ this.recorder = new MacroRecorder(this.host, { onAutoStop: options.onRecordingAutoStop });
54
+ this.autoText = new AutoText(this.host, () => this.state.snippets, {
55
+ ...options.autoText,
56
+ // The recorder rewrite keeps recordings truthful: the user typed a
57
+ // trigger word, the document holds the expanded text, and a replay of
58
+ // the raw keystrokes would diverge. See applyAutoTextExpansion.
59
+ onExpand: (snippet, expansion) => {
60
+ this.recorder.applyAutoTextExpansion(expansion.trigger.length + expansion.expandChar.length, expansion.rendered + expansion.expandChar);
61
+ options.autoText?.onExpand?.(snippet, expansion);
62
+ },
63
+ });
64
+ const reserved = new Set();
65
+ for (const shortcut of options.reservedShortcuts ?? []) {
66
+ const parsed = parseShortcut(shortcut);
67
+ if (parsed)
68
+ for (const signature of shortcutSignatures(parsed))
69
+ reserved.add(signature);
70
+ }
71
+ this.reservedSignatures = reserved;
72
+ }
73
+ /* ---------- Shortcut validation ---------- */
74
+ /**
75
+ * Whether a shortcut is acceptable for a saved binding: parseable, carries
76
+ * a real modifier, not reserved by the host, and not already used by
77
+ * another saved item (`excludeId` skips the item being edited). Empty or
78
+ * undefined means "no shortcut" and is fine. The save paths enforce this;
79
+ * UIs call it directly to show the message before saving.
80
+ */
81
+ validateShortcut(shortcut, excludeId) {
82
+ const trimmed = shortcut?.trim();
83
+ if (!trimmed)
84
+ return { ok: true };
85
+ const parsed = parseShortcut(trimmed);
86
+ if (!parsed)
87
+ return { ok: false, message: macroMessages().shortcutInvalid };
88
+ if (!hasBindingModifier(parsed))
89
+ return { ok: false, message: macroMessages().shortcutNeedsModifier };
90
+ const signatures = shortcutSignatures(parsed);
91
+ if (signatures.some((signature) => this.reservedSignatures.has(signature))) {
92
+ return { ok: false, message: macroMessages().shortcutReserved };
93
+ }
94
+ const owner = this.findShortcutOwner(signatures, excludeId);
95
+ if (owner)
96
+ return { ok: false, message: macroMessages().shortcutTaken(owner) };
97
+ return { ok: true };
98
+ }
99
+ findShortcutOwner(signatures, excludeId) {
100
+ const items = [
101
+ ...this.state.scripts,
102
+ ...this.state.recordings,
103
+ ...this.state.snippets,
104
+ ];
105
+ for (const item of items) {
106
+ if (!item.shortcut || item.id === excludeId)
107
+ continue;
108
+ const parsed = parseShortcut(item.shortcut);
109
+ if (!parsed)
110
+ continue;
111
+ const existing = shortcutSignatures(parsed);
112
+ if (existing.some((signature) => signatures.includes(signature)))
113
+ return item.name;
114
+ }
115
+ return null;
116
+ }
117
+ /** Throws a MacroError when the shortcut is unacceptable. The save paths call this. */
118
+ requireValidShortcut(shortcut, excludeId) {
119
+ const validation = this.validateShortcut(shortcut, excludeId);
120
+ if (!validation.ok)
121
+ throw new MacroError(validation.message, 'invalid-shortcut');
50
122
  }
51
123
  /* ---------- Scripts ---------- */
52
124
  listScripts() {
53
125
  return this.state.scripts;
54
126
  }
55
127
  saveScript(input) {
128
+ this.requireValidShortcut(input.shortcut, input.id);
129
+ this.requireItemLimits({ name: input.name, source: input.source });
130
+ this.requireRoom(this.state.scripts, input.id);
56
131
  const script = {
57
132
  id: input.id ?? newId(),
58
133
  name: input.name,
@@ -75,6 +150,11 @@ export class MacroKit {
75
150
  }
76
151
  /** Runs an unsaved script — e.g. from the macro editor before saving. */
77
152
  async runSource(source) {
153
+ // The real gate: with scripts disabled nothing executes through any
154
+ // path — not a saved script, not an imported one, not its shortcut.
155
+ if (!this.scriptsEnabled) {
156
+ return { ok: false, reason: 'error', message: macroMessages().scriptsDisabled };
157
+ }
78
158
  const guard = this.guardRun();
79
159
  if (guard)
80
160
  return guard;
@@ -101,7 +181,10 @@ export class MacroKit {
101
181
  }
102
182
  /** Stops and saves. `null` when no step was recorded — there is nothing to save. */
103
183
  stopRecording(name, shortcut) {
104
- const steps = this.recorder.stop();
184
+ this.requireValidShortcut(shortcut);
185
+ this.requireItemLimits({ name });
186
+ this.requireRoom(this.state.recordings);
187
+ const steps = splitOversizedSteps(this.recorder.stop());
105
188
  if (steps.length === 0)
106
189
  return null;
107
190
  const recording = {
@@ -131,6 +214,10 @@ export class MacroKit {
131
214
  const recording = this.state.recordings.find((entry) => entry.id === input.id);
132
215
  if (!recording)
133
216
  return null;
217
+ if (input.shortcut !== undefined)
218
+ this.requireValidShortcut(input.shortcut, input.id);
219
+ if (input.name !== undefined)
220
+ this.requireItemLimits({ name: input.name });
134
221
  if (input.name !== undefined)
135
222
  recording.name = input.name;
136
223
  if (input.shortcut !== undefined) {
@@ -168,6 +255,9 @@ export class MacroKit {
168
255
  return this.state.snippets;
169
256
  }
170
257
  saveSnippet(input) {
258
+ this.requireValidShortcut(input.shortcut, input.id);
259
+ this.requireItemLimits({ name: input.name, text: input.text, trigger: input.trigger });
260
+ this.requireRoom(this.state.snippets, input.id);
171
261
  const snippet = {
172
262
  id: input.id ?? newId(),
173
263
  name: input.name,
@@ -209,9 +299,14 @@ export class MacroKit {
209
299
  }
210
300
  currentBindings() {
211
301
  const bindings = [];
212
- for (const script of this.state.scripts) {
213
- if (script.shortcut)
214
- bindings.push({ shortcut: script.shortcut, run: () => this.runScript(script.id) });
302
+ // Part of the scripts gate: with scripts disabled their shortcuts are
303
+ // not bound at all — runScript would refuse anyway, but an unbound key
304
+ // is better than a key that swallows the event just to show an error.
305
+ if (this.scriptsEnabled) {
306
+ for (const script of this.state.scripts) {
307
+ if (script.shortcut)
308
+ bindings.push({ shortcut: script.shortcut, run: () => this.runScript(script.id) });
309
+ }
215
310
  }
216
311
  for (const recording of this.state.recordings) {
217
312
  if (recording.shortcut)
@@ -231,25 +326,81 @@ export class MacroKit {
231
326
  * Imports JSON produced by `exportState`. With `merge: true` an imported
232
327
  * item with an existing `id` replaces it; without merge the whole state is
233
328
  * replaced.
329
+ *
330
+ * The check is **atomic, on the final result**: a candidate state is built
331
+ * first, its size limits and every shortcut in it are validated (the same
332
+ * rules the save paths enforce — a file cannot smuggle in what typing
333
+ * cannot), and only a candidate that passed in full is committed. On any
334
+ * failure the current state is untouched.
234
335
  */
235
336
  importState(json, options = {}) {
236
337
  const imported = parsePersistedState(json);
237
338
  if (!imported)
238
339
  return { ok: false, message: macroMessages().invalidImport };
340
+ let candidate;
239
341
  if (options.merge) {
342
+ candidate = {
343
+ version: 1,
344
+ scripts: [...this.state.scripts.map((item) => ({ ...item }))],
345
+ recordings: [...this.state.recordings.map((item) => ({ ...item }))],
346
+ snippets: [...this.state.snippets.map((item) => ({ ...item }))],
347
+ };
240
348
  for (const script of imported.scripts)
241
- this.upsert(this.state.scripts, script);
349
+ this.upsert(candidate.scripts, script);
242
350
  for (const recording of imported.recordings)
243
- this.upsert(this.state.recordings, recording);
351
+ this.upsert(candidate.recordings, recording);
244
352
  for (const snippet of imported.snippets)
245
- this.upsert(this.state.snippets, snippet);
353
+ this.upsert(candidate.snippets, snippet);
246
354
  }
247
355
  else {
248
- this.state = imported;
356
+ candidate = imported;
249
357
  }
358
+ // The merged result can exceed what each file alone respected.
359
+ if (!isPersistableState(candidate))
360
+ return { ok: false, message: macroMessages().importTooLarge };
361
+ const shortcutsOk = this.validateStateShortcuts(candidate);
362
+ if (!shortcutsOk.ok)
363
+ return shortcutsOk;
364
+ this.state = candidate;
250
365
  this.persist();
251
366
  return { ok: true };
252
367
  }
368
+ /**
369
+ * Every shortcut in a candidate state, under the exact rules of
370
+ * `validateShortcut`: parseable, real modifier, not host-reserved, and
371
+ * unique within the candidate. `importState` is the only caller — the
372
+ * save paths enforce the same rules one item at a time.
373
+ */
374
+ validateStateShortcuts(candidate) {
375
+ const seen = new Map();
376
+ const items = [
377
+ ...candidate.scripts,
378
+ ...candidate.recordings,
379
+ ...candidate.snippets,
380
+ ];
381
+ for (const item of items) {
382
+ if (!item.shortcut)
383
+ continue;
384
+ const parsed = parseShortcut(item.shortcut);
385
+ if (!parsed) {
386
+ return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutInvalid) };
387
+ }
388
+ if (!hasBindingModifier(parsed)) {
389
+ return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutNeedsModifier) };
390
+ }
391
+ for (const signature of shortcutSignatures(parsed)) {
392
+ if (this.reservedSignatures.has(signature)) {
393
+ return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutReserved) };
394
+ }
395
+ const owner = seen.get(signature);
396
+ if (owner !== undefined) {
397
+ return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutTaken(owner)) };
398
+ }
399
+ seen.set(signature, item.name);
400
+ }
401
+ }
402
+ return { ok: true };
403
+ }
253
404
  /* ---------- Internal ---------- */
254
405
  guardRun() {
255
406
  if (this.recorder.recording) {
@@ -260,6 +411,37 @@ export class MacroKit {
260
411
  }
261
412
  return null;
262
413
  }
414
+ /**
415
+ * The save-path half of the persistence invariant: field lengths that the
416
+ * loader would reject are refused at the door. Without this, one oversized
417
+ * save would make the whole store unloadable — and the next startup would
418
+ * silently fall back to an empty state, losing everything.
419
+ */
420
+ requireItemLimits(fields) {
421
+ const limits = IMPORT_LIMITS;
422
+ if (fields.name.length === 0)
423
+ throw new MacroError(macroMessages().nameRequired, 'invalid-item');
424
+ if (fields.name.length > limits.maxNameLength) {
425
+ throw new MacroError(macroMessages().fieldTooLong('name', limits.maxNameLength), 'invalid-item');
426
+ }
427
+ if (fields.text !== undefined && fields.text.length > limits.maxTextLength) {
428
+ throw new MacroError(macroMessages().fieldTooLong('text', limits.maxTextLength), 'invalid-item');
429
+ }
430
+ if (fields.source !== undefined && fields.source.length > limits.maxSourceLength) {
431
+ throw new MacroError(macroMessages().fieldTooLong('source', limits.maxSourceLength), 'invalid-item');
432
+ }
433
+ if (fields.trigger !== undefined && fields.trigger.length > limits.maxTriggerLength) {
434
+ throw new MacroError(macroMessages().fieldTooLong('trigger', limits.maxTriggerLength), 'invalid-item');
435
+ }
436
+ }
437
+ /** The item-count half of the invariant. `existingId` exempts an in-place update. */
438
+ requireRoom(list, existingId) {
439
+ if (existingId && list.some((entry) => entry.id === existingId))
440
+ return;
441
+ if (list.length >= IMPORT_LIMITS.maxItems) {
442
+ throw new MacroError(macroMessages().tooManyItems, 'too-many-items');
443
+ }
444
+ }
263
445
  upsert(list, item) {
264
446
  const index = list.findIndex((entry) => entry.id === item.id);
265
447
  if (index >= 0)
@@ -268,6 +450,39 @@ export class MacroKit {
268
450
  list.push(item);
269
451
  }
270
452
  persist() {
453
+ // Final safety net for the invariant: state the loader would reject is
454
+ // never written. Reaching this branch is a bug in a save path above —
455
+ // the warning is what surfaces it — but the user's stored macros
456
+ // surviving that bug is the point.
457
+ if (!isPersistableState(this.state)) {
458
+ console.warn('[superdoc-macros] refusing to persist state that would fail to load');
459
+ return;
460
+ }
271
461
  this.storage.save(this.state);
272
462
  }
273
463
  }
464
+ /**
465
+ * A recorded insert-text step can exceed the loader's per-step cap (one huge
466
+ * paste coalesces into one step). Splitting preserves the exact text while
467
+ * keeping the recording loadable.
468
+ */
469
+ function splitOversizedSteps(steps) {
470
+ const max = IMPORT_LIMITS.maxTextLength;
471
+ const split = steps.flatMap((step) => {
472
+ if (step.type !== 'insert-text' || step.text.length <= max)
473
+ return [step];
474
+ const chunks = [];
475
+ for (let offset = 0; offset < step.text.length; offset += max) {
476
+ chunks.push({ type: 'insert-text', text: step.text.slice(offset, offset + max) });
477
+ }
478
+ return chunks;
479
+ });
480
+ // Splitting can push a cap-length recording past the loader's step limit.
481
+ // Truncating the tail is the honest option left: the alternative is a
482
+ // recording the loader rejects, which loses the whole store's worth more.
483
+ if (split.length > IMPORT_LIMITS.maxStepsPerRecording) {
484
+ console.warn('[superdoc-macros] recording truncated to the step limit');
485
+ return split.slice(0, IMPORT_LIMITS.maxStepsPerRecording);
486
+ }
487
+ return split;
488
+ }
@@ -27,14 +27,25 @@ export interface MacroMessages {
27
27
  syntaxError: (detail: string) => string;
28
28
  timedOut: (seconds: number) => string;
29
29
  callLimitExceeded: (limit: number) => string;
30
- deleteForwardUnsupported: string;
30
+ macroStopped: string;
31
31
  scriptNotFound: string;
32
32
  recordingNotFound: string;
33
33
  snippetNotFound: string;
34
34
  cannotRunWhileRecording: string;
35
35
  anotherMacroRunning: string;
36
+ scriptsDisabled: string;
37
+ nameRequired: string;
36
38
  invalidImport: string;
39
+ importRejectedShortcut: (itemName: string, detail: string) => string;
40
+ importTooLarge: string;
41
+ tooManyItems: string;
42
+ fieldTooLong: (field: string, max: number) => string;
43
+ shortcutInvalid: string;
44
+ shortcutNeedsModifier: string;
45
+ shortcutReserved: string;
46
+ shortcutTaken: (ownerName: string) => string;
37
47
  noDocument: string;
48
+ selectionUnavailable: string;
38
49
  unknownCommand: (id: string) => string;
39
50
  actionFailed: string;
40
51
  deletionUnavailable: string;
package/dist/messages.js CHANGED
@@ -27,14 +27,25 @@ export const ENGLISH_MESSAGES = {
27
27
  syntaxError: (detail) => `Macro syntax error: ${detail}`,
28
28
  timedOut: (seconds) => `The macro did not finish within ${seconds} seconds and was stopped`,
29
29
  callLimitExceeded: (limit) => `The macro exceeded the API call limit (${limit}) and was stopped`,
30
- deleteForwardUnsupported: 'Forward deletion is not supported during replay',
30
+ macroStopped: 'The macro was stopped the call was not executed',
31
31
  scriptNotFound: 'Macro not found',
32
32
  recordingNotFound: 'Recording not found',
33
33
  snippetNotFound: 'Snippet not found',
34
34
  cannotRunWhileRecording: 'Cannot run a macro while recording',
35
35
  anotherMacroRunning: 'Another macro is still running',
36
+ scriptsDisabled: 'Scripted macros are disabled',
36
37
  invalidImport: 'The file is not a valid macro export',
38
+ importRejectedShortcut: (itemName, detail) => `Import rejected: the shortcut of "${itemName}" is not acceptable — ${detail}`,
39
+ importTooLarge: 'Import rejected: the merged result exceeds the item limits',
40
+ tooManyItems: 'The list is full — delete items before adding new ones',
41
+ nameRequired: 'A name is required',
42
+ fieldTooLong: (field, max) => `${field} is too long (limit: ${max} characters)`,
43
+ shortcutInvalid: 'Invalid shortcut — use a form like Ctrl+Alt+M',
44
+ shortcutNeedsModifier: 'A shortcut must include Ctrl, Alt or Meta',
45
+ shortcutReserved: 'This shortcut is reserved by the editor',
46
+ shortcutTaken: (ownerName) => `This shortcut is already used by "${ownerName}"`,
37
47
  noDocument: 'No document is open',
48
+ selectionUnavailable: 'The caret position could not be read — nothing was inserted',
38
49
  unknownCommand: (id) => `The engine does not recognize the command ${id}`,
39
50
  actionFailed: 'The operation failed',
40
51
  deletionUnavailable: 'Deletion is not available in this document',
@@ -53,14 +64,25 @@ export const HEBREW_MESSAGES = {
53
64
  syntaxError: (detail) => `שגיאת תחביר במאקרו: ${detail}`,
54
65
  timedOut: (seconds) => `המאקרו לא הסתיים תוך ${seconds} שניות ונעצר`,
55
66
  callLimitExceeded: (limit) => `המאקרו חצה את תקרת הקריאות (${limit}) ונעצר`,
56
- deleteForwardUnsupported: 'מחיקה קדימה אינה נתמכת בניגון',
67
+ macroStopped: 'המאקרו נעצר הקריאה לא בוצעה',
57
68
  scriptNotFound: 'המאקרו לא נמצא',
58
69
  recordingNotFound: 'ההקלטה לא נמצאה',
59
70
  snippetNotFound: 'הקטע לא נמצא',
60
71
  cannotRunWhileRecording: 'אי אפשר להריץ מאקרו בזמן הקלטה',
61
72
  anotherMacroRunning: 'מאקרו אחר עדיין רץ',
73
+ scriptsDisabled: 'מאקרו כתובים מושבתים',
62
74
  invalidImport: 'הקובץ אינו ייצוא מאקרו תקין',
75
+ importRejectedShortcut: (itemName, detail) => `הייבוא נדחה: הקיצור של "${itemName}" אינו קביל — ${detail}`,
76
+ importTooLarge: 'הייבוא נדחה: התוצאה הממוזגת חורגת מתקרת הפריטים',
77
+ tooManyItems: 'הרשימה מלאה — יש למחוק פריטים לפני הוספה',
78
+ nameRequired: 'חובה לתת שם',
79
+ fieldTooLong: (field, max) => `${field} ארוך מדי (התקרה: ${max} תווים)`,
80
+ shortcutInvalid: 'קיצור לא תקין — הצורה הנדרשת היא למשל Ctrl+Alt+M',
81
+ shortcutNeedsModifier: 'קיצור חייב לכלול Ctrl,‏ Alt או Meta',
82
+ shortcutReserved: 'הקיצור הזה שמור לעורך',
83
+ shortcutTaken: (ownerName) => `הקיצור כבר בשימוש של "${ownerName}"`,
63
84
  noDocument: 'אין מסמך פתוח',
85
+ selectionUnavailable: 'קריאת מיקום הסמן נכשלה — לא הוכנס דבר',
64
86
  unknownCommand: (id) => `הפקודה ${id} אינה מוכרת למנוע`,
65
87
  actionFailed: 'הפעולה נכשלה',
66
88
  deletionUnavailable: 'מחיקה אינה זמינה במסמך הזה',
@@ -1,14 +1,36 @@
1
+ /**
2
+ * A Word-style macro recorder: records **commands** and typing, not caret
3
+ * positions.
4
+ *
5
+ * That choice is deliberate. Recording raw ProseMirror steps (with absolute
6
+ * positions) breaks the moment the document differs from what it was at
7
+ * recording time; recording commands ("bold", "bullet-list", typing a
8
+ * greeting) behaves like Word's recorder — the actions apply wherever the
9
+ * caret is at replay time. It is also what makes a recording saveable and
10
+ * shareable: the steps are plain JSON.
11
+ *
12
+ * What is not recorded: caret movement and mouse selection. As in Word, a
13
+ * recorded macro acts from wherever the caret stands when it runs.
14
+ */
1
15
  import type { MacroHost, MacroStep } from '../types.js';
2
16
  export interface RecorderOptions {
3
17
  /** Command filter. The default records everything except undo/redo. */
4
18
  shouldRecordCommand?: (id: string) => boolean;
5
19
  /** Step cap per recording, against a recording left running by mistake. */
6
20
  maxSteps?: number;
21
+ /**
22
+ * Called when the step cap stops the recording. The steps are kept —
23
+ * `stop()` still returns them — but listening has ceased, and a UI that
24
+ * shows "recording" must be told, or its indicator would keep promising a
25
+ * recording that is no longer happening.
26
+ */
27
+ onAutoStop?: () => void;
7
28
  }
8
29
  export declare class MacroRecorder {
9
30
  private readonly host;
10
31
  private readonly shouldRecordCommand;
11
32
  private readonly maxSteps;
33
+ private readonly onAutoStop?;
12
34
  private steps;
13
35
  private disposers;
14
36
  private active;
@@ -16,10 +38,28 @@ export declare class MacroRecorder {
16
38
  get recording(): boolean;
17
39
  get stepCount(): number;
18
40
  start(): void;
19
- /** Stops and returns the steps. Empty when nothing was recorded. */
41
+ /**
42
+ * Stops and returns the steps. Empty when nothing was recorded. Also the
43
+ * way to collect a recording that auto-stopped at the cap — the steps are
44
+ * kept until someone asks for them.
45
+ */
20
46
  stop(): MacroStep[];
21
47
  /** Stops and discards whatever was recorded. */
22
48
  cancel(): void;
49
+ /**
50
+ * Rewrites the recorded tail after an auto-text expansion: the user typed
51
+ * a trigger word plus the expansion character, but what the document now
52
+ * holds is the expanded text — a replay of the raw keystrokes would
53
+ * diverge (and would depend on auto-text being active at replay time).
54
+ * The trailing `consumed` characters are removed from the recorded
55
+ * insert-text steps and the expanded text is recorded in their place.
56
+ *
57
+ * If the tail does not hold `consumed` plain characters (a command landed
58
+ * mid-word, or the recording started mid-trigger), the rewrite is skipped
59
+ * and the raw keystrokes stay — a truthful raw recording beats a guessed
60
+ * edit of steps that do not match.
61
+ */
62
+ applyAutoTextExpansion(consumed: number, replacement: string): void;
23
63
  private teardown;
24
64
  private push;
25
65
  private recordCommand;
@@ -1,18 +1,3 @@
1
- /**
2
- * A Word-style macro recorder: records **commands** and typing, not caret
3
- * positions.
4
- *
5
- * That choice is deliberate. Recording raw ProseMirror steps (with absolute
6
- * positions) breaks the moment the document differs from what it was at
7
- * recording time; recording commands ("bold", "bullet-list", typing a
8
- * greeting) behaves like Word's recorder — the actions apply wherever the
9
- * caret is at replay time. It is also what makes a recording saveable and
10
- * shareable: the steps are plain JSON.
11
- *
12
- * What is not recorded: caret movement and mouse selection. As in Word, a
13
- * recorded macro acts from wherever the caret stands when it runs.
14
- */
15
- import { macroMessages } from '../messages.js';
16
1
  const DEFAULT_MAX_STEPS = 5_000;
17
2
  /** Undo/redo during recording fix the recording itself — replaying them would replay the mistake too. */
18
3
  function defaultShouldRecord(id) {
@@ -22,6 +7,7 @@ export class MacroRecorder {
22
7
  host;
23
8
  shouldRecordCommand;
24
9
  maxSteps;
10
+ onAutoStop;
25
11
  steps = [];
26
12
  disposers = [];
27
13
  active = false;
@@ -29,6 +15,7 @@ export class MacroRecorder {
29
15
  this.host = host;
30
16
  this.shouldRecordCommand = options.shouldRecordCommand ?? defaultShouldRecord;
31
17
  this.maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
18
+ this.onAutoStop = options.onAutoStop;
32
19
  }
33
20
  get recording() {
34
21
  return this.active;
@@ -46,10 +33,12 @@ export class MacroRecorder {
46
33
  this.host.onTextInput((event) => this.recordTextInput(event)),
47
34
  ];
48
35
  }
49
- /** Stops and returns the steps. Empty when nothing was recorded. */
36
+ /**
37
+ * Stops and returns the steps. Empty when nothing was recorded. Also the
38
+ * way to collect a recording that auto-stopped at the cap — the steps are
39
+ * kept until someone asks for them.
40
+ */
50
41
  stop() {
51
- if (!this.active)
52
- return [];
53
42
  this.teardown();
54
43
  const recorded = this.steps;
55
44
  this.steps = [];
@@ -57,22 +46,64 @@ export class MacroRecorder {
57
46
  }
58
47
  /** Stops and discards whatever was recorded. */
59
48
  cancel() {
60
- if (!this.active)
61
- return;
62
49
  this.teardown();
63
50
  this.steps = [];
64
51
  }
52
+ /**
53
+ * Rewrites the recorded tail after an auto-text expansion: the user typed
54
+ * a trigger word plus the expansion character, but what the document now
55
+ * holds is the expanded text — a replay of the raw keystrokes would
56
+ * diverge (and would depend on auto-text being active at replay time).
57
+ * The trailing `consumed` characters are removed from the recorded
58
+ * insert-text steps and the expanded text is recorded in their place.
59
+ *
60
+ * If the tail does not hold `consumed` plain characters (a command landed
61
+ * mid-word, or the recording started mid-trigger), the rewrite is skipped
62
+ * and the raw keystrokes stay — a truthful raw recording beats a guessed
63
+ * edit of steps that do not match.
64
+ */
65
+ applyAutoTextExpansion(consumed, replacement) {
66
+ if (!this.active || consumed <= 0)
67
+ return;
68
+ // Verify the tail is entirely typed text before touching anything.
69
+ let remaining = consumed;
70
+ let index = this.steps.length - 1;
71
+ while (remaining > 0 && index >= 0) {
72
+ const step = this.steps[index];
73
+ if (step?.type !== 'insert-text')
74
+ return;
75
+ remaining -= step.text.length;
76
+ index -= 1;
77
+ }
78
+ if (remaining > 0)
79
+ return;
80
+ let toRemove = consumed;
81
+ while (toRemove > 0) {
82
+ const last = this.steps[this.steps.length - 1];
83
+ if (last?.type !== 'insert-text')
84
+ return; // unreachable after the check above
85
+ if (last.text.length > toRemove) {
86
+ last.text = last.text.slice(0, -toRemove);
87
+ break;
88
+ }
89
+ toRemove -= last.text.length;
90
+ this.steps.pop();
91
+ }
92
+ this.recordTextInput({ kind: 'insert-text', text: replacement });
93
+ }
65
94
  teardown() {
66
95
  this.active = false;
67
96
  for (const dispose of this.disposers.splice(0))
68
97
  dispose();
69
98
  }
70
99
  push(step) {
100
+ this.steps.push(step);
71
101
  if (this.steps.length >= this.maxSteps) {
102
+ // The cap stops the *listening*, not the data: the steps stay for
103
+ // stop() to collect, and the owner is told the recording ended.
72
104
  this.teardown();
73
- return;
105
+ this.onAutoStop?.();
74
106
  }
75
- this.steps.push(step);
76
107
  }
77
108
  recordCommand(id, payload) {
78
109
  if (!this.shouldRecordCommand(id))
@@ -124,8 +155,7 @@ async function runStep(host, step) {
124
155
  case 'delete-backward':
125
156
  return host.deleteBackward(step.count);
126
157
  case 'delete-forward':
127
- // The engine exposes no separate forward deletion; report an explicit failure rather than skipping silently.
128
- return { ok: false, message: macroMessages().deleteForwardUnsupported, reason: 'unsupported-step' };
158
+ return host.deleteForward(step.count);
129
159
  }
130
160
  }
131
161
  export async function replayMacro(host, steps, options = {}) {
@@ -2,4 +2,17 @@ import type { MacroBridge } from './macro-api.js';
2
2
  import { type MacroRunner } from './runner.js';
3
3
  /** Wraps a bridge with a call cap. Exposed so both runners share the same enforcement. */
4
4
  export declare function limitCalls(bridge: MacroBridge, maxCalls: number): MacroBridge;
5
+ /**
6
+ * Wraps a bridge with a kill switch. After `revoke()` every new call is
7
+ * rejected — so a script that keeps running past its timeout (the eval
8
+ * runner cannot stop it) can no longer touch the document.
9
+ *
10
+ * What this cannot do: abort a host call that already reached the engine.
11
+ * The engine's public surfaces expose no cancellation, so an in-flight
12
+ * operation completes; what is guaranteed is that nothing *new* starts.
13
+ */
14
+ export declare function revocable(bridge: MacroBridge): {
15
+ bridge: MacroBridge;
16
+ revoke: () => void;
17
+ };
5
18
  export declare function createEvalRunner(): MacroRunner;