superdoc-macros 0.6.0 → 0.7.1

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 CHANGED
@@ -87,7 +87,9 @@ If you must waive isolation (e.g. a CSP that blocks `srcdoc`), switch to the dir
87
87
  ## 2. Macro recorder
88
88
 
89
89
  ```ts
90
- kit.startRecording();
90
+ if (!kit.startRecording()) {
91
+ throw new Error('A macro is running or an unsaved recording is pending');
92
+ }
91
93
  // the user works normally: typing, formatting, lists...
92
94
  const recording = kit.stopRecording('Standard intro', 'Ctrl+Alt+1');
93
95
 
@@ -97,6 +99,8 @@ await kit.replayRecording(recording.id);
97
99
 
98
100
  The recorder captures **commands and typing**, not caret positions — exactly like Word's recorder: replay applies wherever the caret stands. Consecutive keystrokes coalesce into one step, `undo`/`redo` are not recorded (configurable via `RecorderOptions`), and recordings persist as clean JSON that can be exported and shared. `updateRecording({id, name?, shortcut?})` renames a recording or edits its shortcut.
99
101
 
102
+ Finalization is loss-aware. A command payload that cannot be stored faithfully is reported instead of silently omitted; `stopRecording()` keeps the stopped capture pending until it is saved or explicitly cancelled. A mixed capture requires `{ allowIncomplete: true }` after the host has obtained user consent, while a capture containing no replayable step is rejected as `recording-uncapturable`. Storage, capacity, and validation failures are retryable: fix the problem and call `stopRecording()` again. Starting a new recording while one is pending is rejected, preventing accidental loss.
103
+
100
104
  ## 3. Snippets and auto-text
101
105
 
102
106
  ```ts
@@ -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;
@@ -96,15 +108,33 @@ export declare class MacroKit {
96
108
  runSource(source: string): Promise<MacroRunResult>;
97
109
  get isRecording(): boolean;
98
110
  get recordedStepCount(): number;
99
- startRecording(): void;
111
+ /**
112
+ * Starts a fresh recording. Returns false instead of overwriting an active
113
+ * or stopped-but-unsaved capture, or while another macro is running.
114
+ */
115
+ startRecording(): boolean;
100
116
  /**
101
117
  * 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.
118
+ * save.
119
+ *
120
+ * Throws with the stopped recording **retained for retry** (call again;
121
+ * `cancelRecording` is the explicit way to drop it) — when:
122
+ * - `recording-uncapturable`: every action was uncapturable, so even an
123
+ * explicitly incomplete save would create an empty macro.
124
+ * - `recording-incomplete`: some actions could not be captured (e.g. an
125
+ * inserted image, whose payload is the whole file). Saving that as-is
126
+ * would present a macro that replays less than what the user did, so it
127
+ * needs an explicit `allowIncomplete: true` from a confirming UI.
128
+ * - `recording-too-large`: even split, the steps exceed the loader's cap —
129
+ * a silently partial macro is not produced.
130
+ * - a persistence failure (quota, oversized state).
105
131
  */
106
- stopRecording(name: string, shortcut?: string): RecordedMacro | null;
132
+ stopRecording(name: string, shortcut?: string, options?: {
133
+ allowIncomplete?: boolean;
134
+ }): RecordedMacro | null;
107
135
  cancelRecording(): void;
136
+ /** Whether a stopped recording awaits a retried save (see stopRecording). */
137
+ get hasPendingRecording(): boolean;
108
138
  listRecordings(): readonly RecordedMacro[];
109
139
  removeRecording(id: string): void;
110
140
  /** 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 = [
@@ -180,26 +223,56 @@ export class MacroKit {
180
223
  get recordedStepCount() {
181
224
  return this.recorder.stepCount;
182
225
  }
226
+ /**
227
+ * Starts a fresh recording. Returns false instead of overwriting an active
228
+ * or stopped-but-unsaved capture, or while another macro is running.
229
+ */
183
230
  startRecording() {
184
- if (this.running)
185
- return;
231
+ if (this.running || this.recorder.recording || this.recorder.hasPending)
232
+ return false;
186
233
  this.recorder.start();
234
+ return this.recorder.recording;
187
235
  }
188
236
  /**
189
237
  * 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.
238
+ * save.
239
+ *
240
+ * Throws with the stopped recording **retained for retry** (call again;
241
+ * `cancelRecording` is the explicit way to drop it) — when:
242
+ * - `recording-uncapturable`: every action was uncapturable, so even an
243
+ * explicitly incomplete save would create an empty macro.
244
+ * - `recording-incomplete`: some actions could not be captured (e.g. an
245
+ * inserted image, whose payload is the whole file). Saving that as-is
246
+ * would present a macro that replays less than what the user did, so it
247
+ * needs an explicit `allowIncomplete: true` from a confirming UI.
248
+ * - `recording-too-large`: even split, the steps exceed the loader's cap —
249
+ * a silently partial macro is not produced.
250
+ * - a persistence failure (quota, oversized state).
193
251
  */
194
- stopRecording(name, shortcut) {
195
- this.requireValidShortcut(shortcut);
196
- this.requireItemLimits({ name, shortcut });
197
- this.requireRoom(this.state.recordings);
198
- const { steps, truncated } = splitOversizedSteps(this.recorder.stop());
252
+ stopRecording(name, shortcut, options = {}) {
253
+ // Stop first. Every failure below must leave the recorder inactive and
254
+ // its snapshot retryable. Checking capacity before stop() used to leave a
255
+ // manual recording active behind a stopped UI, and could lose an
256
+ // auto-stopped recording on the next start.
257
+ const pending = this.recorder.stop();
258
+ const { steps, truncated } = splitOversizedSteps(pending.steps);
199
259
  if (truncated)
200
260
  throw new MacroError(macroMessages().recordingTooLarge, 'recording-too-large');
201
- if (steps.length === 0)
261
+ const commandIds = [...new Set(pending.warnings.map((warning) => warning.commandId))].join(', ');
262
+ if (pending.warnings.length > 0 && steps.length === 0) {
263
+ const messages = macroMessages();
264
+ throw new MacroError(messages.recordingUncapturable?.(commandIds) ?? messages.recordingIncomplete(commandIds), 'recording-uncapturable');
265
+ }
266
+ if (steps.length === 0) {
267
+ this.recorder.discard();
202
268
  return null;
269
+ }
270
+ this.requireValidShortcut(shortcut);
271
+ this.requireItemLimits({ name, shortcut });
272
+ this.requireRoom(this.state.recordings);
273
+ if (pending.warnings.length > 0 && !options.allowIncomplete) {
274
+ throw new MacroError(macroMessages().recordingIncomplete(commandIds), 'recording-incomplete');
275
+ }
203
276
  const recording = {
204
277
  version: 1,
205
278
  id: newId(),
@@ -208,14 +281,22 @@ export class MacroKit {
208
281
  ...(shortcut ? { shortcut } : {}),
209
282
  steps,
210
283
  };
211
- return this.commit((draft) => {
284
+ const saved = this.commit((draft) => {
212
285
  draft.recordings.push(recording);
213
286
  return recording;
214
287
  });
288
+ // Only after the commit landed: a failed save keeps the recording
289
+ // retrievable for another attempt (delete an old macro, stop again).
290
+ this.recorder.discard();
291
+ return saved;
215
292
  }
216
293
  cancelRecording() {
217
294
  this.recorder.cancel();
218
295
  }
296
+ /** Whether a stopped recording awaits a retried save (see stopRecording). */
297
+ get hasPendingRecording() {
298
+ return this.recorder.hasPending;
299
+ }
219
300
  listRecordings() {
220
301
  return this.state.recordings;
221
302
  }
@@ -416,17 +497,14 @@ export class MacroKit {
416
497
  for (const item of items) {
417
498
  if (!item.shortcut)
418
499
  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) };
500
+ // The exact same context-free rules the manual save path applies —
501
+ // including the bindable-key restriction (an imported Ctrl+Tab must
502
+ // not slip in through the event.key fallback).
503
+ const issue = this.bindingIssue(item.shortcut);
504
+ if (issue) {
505
+ return { ok: false, message: macroMessages().importRejectedShortcut(item.name, issue.message) };
425
506
  }
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
- }
507
+ for (const signature of shortcutSignatures(parseShortcut(item.shortcut))) {
430
508
  const owner = seen.get(signature);
431
509
  if (owner !== undefined) {
432
510
  return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutTaken(owner)) };
@@ -42,6 +42,13 @@ export interface MacroMessages {
42
42
  fieldTooLong: (field: string, max: number) => string;
43
43
  saveFailed: string;
44
44
  recordingTooLarge: string;
45
+ recordingIncomplete: (commandIds: string) => string;
46
+ /**
47
+ * No usable step was captured; saving with allowIncomplete would create an
48
+ * empty macro. Optional for source compatibility with existing full locale
49
+ * objects compiled against 0.7.0.
50
+ */
51
+ recordingUncapturable?: (commandIds: string) => string;
45
52
  shortcutInvalid: string;
46
53
  shortcutNeedsModifier: string;
47
54
  shortcutReserved: string;
package/dist/messages.js CHANGED
@@ -42,6 +42,8 @@ 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})`,
46
+ recordingUncapturable: (commandIds) => `The recording contains only actions that cannot be recorded (${commandIds})`,
45
47
  shortcutInvalid: 'Invalid shortcut — use a form like Ctrl+Alt+M',
46
48
  shortcutNeedsModifier: 'A shortcut must include Ctrl, Alt or Meta',
47
49
  shortcutReserved: 'This shortcut is reserved by the editor',
@@ -81,6 +83,8 @@ export const HEBREW_MESSAGES = {
81
83
  fieldTooLong: (field, max) => `${field} ארוך מדי (התקרה: ${max} תווים)`,
82
84
  saveFailed: 'השמירה נכשלה — השינוי לא הוחל',
83
85
  recordingTooLarge: 'ההקלטה גדולה מכדי להישמר',
86
+ recordingIncomplete: (commandIds) => `בהקלטה חסרות פעולות שאינן ניתנות להקלטה (${commandIds})`,
87
+ recordingUncapturable: (commandIds) => `ההקלטה מכילה רק פעולות שאינן ניתנות להקלטה (${commandIds})`,
84
88
  shortcutInvalid: 'קיצור לא תקין — הצורה הנדרשת היא למשל Ctrl+Alt+M',
85
89
  shortcutNeedsModifier: 'קיצור חייב לכלול Ctrl,‏ Alt או Meta',
86
90
  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
@@ -54,6 +80,13 @@ export declare class MacroRecorder {
54
80
  applyAutoTextExpansion(consumed: number, replacement: string): void;
55
81
  private teardown;
56
82
  private push;
83
+ private addWarning;
84
+ /**
85
+ * Warnings are part of the stopped snapshot just like steps. Counting both
86
+ * prevents a warning-only recording from growing without bound while never
87
+ * reaching the ordinary step cap.
88
+ */
89
+ private maybeAutoStop;
57
90
  /**
58
91
  * Records a programmatic insertion the host will not report as typing —
59
92
  * e.g. a snippet expanded from a button or shortcut, which writes through
@@ -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
@@ -119,12 +146,27 @@ export class MacroRecorder {
119
146
  }
120
147
  push(step) {
121
148
  this.steps.push(step);
122
- if (this.steps.length >= this.maxSteps) {
123
- // The cap stops the *listening*, not the data: the steps stay for
124
- // stop() to collect, and the owner is told the recording ended.
125
- this.teardown();
126
- this.onAutoStop?.();
127
- }
149
+ this.maybeAutoStop();
150
+ }
151
+ addWarning(warning) {
152
+ this.warnings.push(warning);
153
+ this.maybeAutoStop();
154
+ }
155
+ /**
156
+ * Warnings are part of the stopped snapshot just like steps. Counting both
157
+ * prevents a warning-only recording from growing without bound while never
158
+ * reaching the ordinary step cap.
159
+ */
160
+ maybeAutoStop() {
161
+ if (!this.active)
162
+ return;
163
+ if (this.steps.length + this.warnings.length < this.maxSteps)
164
+ return;
165
+ this.teardown();
166
+ this.pending = { steps: this.steps, warnings: this.warnings };
167
+ this.steps = [];
168
+ this.warnings = [];
169
+ this.onAutoStop?.();
128
170
  }
129
171
  /**
130
172
  * Records a programmatic insertion the host will not report as typing —
@@ -147,17 +189,27 @@ export class MacroRecorder {
147
189
  // The payload is opaque engine data, but not unlimited: it must survive
148
190
  // a JSON round-trip within the persistence cap, or the recording would
149
191
  // 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.
192
+ // faithfully is not stepped — replaying it with a mangled payload would
193
+ // do something other than what was recorded — but it is never dropped
194
+ // *silently*: the warning is what lets the owner refuse to present the
195
+ // recording as complete. Inserting an image is the concrete case — its
196
+ // payload carries the whole file as a data URL.
152
197
  let json;
153
198
  try {
154
199
  json = JSON.stringify(payload);
155
200
  }
156
201
  catch {
202
+ this.addWarning({ commandId: id, reason: 'payload-not-serializable' });
157
203
  return;
158
204
  }
159
- if (typeof json !== 'string' || json.length > IMPORT_LIMITS.maxPayloadLength)
205
+ if (typeof json !== 'string') {
206
+ this.addWarning({ commandId: id, reason: 'payload-not-serializable' });
160
207
  return;
208
+ }
209
+ if (json.length > IMPORT_LIMITS.maxPayloadLength) {
210
+ this.addWarning({ commandId: id, reason: 'payload-too-large' });
211
+ return;
212
+ }
161
213
  this.push({ type: 'command', id, payload: JSON.parse(json) });
162
214
  }
163
215
  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.1",
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",