superdoc-macros 0.6.0 → 0.7.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.
@@ -327,6 +327,28 @@ export function createSuperdocHost(options) {
327
327
  async getSelection(options) {
328
328
  return (await readSelectionDetailed(options?.includeText ?? false)).snapshot;
329
329
  },
330
+ async replaceTextBefore(expected, replacement) {
331
+ // Auto-text's atomic path: verify and replace inside one ProseMirror
332
+ // transaction — no window in which the trigger is deleted but the
333
+ // replacement not yet in.
334
+ const pm = view();
335
+ if (!pm)
336
+ return failed(macroMessages().deletionUnavailable, 'view-unavailable');
337
+ try {
338
+ const { from } = pm.state.selection;
339
+ const start = from - expected.length;
340
+ if (start < 0 || pm.state.doc.textBetween(start, from) !== expected) {
341
+ return failed(macroMessages().actionFailed, 'text-mismatch');
342
+ }
343
+ const tr = pm.state.tr;
344
+ tr.insertText(replacement, start, from);
345
+ pm.dispatch(tr);
346
+ return { ok: true };
347
+ }
348
+ catch (error) {
349
+ return failed(error instanceof Error ? error.message : macroMessages().actionFailed, 'threw');
350
+ }
351
+ },
330
352
  async getTextBefore(count) {
331
353
  // Auto-text's verification before it deletes: the answer must reflect
332
354
  // the live document, so `null` (unknown) is the only honest reply when
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export { createMacroApi, MacroError, type MacroApi, type MacroBridge, type Scrip
6
6
  export { createEvalRunner } from './scripting/eval-runner.js';
7
7
  export { createIframeRunner, SANDBOX_BOOTSTRAP, isProtocolMessage } from './scripting/iframe-runner.js';
8
8
  export type { MacroRunner, MacroRunOptions, MacroRunResult } from './scripting/runner.js';
9
- export { MacroRecorder, replayMacro, type RecorderOptions, type ReplayOptions, type ReplayResult } from './recorder/recorder.js';
9
+ export { MacroRecorder, replayMacro, type RecorderOptions, type RecordingWarning, type ReplayOptions, type ReplayResult, } from './recorder/recorder.js';
10
10
  export { renderSnippet, renderSnippetForHost, expandSnippet, usesSelection, type ExpandOptions, type RenderContext, } from './snippets/snippets.js';
11
11
  export { AutoText, type AutoTextOptions, type AutoTextExpansion } from './snippets/autotext.js';
12
12
  export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, isBindableKey, codesForKey, type ParsedShortcut, type ShortcutBinding, type ShortcutTarget, } from './shortcuts.js';
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ export { createSuperdocHost } from './host/superdoc-host.js';
4
4
  export { createMacroApi, MacroError } from './scripting/macro-api.js';
5
5
  export { createEvalRunner } from './scripting/eval-runner.js';
6
6
  export { createIframeRunner, SANDBOX_BOOTSTRAP, isProtocolMessage } from './scripting/iframe-runner.js';
7
- export { MacroRecorder, replayMacro } from './recorder/recorder.js';
7
+ export { MacroRecorder, replayMacro, } from './recorder/recorder.js';
8
8
  export { renderSnippet, renderSnippetForHost, expandSnippet, usesSelection, } from './snippets/snippets.js';
9
9
  export { AutoText } from './snippets/autotext.js';
10
10
  export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, isBindableKey, codesForKey, } from './shortcuts.js';
package/dist/manager.d.ts CHANGED
@@ -80,6 +80,18 @@ export declare class MacroKit {
80
80
  * UIs call it directly to show the message before saving.
81
81
  */
82
82
  validateShortcut(shortcut: string | undefined, excludeId?: string): ShortcutValidation;
83
+ /**
84
+ * The context-free binding rules, in one place: parseable, real modifier,
85
+ * physically-mappable key, not host-reserved. Manual save, import and
86
+ * legacy-state load all judge a shortcut by exactly this — a file or an
87
+ * old store must not smuggle in what typing cannot.
88
+ */
89
+ private bindingIssue;
90
+ /**
91
+ * Strips loaded shortcuts that today's rules reject — see the constructor.
92
+ * Items survive; only their bindings are dropped.
93
+ */
94
+ private sanitizeLoadedShortcuts;
83
95
  private findShortcutOwner;
84
96
  /** Throws a MacroError when the shortcut is unacceptable. The save paths call this. */
85
97
  private requireValidShortcut;
@@ -99,12 +111,24 @@ export declare class MacroKit {
99
111
  startRecording(): void;
100
112
  /**
101
113
  * Stops and saves. `null` when no step was recorded — there is nothing to
102
- * save. Throws when the recording cannot be saved *whole* (its splittable
103
- * steps still exceed the loader's step cap): a silently partial macro
104
- * would replay something other than what the user did.
114
+ * save.
115
+ *
116
+ * Throws with the stopped recording **retained for retry** (call again;
117
+ * `cancelRecording` is the explicit way to drop it) — when:
118
+ * - `recording-incomplete`: some actions could not be captured (e.g. an
119
+ * inserted image, whose payload is the whole file). Saving that as-is
120
+ * would present a macro that replays less than what the user did, so it
121
+ * needs an explicit `allowIncomplete: true` from a confirming UI.
122
+ * - `recording-too-large`: even split, the steps exceed the loader's cap —
123
+ * a silently partial macro is not produced.
124
+ * - a persistence failure (quota, oversized state).
105
125
  */
106
- stopRecording(name: string, shortcut?: string): RecordedMacro | null;
126
+ stopRecording(name: string, shortcut?: string, options?: {
127
+ allowIncomplete?: boolean;
128
+ }): RecordedMacro | null;
107
129
  cancelRecording(): void;
130
+ /** Whether a stopped recording awaits a retried save (see stopRecording). */
131
+ get hasPendingRecording(): boolean;
108
132
  listRecordings(): readonly RecordedMacro[];
109
133
  removeRecording(id: string): void;
110
134
  /** Renames a recording or edits its shortcut. `null` when the recording was not found. */
package/dist/manager.js CHANGED
@@ -49,7 +49,20 @@ export class MacroKit {
49
49
  this.runner =
50
50
  runner === 'iframe' ? createIframeRunner() : runner === 'eval' ? createEvalRunner() : runner;
51
51
  this.scriptsEnabled = options.scriptsEnabled ?? true;
52
+ const reserved = new Set();
53
+ for (const shortcut of options.reservedShortcuts ?? []) {
54
+ const parsed = parseShortcut(shortcut);
55
+ if (parsed)
56
+ for (const signature of shortcutSignatures(parsed))
57
+ reserved.add(signature);
58
+ }
59
+ this.reservedSignatures = reserved;
52
60
  this.state = this.storage.load() ?? emptyState();
61
+ // Loaded state passed the *structural* validation only. The semantic
62
+ // shortcut rules (bindable key, reserved list, duplicates) may have
63
+ // tightened since it was saved — a stale binding must not stay armed.
64
+ // Offending shortcuts are stripped, their items kept.
65
+ this.sanitizeLoadedShortcuts();
53
66
  this.recorder = new MacroRecorder(this.host, { onAutoStop: options.onRecordingAutoStop });
54
67
  this.autoText = new AutoText(this.host, () => this.state.snippets, {
55
68
  ...options.autoText,
@@ -61,14 +74,6 @@ export class MacroKit {
61
74
  options.autoText?.onExpand?.(snippet, expansion);
62
75
  },
63
76
  });
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
77
  }
73
78
  /* ---------- Shortcut validation ---------- */
74
79
  /**
@@ -82,23 +87,61 @@ export class MacroKit {
82
87
  const trimmed = shortcut?.trim();
83
88
  if (!trimmed)
84
89
  return { ok: true };
85
- const parsed = parseShortcut(trimmed);
90
+ const issue = this.bindingIssue(trimmed);
91
+ if (issue)
92
+ return { ok: false, message: issue.message };
93
+ const owner = this.findShortcutOwner(shortcutSignatures(parseShortcut(trimmed)), excludeId);
94
+ if (owner)
95
+ return { ok: false, message: macroMessages().shortcutTaken(owner) };
96
+ return { ok: true };
97
+ }
98
+ /**
99
+ * The context-free binding rules, in one place: parseable, real modifier,
100
+ * physically-mappable key, not host-reserved. Manual save, import and
101
+ * legacy-state load all judge a shortcut by exactly this — a file or an
102
+ * old store must not smuggle in what typing cannot.
103
+ */
104
+ bindingIssue(shortcut) {
105
+ const parsed = parseShortcut(shortcut);
86
106
  if (!parsed)
87
- return { ok: false, message: macroMessages().shortcutInvalid };
107
+ return { message: macroMessages().shortcutInvalid };
88
108
  if (!hasBindingModifier(parsed))
89
- return { ok: false, message: macroMessages().shortcutNeedsModifier };
109
+ return { message: macroMessages().shortcutNeedsModifier };
90
110
  // Only keys with a physical-code mapping (letters, digits, F-keys):
91
111
  // matching is by event.code, so it survives a Hebrew keyboard layout.
92
112
  if (!isBindableKey(parsed))
93
- return { ok: false, message: macroMessages().shortcutInvalid };
94
- const signatures = shortcutSignatures(parsed);
95
- if (signatures.some((signature) => this.reservedSignatures.has(signature))) {
96
- return { ok: false, message: macroMessages().shortcutReserved };
113
+ return { message: macroMessages().shortcutInvalid };
114
+ if (shortcutSignatures(parsed).some((signature) => this.reservedSignatures.has(signature))) {
115
+ return { message: macroMessages().shortcutReserved };
116
+ }
117
+ return null;
118
+ }
119
+ /**
120
+ * Strips loaded shortcuts that today's rules reject — see the constructor.
121
+ * Items survive; only their bindings are dropped.
122
+ */
123
+ sanitizeLoadedShortcuts() {
124
+ const seen = new Set();
125
+ const items = [
126
+ ...this.state.scripts,
127
+ ...this.state.recordings,
128
+ ...this.state.snippets,
129
+ ];
130
+ for (const item of items) {
131
+ if (!item.shortcut)
132
+ continue;
133
+ if (this.bindingIssue(item.shortcut)) {
134
+ delete item.shortcut;
135
+ continue;
136
+ }
137
+ const signatures = shortcutSignatures(parseShortcut(item.shortcut));
138
+ if (signatures.some((signature) => seen.has(signature))) {
139
+ delete item.shortcut;
140
+ continue;
141
+ }
142
+ for (const signature of signatures)
143
+ seen.add(signature);
97
144
  }
98
- const owner = this.findShortcutOwner(signatures, excludeId);
99
- if (owner)
100
- return { ok: false, message: macroMessages().shortcutTaken(owner) };
101
- return { ok: true };
102
145
  }
103
146
  findShortcutOwner(signatures, excludeId) {
104
147
  const items = [
@@ -187,19 +230,34 @@ export class MacroKit {
187
230
  }
188
231
  /**
189
232
  * Stops and saves. `null` when no step was recorded — there is nothing to
190
- * save. Throws when the recording cannot be saved *whole* (its splittable
191
- * steps still exceed the loader's step cap): a silently partial macro
192
- * would replay something other than what the user did.
233
+ * save.
234
+ *
235
+ * Throws with the stopped recording **retained for retry** (call again;
236
+ * `cancelRecording` is the explicit way to drop it) — when:
237
+ * - `recording-incomplete`: some actions could not be captured (e.g. an
238
+ * inserted image, whose payload is the whole file). Saving that as-is
239
+ * would present a macro that replays less than what the user did, so it
240
+ * needs an explicit `allowIncomplete: true` from a confirming UI.
241
+ * - `recording-too-large`: even split, the steps exceed the loader's cap —
242
+ * a silently partial macro is not produced.
243
+ * - a persistence failure (quota, oversized state).
193
244
  */
194
- stopRecording(name, shortcut) {
245
+ stopRecording(name, shortcut, options = {}) {
195
246
  this.requireValidShortcut(shortcut);
196
247
  this.requireItemLimits({ name, shortcut });
197
248
  this.requireRoom(this.state.recordings);
198
- const { steps, truncated } = splitOversizedSteps(this.recorder.stop());
249
+ const pending = this.recorder.stop();
250
+ const { steps, truncated } = splitOversizedSteps(pending.steps);
199
251
  if (truncated)
200
252
  throw new MacroError(macroMessages().recordingTooLarge, 'recording-too-large');
201
- if (steps.length === 0)
253
+ if (steps.length === 0) {
254
+ this.recorder.discard();
202
255
  return null;
256
+ }
257
+ if (pending.warnings.length > 0 && !options.allowIncomplete) {
258
+ const commandIds = [...new Set(pending.warnings.map((warning) => warning.commandId))].join(', ');
259
+ throw new MacroError(macroMessages().recordingIncomplete(commandIds), 'recording-incomplete');
260
+ }
203
261
  const recording = {
204
262
  version: 1,
205
263
  id: newId(),
@@ -208,14 +266,22 @@ export class MacroKit {
208
266
  ...(shortcut ? { shortcut } : {}),
209
267
  steps,
210
268
  };
211
- return this.commit((draft) => {
269
+ const saved = this.commit((draft) => {
212
270
  draft.recordings.push(recording);
213
271
  return recording;
214
272
  });
273
+ // Only after the commit landed: a failed save keeps the recording
274
+ // retrievable for another attempt (delete an old macro, stop again).
275
+ this.recorder.discard();
276
+ return saved;
215
277
  }
216
278
  cancelRecording() {
217
279
  this.recorder.cancel();
218
280
  }
281
+ /** Whether a stopped recording awaits a retried save (see stopRecording). */
282
+ get hasPendingRecording() {
283
+ return this.recorder.hasPending;
284
+ }
219
285
  listRecordings() {
220
286
  return this.state.recordings;
221
287
  }
@@ -416,17 +482,14 @@ export class MacroKit {
416
482
  for (const item of items) {
417
483
  if (!item.shortcut)
418
484
  continue;
419
- const parsed = parseShortcut(item.shortcut);
420
- if (!parsed) {
421
- return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutInvalid) };
422
- }
423
- if (!hasBindingModifier(parsed)) {
424
- return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutNeedsModifier) };
485
+ // The exact same context-free rules the manual save path applies —
486
+ // including the bindable-key restriction (an imported Ctrl+Tab must
487
+ // not slip in through the event.key fallback).
488
+ const issue = this.bindingIssue(item.shortcut);
489
+ if (issue) {
490
+ return { ok: false, message: macroMessages().importRejectedShortcut(item.name, issue.message) };
425
491
  }
426
- for (const signature of shortcutSignatures(parsed)) {
427
- if (this.reservedSignatures.has(signature)) {
428
- return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutReserved) };
429
- }
492
+ for (const signature of shortcutSignatures(parseShortcut(item.shortcut))) {
430
493
  const owner = seen.get(signature);
431
494
  if (owner !== undefined) {
432
495
  return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutTaken(owner)) };
@@ -42,6 +42,7 @@ export interface MacroMessages {
42
42
  fieldTooLong: (field: string, max: number) => string;
43
43
  saveFailed: string;
44
44
  recordingTooLarge: string;
45
+ recordingIncomplete: (commandIds: string) => string;
45
46
  shortcutInvalid: string;
46
47
  shortcutNeedsModifier: string;
47
48
  shortcutReserved: string;
package/dist/messages.js CHANGED
@@ -42,6 +42,7 @@ export const ENGLISH_MESSAGES = {
42
42
  fieldTooLong: (field, max) => `${field} is too long (limit: ${max} characters)`,
43
43
  saveFailed: 'Saving failed — the change was not applied',
44
44
  recordingTooLarge: 'The recording is too large to save',
45
+ recordingIncomplete: (commandIds) => `The recording is missing actions that cannot be recorded (${commandIds})`,
45
46
  shortcutInvalid: 'Invalid shortcut — use a form like Ctrl+Alt+M',
46
47
  shortcutNeedsModifier: 'A shortcut must include Ctrl, Alt or Meta',
47
48
  shortcutReserved: 'This shortcut is reserved by the editor',
@@ -81,6 +82,7 @@ export const HEBREW_MESSAGES = {
81
82
  fieldTooLong: (field, max) => `${field} ארוך מדי (התקרה: ${max} תווים)`,
82
83
  saveFailed: 'השמירה נכשלה — השינוי לא הוחל',
83
84
  recordingTooLarge: 'ההקלטה גדולה מכדי להישמר',
85
+ recordingIncomplete: (commandIds) => `בהקלטה חסרות פעולות שאינן ניתנות להקלטה (${commandIds})`,
84
86
  shortcutInvalid: 'קיצור לא תקין — הצורה הנדרשת היא למשל Ctrl+Alt+M',
85
87
  shortcutNeedsModifier: 'קיצור חייב לכלול Ctrl,‏ Alt או Meta',
86
88
  shortcutReserved: 'הקיצור הזה שמור לעורך',
@@ -1,4 +1,13 @@
1
1
  import type { MacroHost, MacroStep } from '../types.js';
2
+ /**
3
+ * A user action the recorder could not capture faithfully. Surfaced, never
4
+ * swallowed: a macro that "saved successfully" but silently misses an image
5
+ * the user watched go in is a lie told at replay time.
6
+ */
7
+ export interface RecordingWarning {
8
+ commandId: string;
9
+ reason: 'payload-too-large' | 'payload-not-serializable';
10
+ }
2
11
  export interface RecorderOptions {
3
12
  /** Command filter. The default records everything except undo/redo. */
4
13
  shouldRecordCommand?: (id: string) => boolean;
@@ -18,6 +27,15 @@ export declare class MacroRecorder {
18
27
  private readonly maxSteps;
19
28
  private readonly onAutoStop?;
20
29
  private steps;
30
+ private warnings;
31
+ /**
32
+ * A stopped recording, held until `discard()`: the owner may fail to save
33
+ * it (storage quota, an incomplete recording awaiting confirmation) and
34
+ * must be able to come back for it. Clearing on stop() was the old
35
+ * behavior, and it lost recordings at exactly the moments they were
36
+ * hardest to redo.
37
+ */
38
+ private pending;
21
39
  private disposers;
22
40
  private active;
23
41
  /**
@@ -29,14 +47,22 @@ export declare class MacroRecorder {
29
47
  constructor(host: MacroHost, options?: RecorderOptions);
30
48
  get recording(): boolean;
31
49
  get stepCount(): number;
50
+ /** Whether a stopped recording is waiting to be saved or discarded. */
51
+ get hasPending(): boolean;
32
52
  start(): void;
33
53
  /**
34
- * Stops and returns the steps. Empty when nothing was recorded. Also the
35
- * way to collect a recording that auto-stopped at the cap — the steps are
36
- * kept until someone asks for them.
54
+ * Stops and returns the recording steps plus any warnings about actions
55
+ * that could not be captured. The result stays retrievable (calling
56
+ * `stop()` again returns the same recording) until `discard()`, `cancel()`
57
+ * or a new `start()`: a save that fails must be retryable.
37
58
  */
38
- stop(): MacroStep[];
39
- /** Stops and discards whatever was recorded. */
59
+ stop(): {
60
+ steps: MacroStep[];
61
+ warnings: RecordingWarning[];
62
+ };
63
+ /** Releases a stopped recording after it was successfully saved (or knowingly dropped). */
64
+ discard(): void;
65
+ /** Stops and discards whatever was recorded — including a pending stopped recording. */
40
66
  cancel(): void;
41
67
  /**
42
68
  * Rewrites the recorded tail after an auto-text expansion: the user typed
@@ -24,6 +24,15 @@ export class MacroRecorder {
24
24
  maxSteps;
25
25
  onAutoStop;
26
26
  steps = [];
27
+ warnings = [];
28
+ /**
29
+ * A stopped recording, held until `discard()`: the owner may fail to save
30
+ * it (storage quota, an incomplete recording awaiting confirmation) and
31
+ * must be able to come back for it. Clearing on stop() was the old
32
+ * behavior, and it lost recordings at exactly the moments they were
33
+ * hardest to redo.
34
+ */
35
+ pending = null;
27
36
  disposers = [];
28
37
  active = false;
29
38
  /**
@@ -44,31 +53,49 @@ export class MacroRecorder {
44
53
  get stepCount() {
45
54
  return this.steps.length;
46
55
  }
56
+ /** Whether a stopped recording is waiting to be saved or discarded. */
57
+ get hasPending() {
58
+ return this.pending !== null;
59
+ }
47
60
  start() {
48
61
  if (this.active)
49
62
  return;
50
63
  this.active = true;
51
64
  this.steps = [];
65
+ this.warnings = [];
66
+ // Starting anew is the explicit "I no longer want the unsaved one".
67
+ this.pending = null;
52
68
  this.disposers = [
53
69
  this.host.onCommand((id, payload) => this.recordCommand(id, payload)),
54
70
  this.host.onTextInput((event) => this.recordTextInput(event)),
55
71
  ];
56
72
  }
57
73
  /**
58
- * Stops and returns the steps. Empty when nothing was recorded. Also the
59
- * way to collect a recording that auto-stopped at the cap — the steps are
60
- * kept until someone asks for them.
74
+ * Stops and returns the recording steps plus any warnings about actions
75
+ * that could not be captured. The result stays retrievable (calling
76
+ * `stop()` again returns the same recording) until `discard()`, `cancel()`
77
+ * or a new `start()`: a save that fails must be retryable.
61
78
  */
62
79
  stop() {
63
- this.teardown();
64
- const recorded = this.steps;
65
- this.steps = [];
66
- return recorded;
80
+ if (this.active)
81
+ this.teardown();
82
+ if (!this.pending && (this.steps.length > 0 || this.warnings.length > 0)) {
83
+ this.pending = { steps: this.steps, warnings: this.warnings };
84
+ this.steps = [];
85
+ this.warnings = [];
86
+ }
87
+ return this.pending ?? { steps: [], warnings: [] };
88
+ }
89
+ /** Releases a stopped recording after it was successfully saved (or knowingly dropped). */
90
+ discard() {
91
+ this.pending = null;
67
92
  }
68
- /** Stops and discards whatever was recorded. */
93
+ /** Stops and discards whatever was recorded — including a pending stopped recording. */
69
94
  cancel() {
70
95
  this.teardown();
71
96
  this.steps = [];
97
+ this.warnings = [];
98
+ this.pending = null;
72
99
  }
73
100
  /**
74
101
  * Rewrites the recorded tail after an auto-text expansion: the user typed
@@ -147,17 +174,27 @@ export class MacroRecorder {
147
174
  // The payload is opaque engine data, but not unlimited: it must survive
148
175
  // a JSON round-trip within the persistence cap, or the recording would
149
176
  // be rejected by the loader. A command whose payload cannot be kept
150
- // faithfully is skipped whole — replaying it with a mangled payload
151
- // would do something other than what was recorded.
177
+ // faithfully is not stepped — replaying it with a mangled payload would
178
+ // do something other than what was recorded — but it is never dropped
179
+ // *silently*: the warning is what lets the owner refuse to present the
180
+ // recording as complete. Inserting an image is the concrete case — its
181
+ // payload carries the whole file as a data URL.
152
182
  let json;
153
183
  try {
154
184
  json = JSON.stringify(payload);
155
185
  }
156
186
  catch {
187
+ this.warnings.push({ commandId: id, reason: 'payload-not-serializable' });
157
188
  return;
158
189
  }
159
- if (typeof json !== 'string' || json.length > IMPORT_LIMITS.maxPayloadLength)
190
+ if (typeof json !== 'string') {
191
+ this.warnings.push({ commandId: id, reason: 'payload-not-serializable' });
160
192
  return;
193
+ }
194
+ if (json.length > IMPORT_LIMITS.maxPayloadLength) {
195
+ this.warnings.push({ commandId: id, reason: 'payload-too-large' });
196
+ return;
197
+ }
161
198
  this.push({ type: 'command', id, payload: JSON.parse(json) });
162
199
  }
163
200
  recordTextInput(event) {
@@ -26,6 +26,8 @@ export interface KeyEventLike {
26
26
  repeat?: boolean;
27
27
  /** Mid-IME-composition — keys belong to the composition, not to bindings. */
28
28
  isComposing?: boolean;
29
+ /** Legacy IME marker: some WebViews report keyCode 229 without isComposing. */
30
+ keyCode?: number;
29
31
  preventDefault?(): void;
30
32
  stopPropagation?(): void;
31
33
  }
package/dist/shortcuts.js CHANGED
@@ -117,8 +117,9 @@ export function hasBindingModifier(parsed) {
117
117
  export function bindShortcuts(target, getBindings) {
118
118
  const listener = (event) => {
119
119
  // Auto-repeat must not replay a macro per repeat tick, and keys mid-IME
120
- // composition belong to the composition.
121
- if (event.repeat || event.isComposing)
120
+ // composition belong to the composition — including the legacy keyCode
121
+ // 229 marker some WebViews report without setting isComposing.
122
+ if (event.repeat || event.isComposing || event.keyCode === 229)
122
123
  return;
123
124
  for (const binding of getBindings()) {
124
125
  const parsed = parseShortcut(binding.shortcut);
@@ -84,27 +84,44 @@ export class AutoText {
84
84
  // to the document. Deferring to the task queue guarantees the expansion
85
85
  // character is already in before it is deleted along with the trigger.
86
86
  await new Promise((resolve) => setTimeout(resolve, 0));
87
- // Second line of defense, independent of the event stream: the
88
- // document itself must hold the trigger right before the caret. A
89
- // caret move the host failed to report (or a race with another
90
- // writer) is caught here instead of deleting foreign text.
87
+ // Fail closed, in both directions: the document must *verifiably*
88
+ // hold the trigger right before the caret. A mismatch means the caret
89
+ // moved (or another writer raced us); `null`/`undefined` means the
90
+ // host cannot tell and a feature that deletes text on its own
91
+ // initiative does not get the benefit of the doubt. Auto-text simply
92
+ // does not expand where it cannot verify.
91
93
  const expected = trigger + expandChar;
94
+ const rendered = renderSnippet(snippet.text, {
95
+ selectionText: usesSelection(snippet.text)
96
+ ? (await this.host.getSelection({ includeText: true })).text
97
+ : undefined,
98
+ });
99
+ const replacement = rendered + expandChar;
100
+ // Preferred path: one atomic engine transaction that verifies and
101
+ // replaces together — nothing to roll back.
102
+ if (this.host.replaceTextBefore) {
103
+ const replaced = await this.host.replaceTextBefore(expected, replacement);
104
+ if (!replaced.ok) {
105
+ this.onError?.(replaced.message);
106
+ return;
107
+ }
108
+ this.onExpand?.(snippet, { trigger, expandChar, rendered });
109
+ return;
110
+ }
92
111
  const actual = await this.host.getTextBefore?.(expected.length);
93
- if (typeof actual === 'string' && actual !== expected)
112
+ if (actual !== expected)
94
113
  return;
95
- const selectionText = usesSelection(snippet.text)
96
- ? (await this.host.getSelection({ includeText: true })).text
97
- : undefined;
98
- const rendered = renderSnippet(snippet.text, { selectionText });
99
- // The expansion character is already in the document by now, so it is
100
- // included in the deletion and restored at the end.
101
- const deleted = await this.host.deleteBackward(trigger.length + 1);
114
+ // Two-operation fallback for hosts without an atomic replace. If the
115
+ // insertion fails after the deletion succeeded, the trigger is put
116
+ // back — the user must not be left with their word silently eaten.
117
+ const deleted = await this.host.deleteBackward(expected.length);
102
118
  if (!deleted.ok) {
103
119
  this.onError?.(deleted.message);
104
120
  return;
105
121
  }
106
- const inserted = await this.host.insertText(rendered + expandChar);
122
+ const inserted = await this.host.insertText(replacement);
107
123
  if (!inserted.ok) {
124
+ await this.host.insertText(expected);
108
125
  this.onError?.(inserted.message);
109
126
  return;
110
127
  }
package/dist/types.d.ts CHANGED
@@ -78,6 +78,13 @@ export interface MacroHost {
78
78
  * caret move the host failed to report.
79
79
  */
80
80
  getTextBefore?(count: number): Promise<string | null>;
81
+ /**
82
+ * Atomically replaces the `expected.length` characters before the caret
83
+ * with `replacement` — verifying they equal `expected` first, all inside
84
+ * one engine transaction. Auto-text prefers this over delete+insert: two
85
+ * operations leave the trigger deleted when the second fails.
86
+ */
87
+ replaceTextBefore?(expected: string, replacement: string): Promise<MacroOutcome>;
81
88
  /** Replaces every occurrence of `query` with `replacement`. Returns how many were replaced. */
82
89
  replaceAll(query: string, replacement: string): Promise<{
83
90
  ok: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superdoc-macros",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Macro toolkit for SuperDoc-based editors: sandboxed scripted macros, a Word-style macro recorder, and snippets with auto-text expansion.",
5
5
  "license": "MIT",
6
6
  "type": "module",