superdoc-macros 0.5.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.
package/README.md CHANGED
@@ -140,11 +140,11 @@ kit.importState(json, { merge: true });
140
140
 
141
141
  Imports are strictly validated **atomically on the final result**: every item and every recorded step is type-checked and size-bounded (see `IMPORT_LIMITS`), every shortcut in the merged state must pass the same binding rules the save paths enforce (modifier required, not host-reserved, no duplicates), and any failure rejects the whole file with the current state untouched — no partial imports.
142
142
 
143
- The same limits hold as a persistence invariant: the save paths refuse oversized fields and full lists, an oversized recorded paste is split into loadable steps, and state that the loader would reject is never writtenso a single bad save can never wipe the store on the next startup.
143
+ The same limits hold as a persistence invariant, transactionally: every mutation runs on a clone that must serialize under the loader's exact rules (shape, field caps, whole-file size) *and* be accepted by the storage before it becomes the state — a quota failure or an oversized save leaves memory and disk agreeing on the previous state, and can never wipe the store on the next startup. An oversized recorded paste is split into loadable steps; a recording that cannot be saved whole is refused with a message rather than saved partially.
144
144
 
145
145
  ## Shortcut safety
146
146
 
147
- Saved bindings go through `kit.validateShortcut(shortcut, excludeId?)` — enforced on every save: a binding must parse, must carry a real modifier (Ctrl/Alt/Meta — a bare letter would fire on ordinary typing), must not collide with another saved item, and must not collide with shortcuts the host declared as reserved:
147
+ Saved bindings go through `kit.validateShortcut(shortcut, excludeId?)` — enforced on every save and on the merged result of every import: a binding must parse, must carry a real modifier (Ctrl/Alt/Meta — a bare letter would fire on ordinary typing), must use a physically-mappable key (letters, digits, F-keys — matching is by `event.code`, so bindings survive non-Latin keyboard layouts), must not collide with another saved item, and must not collide with shortcuts the host declared as reserved. Auto-repeat and keys mid-IME-composition never fire bindings:
148
148
 
149
149
  ```ts
150
150
  const kit = new MacroKit({ host, reservedShortcuts: ['Ctrl+S', 'Ctrl+P', /* … the editor's registry … */] });
@@ -113,6 +113,25 @@ export function createSuperdocHost(options) {
113
113
  if (typeof data === 'string' && data.length > 0)
114
114
  emitInput({ kind: 'insert-text', text: data });
115
115
  };
116
+ /* Caret movement: a click or a navigation key breaks the link between the
117
+ recently-typed characters and what actually sits before the caret.
118
+ Auto-text resets its buffer on this, and the recorder stops coalescing
119
+ across it. Reported as an input event so any MacroHost can supply it. */
120
+ const NAVIGATION_KEYS = new Set([
121
+ 'ArrowLeft',
122
+ 'ArrowRight',
123
+ 'ArrowUp',
124
+ 'ArrowDown',
125
+ 'Home',
126
+ 'End',
127
+ 'PageUp',
128
+ 'PageDown',
129
+ ]);
130
+ const onPointerDown = () => emitInput({ kind: 'caret-moved' });
131
+ const onKeydown = (event) => {
132
+ if (NAVIGATION_KEYS.has(event.key))
133
+ emitInput({ kind: 'caret-moved' });
134
+ };
116
135
  const onBeforeInput = (event) => {
117
136
  const input = event;
118
137
  let mapped = null;
@@ -156,6 +175,8 @@ export function createSuperdocHost(options) {
156
175
  container?.addEventListener('beforeinput', onBeforeInput, true);
157
176
  container?.addEventListener('compositionstart', onCompositionStart, true);
158
177
  container?.addEventListener('compositionend', onCompositionEnd, true);
178
+ container?.addEventListener('pointerdown', onPointerDown, true);
179
+ container?.addEventListener('keydown', onKeydown, true);
159
180
  /**
160
181
  * `failed: true` means the engine call itself threw — as opposed to a
161
182
  * clean "no selection" answer. Callers that write relative to the caret
@@ -306,6 +327,44 @@ export function createSuperdocHost(options) {
306
327
  async getSelection(options) {
307
328
  return (await readSelectionDetailed(options?.includeText ?? false)).snapshot;
308
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
+ },
352
+ async getTextBefore(count) {
353
+ // Auto-text's verification before it deletes: the answer must reflect
354
+ // the live document, so `null` (unknown) is the only honest reply when
355
+ // the view is unavailable — never a guess.
356
+ const pm = view();
357
+ if (!pm)
358
+ return null;
359
+ try {
360
+ const { from } = pm.state.selection;
361
+ const start = Math.max(0, from - Math.max(0, Math.trunc(count)));
362
+ return pm.state.doc.textBetween(start, from);
363
+ }
364
+ catch {
365
+ return null;
366
+ }
367
+ },
309
368
  async replaceAll(query, replacement) {
310
369
  const handle = search();
311
370
  if (!handle)
@@ -367,6 +426,8 @@ export function createSuperdocHost(options) {
367
426
  container?.removeEventListener('beforeinput', onBeforeInput, true);
368
427
  container?.removeEventListener('compositionstart', onCompositionStart, true);
369
428
  container?.removeEventListener('compositionend', onCompositionEnd, true);
429
+ container?.removeEventListener('pointerdown', onPointerDown, true);
430
+ container?.removeEventListener('keydown', onKeydown, true);
370
431
  commandListeners.clear();
371
432
  inputListeners.clear();
372
433
  },
package/dist/index.d.ts CHANGED
@@ -6,8 +6,8 @@ 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';
10
- export { renderSnippet, expandSnippet, usesSelection, type ExpandOptions, type RenderContext } from './snippets/snippets.js';
9
+ export { MacroRecorder, replayMacro, type RecorderOptions, type RecordingWarning, type ReplayOptions, type ReplayResult, } from './recorder/recorder.js';
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
- export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, type ParsedShortcut, type ShortcutBinding, type ShortcutTarget, } from './shortcuts.js';
13
- export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, type MacroStorage, type PersistedMacroState, } from './storage.js';
12
+ export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, isBindableKey, codesForKey, type ParsedShortcut, type ShortcutBinding, type ShortcutTarget, } from './shortcuts.js';
13
+ export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, serializePersistable, type MacroStorage, type PersistedMacroState, } from './storage.js';
package/dist/index.js CHANGED
@@ -4,8 +4,8 @@ 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';
8
- export { renderSnippet, expandSnippet, usesSelection } from './snippets/snippets.js';
7
+ export { MacroRecorder, replayMacro, } from './recorder/recorder.js';
8
+ export { renderSnippet, renderSnippetForHost, expandSnippet, usesSelection, } from './snippets/snippets.js';
9
9
  export { AutoText } from './snippets/autotext.js';
10
- export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, } from './shortcuts.js';
11
- export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, } from './storage.js';
10
+ export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, isBindableKey, codesForKey, } from './shortcuts.js';
11
+ export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, serializePersistable, } from './storage.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;
@@ -97,9 +109,26 @@ export declare class MacroKit {
97
109
  get isRecording(): boolean;
98
110
  get recordedStepCount(): number;
99
111
  startRecording(): void;
100
- /** Stops and saves. `null` when no step was recorded — there is nothing to save. */
101
- stopRecording(name: string, shortcut?: string): RecordedMacro | null;
112
+ /**
113
+ * Stops and saves. `null` when no step was recorded — there is nothing to
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).
125
+ */
126
+ stopRecording(name: string, shortcut?: string, options?: {
127
+ allowIncomplete?: boolean;
128
+ }): RecordedMacro | null;
102
129
  cancelRecording(): void;
130
+ /** Whether a stopped recording awaits a retried save (see stopRecording). */
131
+ get hasPendingRecording(): boolean;
103
132
  listRecordings(): readonly RecordedMacro[];
104
133
  removeRecording(id: string): void;
105
134
  /** Renames a recording or edits its shortcut. `null` when the recording was not found. */
@@ -169,5 +198,19 @@ export declare class MacroKit {
169
198
  /** The item-count half of the invariant. `existingId` exempts an in-place update. */
170
199
  private requireRoom;
171
200
  private upsert;
172
- private persist;
201
+ /**
202
+ * Applies a mutation transactionally: the change runs on a clone, and the
203
+ * clone becomes the state only through `adopt` — validation and storage
204
+ * included. A failure at any stage leaves the previous state fully
205
+ * intact. Before this, the in-memory state mutated first and a quota
206
+ * failure left memory and disk silently disagreeing until the next
207
+ * successful save rewrote history.
208
+ */
209
+ private commit;
210
+ /**
211
+ * The persistence invariant, in one place: a candidate becomes the state
212
+ * only if it serializes under the loader's exact rules (shape, field
213
+ * caps, whole-file size) *and* the storage actually accepted it.
214
+ */
215
+ private adopt;
173
216
  }
package/dist/manager.js CHANGED
@@ -12,9 +12,9 @@ import { createEvalRunner } from './scripting/eval-runner.js';
12
12
  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
- import { expandSnippet } from './snippets/snippets.js';
16
- import { IMPORT_LIMITS, isPersistableState } from './storage.js';
17
- import { bindShortcuts, hasBindingModifier, parseShortcut, shortcutSignatures, } from './shortcuts.js';
15
+ import { renderSnippetForHost } from './snippets/snippets.js';
16
+ import { IMPORT_LIMITS, isPersistableState, serializePersistable } from './storage.js';
17
+ import { bindShortcuts, hasBindingModifier, isBindableKey, parseShortcut, shortcutSignatures, } from './shortcuts.js';
18
18
  import { MacroError } from './scripting/macro-api.js';
19
19
  import { createLocalStorage, emptyState, parsePersistedState } from './storage.js';
20
20
  import { macroMessages } from './messages.js';
@@ -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,20 +87,62 @@ export class MacroKit {
82
87
  const trimmed = shortcut?.trim();
83
88
  if (!trimmed)
84
89
  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);
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);
95
94
  if (owner)
96
95
  return { ok: false, message: macroMessages().shortcutTaken(owner) };
97
96
  return { ok: true };
98
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);
106
+ if (!parsed)
107
+ return { message: macroMessages().shortcutInvalid };
108
+ if (!hasBindingModifier(parsed))
109
+ return { message: macroMessages().shortcutNeedsModifier };
110
+ // Only keys with a physical-code mapping (letters, digits, F-keys):
111
+ // matching is by event.code, so it survives a Hebrew keyboard layout.
112
+ if (!isBindableKey(parsed))
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);
144
+ }
145
+ }
99
146
  findShortcutOwner(signatures, excludeId) {
100
147
  const items = [
101
148
  ...this.state.scripts,
@@ -126,7 +173,7 @@ export class MacroKit {
126
173
  }
127
174
  saveScript(input) {
128
175
  this.requireValidShortcut(input.shortcut, input.id);
129
- this.requireItemLimits({ name: input.name, source: input.source });
176
+ this.requireItemLimits({ name: input.name, source: input.source, shortcut: input.shortcut });
130
177
  this.requireRoom(this.state.scripts, input.id);
131
178
  const script = {
132
179
  id: input.id ?? newId(),
@@ -134,13 +181,15 @@ export class MacroKit {
134
181
  source: input.source,
135
182
  ...(input.shortcut ? { shortcut: input.shortcut } : {}),
136
183
  };
137
- this.upsert(this.state.scripts, script);
138
- this.persist();
139
- return script;
184
+ return this.commit((draft) => {
185
+ this.upsert(draft.scripts, script);
186
+ return script;
187
+ });
140
188
  }
141
189
  removeScript(id) {
142
- this.state.scripts = this.state.scripts.filter((script) => script.id !== id);
143
- this.persist();
190
+ this.commit((draft) => {
191
+ draft.scripts = draft.scripts.filter((script) => script.id !== id);
192
+ });
144
193
  }
145
194
  async runScript(id) {
146
195
  const script = this.state.scripts.find((entry) => entry.id === id);
@@ -179,14 +228,36 @@ export class MacroKit {
179
228
  return;
180
229
  this.recorder.start();
181
230
  }
182
- /** Stops and saves. `null` when no step was recorded — there is nothing to save. */
183
- stopRecording(name, shortcut) {
231
+ /**
232
+ * Stops and saves. `null` when no step was recorded — there is nothing to
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).
244
+ */
245
+ stopRecording(name, shortcut, options = {}) {
184
246
  this.requireValidShortcut(shortcut);
185
- this.requireItemLimits({ name });
247
+ this.requireItemLimits({ name, shortcut });
186
248
  this.requireRoom(this.state.recordings);
187
- const steps = splitOversizedSteps(this.recorder.stop());
188
- if (steps.length === 0)
249
+ const pending = this.recorder.stop();
250
+ const { steps, truncated } = splitOversizedSteps(pending.steps);
251
+ if (truncated)
252
+ throw new MacroError(macroMessages().recordingTooLarge, 'recording-too-large');
253
+ if (steps.length === 0) {
254
+ this.recorder.discard();
189
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
+ }
190
261
  const recording = {
191
262
  version: 1,
192
263
  id: newId(),
@@ -195,39 +266,50 @@ export class MacroKit {
195
266
  ...(shortcut ? { shortcut } : {}),
196
267
  steps,
197
268
  };
198
- this.state.recordings.push(recording);
199
- this.persist();
200
- return recording;
269
+ const saved = this.commit((draft) => {
270
+ draft.recordings.push(recording);
271
+ return recording;
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;
201
277
  }
202
278
  cancelRecording() {
203
279
  this.recorder.cancel();
204
280
  }
281
+ /** Whether a stopped recording awaits a retried save (see stopRecording). */
282
+ get hasPendingRecording() {
283
+ return this.recorder.hasPending;
284
+ }
205
285
  listRecordings() {
206
286
  return this.state.recordings;
207
287
  }
208
288
  removeRecording(id) {
209
- this.state.recordings = this.state.recordings.filter((recording) => recording.id !== id);
210
- this.persist();
289
+ this.commit((draft) => {
290
+ draft.recordings = draft.recordings.filter((recording) => recording.id !== id);
291
+ });
211
292
  }
212
293
  /** Renames a recording or edits its shortcut. `null` when the recording was not found. */
213
294
  updateRecording(input) {
214
- const recording = this.state.recordings.find((entry) => entry.id === input.id);
215
- if (!recording)
295
+ if (!this.state.recordings.some((entry) => entry.id === input.id))
216
296
  return null;
217
297
  if (input.shortcut !== undefined)
218
298
  this.requireValidShortcut(input.shortcut, input.id);
219
299
  if (input.name !== undefined)
220
- this.requireItemLimits({ name: input.name });
221
- if (input.name !== undefined)
222
- recording.name = input.name;
223
- if (input.shortcut !== undefined) {
224
- if (input.shortcut)
225
- recording.shortcut = input.shortcut;
226
- else
227
- delete recording.shortcut;
228
- }
229
- this.persist();
230
- return recording;
300
+ this.requireItemLimits({ name: input.name, shortcut: input.shortcut });
301
+ return this.commit((draft) => {
302
+ const recording = draft.recordings.find((entry) => entry.id === input.id);
303
+ if (input.name !== undefined)
304
+ recording.name = input.name;
305
+ if (input.shortcut !== undefined) {
306
+ if (input.shortcut)
307
+ recording.shortcut = input.shortcut;
308
+ else
309
+ delete recording.shortcut;
310
+ }
311
+ return recording;
312
+ });
231
313
  }
232
314
  async replayRecording(id, options) {
233
315
  const recording = this.state.recordings.find((entry) => entry.id === id);
@@ -256,7 +338,12 @@ export class MacroKit {
256
338
  }
257
339
  saveSnippet(input) {
258
340
  this.requireValidShortcut(input.shortcut, input.id);
259
- this.requireItemLimits({ name: input.name, text: input.text, trigger: input.trigger });
341
+ this.requireItemLimits({
342
+ name: input.name,
343
+ text: input.text,
344
+ trigger: input.trigger,
345
+ shortcut: input.shortcut,
346
+ });
260
347
  this.requireRoom(this.state.snippets, input.id);
261
348
  const snippet = {
262
349
  id: input.id ?? newId(),
@@ -265,20 +352,30 @@ export class MacroKit {
265
352
  ...(input.trigger ? { trigger: input.trigger } : {}),
266
353
  ...(input.shortcut ? { shortcut: input.shortcut } : {}),
267
354
  };
268
- this.upsert(this.state.snippets, snippet);
269
- this.persist();
270
- return snippet;
355
+ return this.commit((draft) => {
356
+ this.upsert(draft.snippets, snippet);
357
+ return snippet;
358
+ });
271
359
  }
272
360
  removeSnippet(id) {
273
- this.state.snippets = this.state.snippets.filter((snippet) => snippet.id !== id);
274
- this.persist();
361
+ this.commit((draft) => {
362
+ draft.snippets = draft.snippets.filter((snippet) => snippet.id !== id);
363
+ });
275
364
  }
276
365
  async expandSnippet(id, options) {
277
366
  const snippet = this.state.snippets.find((entry) => entry.id === id);
278
367
  if (!snippet)
279
368
  return { ok: false, message: macroMessages().snippetNotFound };
280
- const outcome = await expandSnippet(this.host, snippet, options);
281
- return outcome.ok ? { ok: true } : { ok: false, message: outcome.message };
369
+ const rendered = await renderSnippetForHost(this.host, snippet, options);
370
+ const outcome = await this.host.insertText(rendered);
371
+ if (!outcome.ok)
372
+ return { ok: false, message: outcome.message };
373
+ // A snippet expanded from a button or shortcut writes through the
374
+ // document API and fires no typing events — recorded explicitly, or a
375
+ // replay would silently miss text the user watched appear. The auto-text
376
+ // path needs nothing here: its expansion rewrites the typed tail.
377
+ this.recorder.recordInsert(rendered);
378
+ return { ok: true };
282
379
  }
283
380
  /** Enables auto-text (trigger + space). Returns a disable function. */
284
381
  enableAutoText() {
@@ -361,8 +458,12 @@ export class MacroKit {
361
458
  const shortcutsOk = this.validateStateShortcuts(candidate);
362
459
  if (!shortcutsOk.ok)
363
460
  return shortcutsOk;
364
- this.state = candidate;
365
- this.persist();
461
+ try {
462
+ this.adopt(candidate);
463
+ }
464
+ catch (error) {
465
+ return { ok: false, message: error instanceof Error ? error.message : macroMessages().saveFailed };
466
+ }
366
467
  return { ok: true };
367
468
  }
368
469
  /**
@@ -381,17 +482,14 @@ export class MacroKit {
381
482
  for (const item of items) {
382
483
  if (!item.shortcut)
383
484
  continue;
384
- const parsed = parseShortcut(item.shortcut);
385
- if (!parsed) {
386
- return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutInvalid) };
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) };
387
491
  }
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
- }
492
+ for (const signature of shortcutSignatures(parseShortcut(item.shortcut))) {
395
493
  const owner = seen.get(signature);
396
494
  if (owner !== undefined) {
397
495
  return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutTaken(owner)) };
@@ -424,6 +522,9 @@ export class MacroKit {
424
522
  if (fields.name.length > limits.maxNameLength) {
425
523
  throw new MacroError(macroMessages().fieldTooLong('name', limits.maxNameLength), 'invalid-item');
426
524
  }
525
+ if (fields.shortcut !== undefined && fields.shortcut.length > limits.maxShortcutLength) {
526
+ throw new MacroError(macroMessages().fieldTooLong('shortcut', limits.maxShortcutLength), 'invalid-item');
527
+ }
427
528
  if (fields.text !== undefined && fields.text.length > limits.maxTextLength) {
428
529
  throw new MacroError(macroMessages().fieldTooLong('text', limits.maxTextLength), 'invalid-item');
429
530
  }
@@ -449,22 +550,42 @@ export class MacroKit {
449
550
  else
450
551
  list.push(item);
451
552
  }
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;
553
+ /**
554
+ * Applies a mutation transactionally: the change runs on a clone, and the
555
+ * clone becomes the state only through `adopt` validation and storage
556
+ * included. A failure at any stage leaves the previous state fully
557
+ * intact. Before this, the in-memory state mutated first and a quota
558
+ * failure left memory and disk silently disagreeing until the next
559
+ * successful save rewrote history.
560
+ */
561
+ commit(mutate) {
562
+ const draft = JSON.parse(JSON.stringify(this.state));
563
+ const result = mutate(draft);
564
+ this.adopt(draft);
565
+ return result;
566
+ }
567
+ /**
568
+ * The persistence invariant, in one place: a candidate becomes the state
569
+ * only if it serializes under the loader's exact rules (shape, field
570
+ * caps, whole-file size) *and* the storage actually accepted it.
571
+ */
572
+ adopt(candidate) {
573
+ if (serializePersistable(candidate) === null) {
574
+ throw new MacroError(macroMessages().saveFailed, 'invalid-state');
460
575
  }
461
- this.storage.save(this.state);
576
+ if (!this.storage.save(candidate)) {
577
+ throw new MacroError(macroMessages().saveFailed, 'storage-failed');
578
+ }
579
+ this.state = candidate;
462
580
  }
463
581
  }
464
582
  /**
465
583
  * A recorded insert-text step can exceed the loader's per-step cap (one huge
466
584
  * paste coalesces into one step). Splitting preserves the exact text while
467
- * keeping the recording loadable.
585
+ * keeping the recording loadable. `truncated` reports the pathological case
586
+ * where even the split exceeds the loader's step limit — the caller refuses
587
+ * to save then, with a message: a silently partial macro is worse than no
588
+ * macro.
468
589
  */
469
590
  function splitOversizedSteps(steps) {
470
591
  const max = IMPORT_LIMITS.maxTextLength;
@@ -477,12 +598,5 @@ function splitOversizedSteps(steps) {
477
598
  }
478
599
  return chunks;
479
600
  });
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;
601
+ return { steps: split, truncated: split.length > IMPORT_LIMITS.maxStepsPerRecording };
488
602
  }
@@ -40,6 +40,9 @@ export interface MacroMessages {
40
40
  importTooLarge: string;
41
41
  tooManyItems: string;
42
42
  fieldTooLong: (field: string, max: number) => string;
43
+ saveFailed: string;
44
+ recordingTooLarge: string;
45
+ recordingIncomplete: (commandIds: string) => string;
43
46
  shortcutInvalid: string;
44
47
  shortcutNeedsModifier: string;
45
48
  shortcutReserved: string;