superdoc-macros 0.5.0 → 0.6.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,22 @@ export function createSuperdocHost(options) {
306
327
  async getSelection(options) {
307
328
  return (await readSelectionDetailed(options?.includeText ?? false)).snapshot;
308
329
  },
330
+ async getTextBefore(count) {
331
+ // Auto-text's verification before it deletes: the answer must reflect
332
+ // the live document, so `null` (unknown) is the only honest reply when
333
+ // the view is unavailable — never a guess.
334
+ const pm = view();
335
+ if (!pm)
336
+ return null;
337
+ try {
338
+ const { from } = pm.state.selection;
339
+ const start = Math.max(0, from - Math.max(0, Math.trunc(count)));
340
+ return pm.state.doc.textBetween(start, from);
341
+ }
342
+ catch {
343
+ return null;
344
+ }
345
+ },
309
346
  async replaceAll(query, replacement) {
310
347
  const handle = search();
311
348
  if (!handle)
@@ -367,6 +404,8 @@ export function createSuperdocHost(options) {
367
404
  container?.removeEventListener('beforeinput', onBeforeInput, true);
368
405
  container?.removeEventListener('compositionstart', onCompositionStart, true);
369
406
  container?.removeEventListener('compositionend', onCompositionEnd, true);
407
+ container?.removeEventListener('pointerdown', onPointerDown, true);
408
+ container?.removeEventListener('keydown', onKeydown, true);
370
409
  commandListeners.clear();
371
410
  inputListeners.clear();
372
411
  },
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@ 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
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';
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
@@ -5,7 +5,7 @@ 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
7
  export { MacroRecorder, replayMacro } from './recorder/recorder.js';
8
- export { renderSnippet, expandSnippet, usesSelection } from './snippets/snippets.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
@@ -97,7 +97,12 @@ export declare class MacroKit {
97
97
  get isRecording(): boolean;
98
98
  get recordedStepCount(): number;
99
99
  startRecording(): void;
100
- /** Stops and saves. `null` when no step was recorded — there is nothing to save. */
100
+ /**
101
+ * 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.
105
+ */
101
106
  stopRecording(name: string, shortcut?: string): RecordedMacro | null;
102
107
  cancelRecording(): void;
103
108
  listRecordings(): readonly RecordedMacro[];
@@ -169,5 +174,19 @@ export declare class MacroKit {
169
174
  /** The item-count half of the invariant. `existingId` exempts an in-place update. */
170
175
  private requireRoom;
171
176
  private upsert;
172
- private persist;
177
+ /**
178
+ * Applies a mutation transactionally: the change runs on a clone, and the
179
+ * clone becomes the state only through `adopt` — validation and storage
180
+ * included. A failure at any stage leaves the previous state fully
181
+ * intact. Before this, the in-memory state mutated first and a quota
182
+ * failure left memory and disk silently disagreeing until the next
183
+ * successful save rewrote history.
184
+ */
185
+ private commit;
186
+ /**
187
+ * The persistence invariant, in one place: a candidate becomes the state
188
+ * only if it serializes under the loader's exact rules (shape, field
189
+ * caps, whole-file size) *and* the storage actually accepted it.
190
+ */
191
+ private adopt;
173
192
  }
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';
@@ -87,6 +87,10 @@ export class MacroKit {
87
87
  return { ok: false, message: macroMessages().shortcutInvalid };
88
88
  if (!hasBindingModifier(parsed))
89
89
  return { ok: false, message: macroMessages().shortcutNeedsModifier };
90
+ // Only keys with a physical-code mapping (letters, digits, F-keys):
91
+ // matching is by event.code, so it survives a Hebrew keyboard layout.
92
+ if (!isBindableKey(parsed))
93
+ return { ok: false, message: macroMessages().shortcutInvalid };
90
94
  const signatures = shortcutSignatures(parsed);
91
95
  if (signatures.some((signature) => this.reservedSignatures.has(signature))) {
92
96
  return { ok: false, message: macroMessages().shortcutReserved };
@@ -126,7 +130,7 @@ export class MacroKit {
126
130
  }
127
131
  saveScript(input) {
128
132
  this.requireValidShortcut(input.shortcut, input.id);
129
- this.requireItemLimits({ name: input.name, source: input.source });
133
+ this.requireItemLimits({ name: input.name, source: input.source, shortcut: input.shortcut });
130
134
  this.requireRoom(this.state.scripts, input.id);
131
135
  const script = {
132
136
  id: input.id ?? newId(),
@@ -134,13 +138,15 @@ export class MacroKit {
134
138
  source: input.source,
135
139
  ...(input.shortcut ? { shortcut: input.shortcut } : {}),
136
140
  };
137
- this.upsert(this.state.scripts, script);
138
- this.persist();
139
- return script;
141
+ return this.commit((draft) => {
142
+ this.upsert(draft.scripts, script);
143
+ return script;
144
+ });
140
145
  }
141
146
  removeScript(id) {
142
- this.state.scripts = this.state.scripts.filter((script) => script.id !== id);
143
- this.persist();
147
+ this.commit((draft) => {
148
+ draft.scripts = draft.scripts.filter((script) => script.id !== id);
149
+ });
144
150
  }
145
151
  async runScript(id) {
146
152
  const script = this.state.scripts.find((entry) => entry.id === id);
@@ -179,12 +185,19 @@ export class MacroKit {
179
185
  return;
180
186
  this.recorder.start();
181
187
  }
182
- /** Stops and saves. `null` when no step was recorded — there is nothing to save. */
188
+ /**
189
+ * 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.
193
+ */
183
194
  stopRecording(name, shortcut) {
184
195
  this.requireValidShortcut(shortcut);
185
- this.requireItemLimits({ name });
196
+ this.requireItemLimits({ name, shortcut });
186
197
  this.requireRoom(this.state.recordings);
187
- const steps = splitOversizedSteps(this.recorder.stop());
198
+ const { steps, truncated } = splitOversizedSteps(this.recorder.stop());
199
+ if (truncated)
200
+ throw new MacroError(macroMessages().recordingTooLarge, 'recording-too-large');
188
201
  if (steps.length === 0)
189
202
  return null;
190
203
  const recording = {
@@ -195,9 +208,10 @@ export class MacroKit {
195
208
  ...(shortcut ? { shortcut } : {}),
196
209
  steps,
197
210
  };
198
- this.state.recordings.push(recording);
199
- this.persist();
200
- return recording;
211
+ return this.commit((draft) => {
212
+ draft.recordings.push(recording);
213
+ return recording;
214
+ });
201
215
  }
202
216
  cancelRecording() {
203
217
  this.recorder.cancel();
@@ -206,28 +220,30 @@ export class MacroKit {
206
220
  return this.state.recordings;
207
221
  }
208
222
  removeRecording(id) {
209
- this.state.recordings = this.state.recordings.filter((recording) => recording.id !== id);
210
- this.persist();
223
+ this.commit((draft) => {
224
+ draft.recordings = draft.recordings.filter((recording) => recording.id !== id);
225
+ });
211
226
  }
212
227
  /** Renames a recording or edits its shortcut. `null` when the recording was not found. */
213
228
  updateRecording(input) {
214
- const recording = this.state.recordings.find((entry) => entry.id === input.id);
215
- if (!recording)
229
+ if (!this.state.recordings.some((entry) => entry.id === input.id))
216
230
  return null;
217
231
  if (input.shortcut !== undefined)
218
232
  this.requireValidShortcut(input.shortcut, input.id);
219
233
  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;
234
+ this.requireItemLimits({ name: input.name, shortcut: input.shortcut });
235
+ return this.commit((draft) => {
236
+ const recording = draft.recordings.find((entry) => entry.id === input.id);
237
+ if (input.name !== undefined)
238
+ recording.name = input.name;
239
+ if (input.shortcut !== undefined) {
240
+ if (input.shortcut)
241
+ recording.shortcut = input.shortcut;
242
+ else
243
+ delete recording.shortcut;
244
+ }
245
+ return recording;
246
+ });
231
247
  }
232
248
  async replayRecording(id, options) {
233
249
  const recording = this.state.recordings.find((entry) => entry.id === id);
@@ -256,7 +272,12 @@ export class MacroKit {
256
272
  }
257
273
  saveSnippet(input) {
258
274
  this.requireValidShortcut(input.shortcut, input.id);
259
- this.requireItemLimits({ name: input.name, text: input.text, trigger: input.trigger });
275
+ this.requireItemLimits({
276
+ name: input.name,
277
+ text: input.text,
278
+ trigger: input.trigger,
279
+ shortcut: input.shortcut,
280
+ });
260
281
  this.requireRoom(this.state.snippets, input.id);
261
282
  const snippet = {
262
283
  id: input.id ?? newId(),
@@ -265,20 +286,30 @@ export class MacroKit {
265
286
  ...(input.trigger ? { trigger: input.trigger } : {}),
266
287
  ...(input.shortcut ? { shortcut: input.shortcut } : {}),
267
288
  };
268
- this.upsert(this.state.snippets, snippet);
269
- this.persist();
270
- return snippet;
289
+ return this.commit((draft) => {
290
+ this.upsert(draft.snippets, snippet);
291
+ return snippet;
292
+ });
271
293
  }
272
294
  removeSnippet(id) {
273
- this.state.snippets = this.state.snippets.filter((snippet) => snippet.id !== id);
274
- this.persist();
295
+ this.commit((draft) => {
296
+ draft.snippets = draft.snippets.filter((snippet) => snippet.id !== id);
297
+ });
275
298
  }
276
299
  async expandSnippet(id, options) {
277
300
  const snippet = this.state.snippets.find((entry) => entry.id === id);
278
301
  if (!snippet)
279
302
  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 };
303
+ const rendered = await renderSnippetForHost(this.host, snippet, options);
304
+ const outcome = await this.host.insertText(rendered);
305
+ if (!outcome.ok)
306
+ return { ok: false, message: outcome.message };
307
+ // A snippet expanded from a button or shortcut writes through the
308
+ // document API and fires no typing events — recorded explicitly, or a
309
+ // replay would silently miss text the user watched appear. The auto-text
310
+ // path needs nothing here: its expansion rewrites the typed tail.
311
+ this.recorder.recordInsert(rendered);
312
+ return { ok: true };
282
313
  }
283
314
  /** Enables auto-text (trigger + space). Returns a disable function. */
284
315
  enableAutoText() {
@@ -361,8 +392,12 @@ export class MacroKit {
361
392
  const shortcutsOk = this.validateStateShortcuts(candidate);
362
393
  if (!shortcutsOk.ok)
363
394
  return shortcutsOk;
364
- this.state = candidate;
365
- this.persist();
395
+ try {
396
+ this.adopt(candidate);
397
+ }
398
+ catch (error) {
399
+ return { ok: false, message: error instanceof Error ? error.message : macroMessages().saveFailed };
400
+ }
366
401
  return { ok: true };
367
402
  }
368
403
  /**
@@ -424,6 +459,9 @@ export class MacroKit {
424
459
  if (fields.name.length > limits.maxNameLength) {
425
460
  throw new MacroError(macroMessages().fieldTooLong('name', limits.maxNameLength), 'invalid-item');
426
461
  }
462
+ if (fields.shortcut !== undefined && fields.shortcut.length > limits.maxShortcutLength) {
463
+ throw new MacroError(macroMessages().fieldTooLong('shortcut', limits.maxShortcutLength), 'invalid-item');
464
+ }
427
465
  if (fields.text !== undefined && fields.text.length > limits.maxTextLength) {
428
466
  throw new MacroError(macroMessages().fieldTooLong('text', limits.maxTextLength), 'invalid-item');
429
467
  }
@@ -449,22 +487,42 @@ export class MacroKit {
449
487
  else
450
488
  list.push(item);
451
489
  }
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;
490
+ /**
491
+ * Applies a mutation transactionally: the change runs on a clone, and the
492
+ * clone becomes the state only through `adopt` validation and storage
493
+ * included. A failure at any stage leaves the previous state fully
494
+ * intact. Before this, the in-memory state mutated first and a quota
495
+ * failure left memory and disk silently disagreeing until the next
496
+ * successful save rewrote history.
497
+ */
498
+ commit(mutate) {
499
+ const draft = JSON.parse(JSON.stringify(this.state));
500
+ const result = mutate(draft);
501
+ this.adopt(draft);
502
+ return result;
503
+ }
504
+ /**
505
+ * The persistence invariant, in one place: a candidate becomes the state
506
+ * only if it serializes under the loader's exact rules (shape, field
507
+ * caps, whole-file size) *and* the storage actually accepted it.
508
+ */
509
+ adopt(candidate) {
510
+ if (serializePersistable(candidate) === null) {
511
+ throw new MacroError(macroMessages().saveFailed, 'invalid-state');
512
+ }
513
+ if (!this.storage.save(candidate)) {
514
+ throw new MacroError(macroMessages().saveFailed, 'storage-failed');
460
515
  }
461
- this.storage.save(this.state);
516
+ this.state = candidate;
462
517
  }
463
518
  }
464
519
  /**
465
520
  * A recorded insert-text step can exceed the loader's per-step cap (one huge
466
521
  * paste coalesces into one step). Splitting preserves the exact text while
467
- * keeping the recording loadable.
522
+ * keeping the recording loadable. `truncated` reports the pathological case
523
+ * where even the split exceeds the loader's step limit — the caller refuses
524
+ * to save then, with a message: a silently partial macro is worse than no
525
+ * macro.
468
526
  */
469
527
  function splitOversizedSteps(steps) {
470
528
  const max = IMPORT_LIMITS.maxTextLength;
@@ -477,12 +535,5 @@ function splitOversizedSteps(steps) {
477
535
  }
478
536
  return chunks;
479
537
  });
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;
538
+ return { steps: split, truncated: split.length > IMPORT_LIMITS.maxStepsPerRecording };
488
539
  }
@@ -40,6 +40,8 @@ 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;
43
45
  shortcutInvalid: string;
44
46
  shortcutNeedsModifier: string;
45
47
  shortcutReserved: string;
package/dist/messages.js CHANGED
@@ -40,6 +40,8 @@ export const ENGLISH_MESSAGES = {
40
40
  tooManyItems: 'The list is full — delete items before adding new ones',
41
41
  nameRequired: 'A name is required',
42
42
  fieldTooLong: (field, max) => `${field} is too long (limit: ${max} characters)`,
43
+ saveFailed: 'Saving failed — the change was not applied',
44
+ recordingTooLarge: 'The recording is too large to save',
43
45
  shortcutInvalid: 'Invalid shortcut — use a form like Ctrl+Alt+M',
44
46
  shortcutNeedsModifier: 'A shortcut must include Ctrl, Alt or Meta',
45
47
  shortcutReserved: 'This shortcut is reserved by the editor',
@@ -77,6 +79,8 @@ export const HEBREW_MESSAGES = {
77
79
  tooManyItems: 'הרשימה מלאה — יש למחוק פריטים לפני הוספה',
78
80
  nameRequired: 'חובה לתת שם',
79
81
  fieldTooLong: (field, max) => `${field} ארוך מדי (התקרה: ${max} תווים)`,
82
+ saveFailed: 'השמירה נכשלה — השינוי לא הוחל',
83
+ recordingTooLarge: 'ההקלטה גדולה מכדי להישמר',
80
84
  shortcutInvalid: 'קיצור לא תקין — הצורה הנדרשת היא למשל Ctrl+Alt+M',
81
85
  shortcutNeedsModifier: 'קיצור חייב לכלול Ctrl,‏ Alt או Meta',
82
86
  shortcutReserved: 'הקיצור הזה שמור לעורך',
@@ -1,17 +1,3 @@
1
- /**
2
- * A Word-style macro recorder: records **commands** and typing, not caret
3
- * positions.
4
- *
5
- * That choice is deliberate. Recording raw ProseMirror steps (with absolute
6
- * positions) breaks the moment the document differs from what it was at
7
- * recording time; recording commands ("bold", "bullet-list", typing a
8
- * greeting) behaves like Word's recorder — the actions apply wherever the
9
- * caret is at replay time. It is also what makes a recording saveable and
10
- * shareable: the steps are plain JSON.
11
- *
12
- * What is not recorded: caret movement and mouse selection. As in Word, a
13
- * recorded macro acts from wherever the caret stands when it runs.
14
- */
15
1
  import type { MacroHost, MacroStep } from '../types.js';
16
2
  export interface RecorderOptions {
17
3
  /** Command filter. The default records everything except undo/redo. */
@@ -34,6 +20,12 @@ export declare class MacroRecorder {
34
20
  private steps;
35
21
  private disposers;
36
22
  private active;
23
+ /**
24
+ * Set by a caret move: the next typed character starts a fresh step
25
+ * instead of coalescing — text typed at a new position is not a
26
+ * continuation of the text typed at the old one.
27
+ */
28
+ private tailInterrupted;
37
29
  constructor(host: MacroHost, options?: RecorderOptions);
38
30
  get recording(): boolean;
39
31
  get stepCount(): number;
@@ -62,6 +54,13 @@ export declare class MacroRecorder {
62
54
  applyAutoTextExpansion(consumed: number, replacement: string): void;
63
55
  private teardown;
64
56
  private push;
57
+ /**
58
+ * Records a programmatic insertion the host will not report as typing —
59
+ * e.g. a snippet expanded from a button or shortcut, which writes through
60
+ * the document API and never fires beforeinput. Without this, a replay
61
+ * would silently miss text the user watched appear.
62
+ */
63
+ recordInsert(text: string): void;
65
64
  private recordCommand;
66
65
  private recordTextInput;
67
66
  }
@@ -1,3 +1,18 @@
1
+ /**
2
+ * A Word-style macro recorder: records **commands** and typing, not caret
3
+ * positions.
4
+ *
5
+ * That choice is deliberate. Recording raw ProseMirror steps (with absolute
6
+ * positions) breaks the moment the document differs from what it was at
7
+ * recording time; recording commands ("bold", "bullet-list", typing a
8
+ * greeting) behaves like Word's recorder — the actions apply wherever the
9
+ * caret is at replay time. It is also what makes a recording saveable and
10
+ * shareable: the steps are plain JSON.
11
+ *
12
+ * What is not recorded: caret movement and mouse selection. As in Word, a
13
+ * recorded macro acts from wherever the caret stands when it runs.
14
+ */
15
+ import { IMPORT_LIMITS } from '../storage.js';
1
16
  const DEFAULT_MAX_STEPS = 5_000;
2
17
  /** Undo/redo during recording fix the recording itself — replaying them would replay the mistake too. */
3
18
  function defaultShouldRecord(id) {
@@ -11,6 +26,12 @@ export class MacroRecorder {
11
26
  steps = [];
12
27
  disposers = [];
13
28
  active = false;
29
+ /**
30
+ * Set by a caret move: the next typed character starts a fresh step
31
+ * instead of coalescing — text typed at a new position is not a
32
+ * continuation of the text typed at the old one.
33
+ */
34
+ tailInterrupted = false;
14
35
  constructor(host, options = {}) {
15
36
  this.host = host;
16
37
  this.shouldRecordCommand = options.shouldRecordCommand ?? defaultShouldRecord;
@@ -105,17 +126,49 @@ export class MacroRecorder {
105
126
  this.onAutoStop?.();
106
127
  }
107
128
  }
129
+ /**
130
+ * Records a programmatic insertion the host will not report as typing —
131
+ * e.g. a snippet expanded from a button or shortcut, which writes through
132
+ * the document API and never fires beforeinput. Without this, a replay
133
+ * would silently miss text the user watched appear.
134
+ */
135
+ recordInsert(text) {
136
+ if (!this.active || text.length === 0)
137
+ return;
138
+ this.recordTextInput({ kind: 'insert-text', text });
139
+ }
108
140
  recordCommand(id, payload) {
109
141
  if (!this.shouldRecordCommand(id))
110
142
  return;
111
- this.push(payload === undefined ? { type: 'command', id } : { type: 'command', id, payload });
143
+ if (payload === undefined) {
144
+ this.push({ type: 'command', id });
145
+ return;
146
+ }
147
+ // The payload is opaque engine data, but not unlimited: it must survive
148
+ // a JSON round-trip within the persistence cap, or the recording would
149
+ // 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.
152
+ let json;
153
+ try {
154
+ json = JSON.stringify(payload);
155
+ }
156
+ catch {
157
+ return;
158
+ }
159
+ if (typeof json !== 'string' || json.length > IMPORT_LIMITS.maxPayloadLength)
160
+ return;
161
+ this.push({ type: 'command', id, payload: JSON.parse(json) });
112
162
  }
113
163
  recordTextInput(event) {
164
+ const interrupted = this.tailInterrupted;
165
+ this.tailInterrupted = false;
114
166
  const last = this.steps[this.steps.length - 1];
115
167
  switch (event.kind) {
116
168
  case 'insert-text': {
117
- // Consecutive keystrokes coalesce into one step — more readable, faster to replay.
118
- if (last?.type === 'insert-text') {
169
+ // Consecutive keystrokes coalesce into one step — more readable,
170
+ // faster to replay — but never across a caret move.
171
+ if (!interrupted && last?.type === 'insert-text') {
119
172
  last.text += event.text;
120
173
  return;
121
174
  }
@@ -126,7 +179,7 @@ export class MacroRecorder {
126
179
  this.push({ type: 'insert-paragraph' });
127
180
  return;
128
181
  case 'delete-backward': {
129
- if (last?.type === 'delete-backward') {
182
+ if (!interrupted && last?.type === 'delete-backward') {
130
183
  last.count += 1;
131
184
  return;
132
185
  }
@@ -134,13 +187,18 @@ export class MacroRecorder {
134
187
  return;
135
188
  }
136
189
  case 'delete-forward': {
137
- if (last?.type === 'delete-forward') {
190
+ if (!interrupted && last?.type === 'delete-forward') {
138
191
  last.count += 1;
139
192
  return;
140
193
  }
141
194
  this.push({ type: 'delete-forward', count: 1 });
142
195
  return;
143
196
  }
197
+ case 'caret-moved':
198
+ // Not a step — replay acts from the live caret — but the recorded
199
+ // tail is no longer "where the user is typing".
200
+ this.tailInterrupted = true;
201
+ return;
144
202
  }
145
203
  }
146
204
  }
@@ -16,14 +16,33 @@ export interface ParsedShortcut {
16
16
  /** The subset of KeyboardEvent that matching needs. Enables DOM-free tests. */
17
17
  export interface KeyEventLike {
18
18
  key: string;
19
+ /** The physical key. When present, letters and digits match by it — see `eventMatches`. */
20
+ code?: string;
19
21
  ctrlKey: boolean;
20
22
  altKey: boolean;
21
23
  shiftKey: boolean;
22
24
  metaKey: boolean;
25
+ /** Key held down — auto-repeat must not re-fire a macro. */
26
+ repeat?: boolean;
27
+ /** Mid-IME-composition — keys belong to the composition, not to bindings. */
28
+ isComposing?: boolean;
23
29
  preventDefault?(): void;
24
30
  stopPropagation?(): void;
25
31
  }
26
32
  export declare function parseShortcut(shortcut: string): ParsedShortcut | null;
33
+ /**
34
+ * The physical `event.code` values a binding key stands for. Letters and
35
+ * digits get a deterministic mapping; anything else returns empty and falls
36
+ * back to `event.key`.
37
+ *
38
+ * Physical-key matching is what keeps a binding alive across keyboard
39
+ * layouts: on a Hebrew layout Ctrl+Alt+R reports `key: 'ר'`, and a
40
+ * key-based match would die the moment the user switches to Hebrew — the
41
+ * exact bug the host editor once had with its own shortcuts.
42
+ */
43
+ export declare function codesForKey(key: string): readonly string[];
44
+ /** Whether the key can be bound reliably (has a physical-code mapping). */
45
+ export declare function isBindableKey(parsed: ParsedShortcut): boolean;
27
46
  export declare function eventMatches(parsed: ParsedShortcut, event: KeyEventLike): boolean;
28
47
  /**
29
48
  * Comparable signatures for collision checks. `Mod` matches either Ctrl or
package/dist/shortcuts.js CHANGED
@@ -44,8 +44,40 @@ function normalizeKey(key) {
44
44
  return 'escape';
45
45
  return lower;
46
46
  }
47
+ /**
48
+ * The physical `event.code` values a binding key stands for. Letters and
49
+ * digits get a deterministic mapping; anything else returns empty and falls
50
+ * back to `event.key`.
51
+ *
52
+ * Physical-key matching is what keeps a binding alive across keyboard
53
+ * layouts: on a Hebrew layout Ctrl+Alt+R reports `key: 'ר'`, and a
54
+ * key-based match would die the moment the user switches to Hebrew — the
55
+ * exact bug the host editor once had with its own shortcuts.
56
+ */
57
+ export function codesForKey(key) {
58
+ if (/^[a-z]$/.test(key))
59
+ return [`Key${key.toUpperCase()}`];
60
+ if (/^[0-9]$/.test(key))
61
+ return [`Digit${key}`, `Numpad${key}`];
62
+ if (/^f([1-9]|1[0-2])$/.test(key))
63
+ return [key.toUpperCase()];
64
+ if (key === ' ')
65
+ return ['Space'];
66
+ if (key === 'escape')
67
+ return ['Escape'];
68
+ return [];
69
+ }
70
+ /** Whether the key can be bound reliably (has a physical-code mapping). */
71
+ export function isBindableKey(parsed) {
72
+ return codesForKey(parsed.key).length > 0;
73
+ }
47
74
  export function eventMatches(parsed, event) {
48
- if (normalizeKey(event.key) !== parsed.key)
75
+ const codes = codesForKey(parsed.key);
76
+ const keyMatched = normalizeKey(event.key) === parsed.key;
77
+ // The physical code decides whenever both sides have one; `event.key` is
78
+ // the fallback for keys with no mapping or hosts that do not report codes.
79
+ const matched = codes.length > 0 && event.code !== undefined ? codes.includes(event.code) : keyMatched;
80
+ if (!matched)
49
81
  return false;
50
82
  if (parsed.mod) {
51
83
  if (!event.ctrlKey && !event.metaKey)
@@ -84,6 +116,10 @@ export function hasBindingModifier(parsed) {
84
116
  */
85
117
  export function bindShortcuts(target, getBindings) {
86
118
  const listener = (event) => {
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)
122
+ return;
87
123
  for (const binding of getBindings()) {
88
124
  const parsed = parseShortcut(binding.shortcut);
89
125
  if (!parsed || !eventMatches(parsed, event))
@@ -50,6 +50,12 @@ export class AutoText {
50
50
  case 'delete-forward':
51
51
  this.buffer = '';
52
52
  return;
53
+ // A click or navigation key moved the caret: the buffer no longer
54
+ // describes what sits before it, and expanding on it would delete
55
+ // text at the new position. Missing an expansion is the cheap error.
56
+ case 'caret-moved':
57
+ this.buffer = '';
58
+ return;
53
59
  case 'delete-backward':
54
60
  this.buffer = this.buffer.slice(0, -1);
55
61
  return;
@@ -78,6 +84,14 @@ export class AutoText {
78
84
  // to the document. Deferring to the task queue guarantees the expansion
79
85
  // character is already in before it is deleted along with the trigger.
80
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.
91
+ const expected = trigger + expandChar;
92
+ const actual = await this.host.getTextBefore?.(expected.length);
93
+ if (typeof actual === 'string' && actual !== expected)
94
+ return;
81
95
  const selectionText = usesSelection(snippet.text)
82
96
  ? (await this.host.getSelection({ includeText: true })).text
83
97
  : undefined;
@@ -26,5 +26,11 @@ export interface ExpandOptions {
26
26
  now?: Date;
27
27
  locale?: string;
28
28
  }
29
+ /**
30
+ * Renders a snippet against the live document (reads the selection only
31
+ * when the snippet needs it). Split from the insertion so a caller that
32
+ * must know what text actually landed — e.g. a recorder — can.
33
+ */
34
+ export declare function renderSnippetForHost(host: MacroHost, snippet: Pick<Snippet, 'text'>, options?: ExpandOptions): Promise<string>;
29
35
  /** Expands a snippet at the caret. */
30
36
  export declare function expandSnippet(host: MacroHost, snippet: Pick<Snippet, 'text'>, options?: ExpandOptions): Promise<MacroOutcome>;
@@ -24,17 +24,23 @@ export function renderSnippet(text, context = {}) {
24
24
  export function usesSelection(text) {
25
25
  return /\{\{\s*selection\s*\}\}/iu.test(text);
26
26
  }
27
- /** Expands a snippet at the caret. */
28
- export async function expandSnippet(host, snippet, options = {}) {
29
- // The selection is read only when needed: extracting its text has an engine cost.
27
+ /**
28
+ * Renders a snippet against the live document (reads the selection only
29
+ * when the snippet needs it). Split from the insertion so a caller that
30
+ * must know what text actually landed — e.g. a recorder — can.
31
+ */
32
+ export async function renderSnippetForHost(host, snippet, options = {}) {
30
33
  const selectionText = usesSelection(snippet.text)
31
34
  ? (await host.getSelection({ includeText: true })).text
32
35
  : undefined;
33
- const rendered = renderSnippet(snippet.text, {
36
+ return renderSnippet(snippet.text, {
34
37
  variables: options.variables,
35
38
  selectionText,
36
39
  now: options.now,
37
40
  locale: options.locale,
38
41
  });
39
- return host.insertText(rendered);
42
+ }
43
+ /** Expands a snippet at the caret. */
44
+ export async function expandSnippet(host, snippet, options = {}) {
45
+ return host.insertText(await renderSnippetForHost(host, snippet, options));
40
46
  }
package/dist/storage.d.ts CHANGED
@@ -13,7 +13,12 @@ export interface PersistedMacroState {
13
13
  export interface MacroStorage {
14
14
  /** `null` when there is no saved state or the saved state is unreadable. */
15
15
  load(): PersistedMacroState | null;
16
- save(state: PersistedMacroState): void;
16
+ /**
17
+ * Persists the state. Returns whether it actually landed — quota and
18
+ * serialization failures come back as `false`, never as a throw, so the
19
+ * caller can refuse to adopt an in-memory change its storage rejected.
20
+ */
21
+ save(state: PersistedMacroState): boolean;
17
22
  }
18
23
  export declare function emptyState(): PersistedMacroState;
19
24
  /**
@@ -33,13 +38,18 @@ export declare const IMPORT_LIMITS: {
33
38
  /** Snippet text and single recorded insert-text step. */
34
39
  readonly maxTextLength: 100000;
35
40
  readonly maxSourceLength: 200000;
41
+ /** A recorded command payload, serialized. Engine payloads are small config objects. */
42
+ readonly maxPayloadLength: 10000;
36
43
  };
37
44
  /**
38
- * Whether a state object passes the exact validation the loader applies.
39
- * The save paths hold this as an invariant: state that would be rejected on
40
- * the next load must never be persisted otherwise a single oversized save
41
- * silently wipes everything at the next startup.
45
+ * Serializes state iff it passes the exact validation the loader applies
46
+ * including the whole-file size cap the loader enforces on read. `null`
47
+ * otherwise. The save paths hold this as an invariant: state that would be
48
+ * rejected on the next load must never be persisted — otherwise a single
49
+ * oversized save silently wipes everything at the next startup.
42
50
  */
51
+ export declare function serializePersistable(value: unknown): string | null;
52
+ /** Whether `serializePersistable` would accept the state. */
43
53
  export declare function isPersistableState(value: unknown): value is PersistedMacroState;
44
54
  /**
45
55
  * Parses saved/imported state. `null` on any unexpected shape, oversized
package/dist/storage.js CHANGED
@@ -18,6 +18,8 @@ export const IMPORT_LIMITS = {
18
18
  /** Snippet text and single recorded insert-text step. */
19
19
  maxTextLength: 100_000,
20
20
  maxSourceLength: 200_000,
21
+ /** A recorded command payload, serialized. Engine payloads are small config objects. */
22
+ maxPayloadLength: 10_000,
21
23
  };
22
24
  function boundedString(value, maxLength, allowEmpty = false) {
23
25
  return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0);
@@ -25,14 +27,26 @@ function boundedString(value, maxLength, allowEmpty = false) {
25
27
  function optionalBoundedString(value, maxLength) {
26
28
  return value === undefined || boundedString(value, maxLength);
27
29
  }
30
+ /** Whether a recorded payload is JSON-clean and bounded. Opaque otherwise — but not unlimited. */
31
+ function isValidPayload(payload) {
32
+ if (payload === undefined)
33
+ return true;
34
+ let json;
35
+ try {
36
+ json = JSON.stringify(payload);
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ return typeof json === 'string' && json.length <= IMPORT_LIMITS.maxPayloadLength;
42
+ }
28
43
  function isValidStep(value) {
29
44
  if (typeof value !== 'object' || value === null)
30
45
  return false;
31
46
  const step = value;
32
47
  switch (step.type) {
33
48
  case 'command':
34
- // The payload is opaque engine data; the id is what replay dispatches on.
35
- return boundedString(step.id, IMPORT_LIMITS.maxNameLength);
49
+ return boundedString(step.id, IMPORT_LIMITS.maxNameLength) && isValidPayload(step.payload);
36
50
  case 'insert-text':
37
51
  return boundedString(step.text, IMPORT_LIMITS.maxTextLength, true);
38
52
  case 'insert-paragraph':
@@ -92,13 +106,27 @@ function isValidState(value) {
92
106
  state.snippets.every(isValidSnippet));
93
107
  }
94
108
  /**
95
- * Whether a state object passes the exact validation the loader applies.
96
- * The save paths hold this as an invariant: state that would be rejected on
97
- * the next load must never be persisted otherwise a single oversized save
98
- * silently wipes everything at the next startup.
109
+ * Serializes state iff it passes the exact validation the loader applies
110
+ * including the whole-file size cap the loader enforces on read. `null`
111
+ * otherwise. The save paths hold this as an invariant: state that would be
112
+ * rejected on the next load must never be persisted — otherwise a single
113
+ * oversized save silently wipes everything at the next startup.
99
114
  */
115
+ export function serializePersistable(value) {
116
+ if (!isValidState(value))
117
+ return null;
118
+ let json;
119
+ try {
120
+ json = JSON.stringify(value);
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ return json.length <= IMPORT_LIMITS.maxJsonLength ? json : null;
126
+ }
127
+ /** Whether `serializePersistable` would accept the state. */
100
128
  export function isPersistableState(value) {
101
- return isValidState(value);
129
+ return serializePersistable(value) !== null;
102
130
  }
103
131
  /**
104
132
  * Parses saved/imported state. `null` on any unexpected shape, oversized
@@ -142,10 +170,17 @@ export function createLocalStorage(key = DEFAULT_STORAGE_KEY, storage) {
142
170
  },
143
171
  save(state) {
144
172
  try {
145
- backing()?.setItem(key, JSON.stringify(state));
173
+ const store = backing();
174
+ if (!store)
175
+ return false;
176
+ store.setItem(key, JSON.stringify(state));
177
+ return true;
146
178
  }
147
179
  catch (error) {
180
+ // Quota or serialization — reported, not swallowed: the caller must
181
+ // know the change did not land.
148
182
  console.warn('[superdoc-macros] saving macros failed', error);
183
+ return false;
149
184
  }
150
185
  },
151
186
  };
@@ -157,6 +192,7 @@ export function createMemoryStorage() {
157
192
  load: () => (saved ? JSON.parse(JSON.stringify(saved)) : null),
158
193
  save(state) {
159
194
  saved = JSON.parse(JSON.stringify(state));
195
+ return true;
160
196
  },
161
197
  };
162
198
  }
package/dist/types.d.ts CHANGED
@@ -38,6 +38,15 @@ export type TextInputEvent = {
38
38
  kind: 'delete-backward';
39
39
  } | {
40
40
  kind: 'delete-forward';
41
+ }
42
+ /**
43
+ * The caret moved by pointer or navigation keys. Not an edit — the
44
+ * recorder records no step — but both consumers depend on it: auto-text
45
+ * resets its typed-word buffer (the buffer no longer describes what sits
46
+ * before the caret), and the recorder stops coalescing across it.
47
+ */
48
+ | {
49
+ kind: 'caret-moved';
41
50
  };
42
51
  /**
43
52
  * What the toolkit needs from the editor. The SuperDoc v2 implementation is
@@ -62,6 +71,13 @@ export interface MacroHost {
62
71
  getSelection(options?: {
63
72
  includeText?: boolean;
64
73
  }): Promise<SelectionSnapshot>;
74
+ /**
75
+ * The `count` characters immediately before the caret, or `null` when the
76
+ * host cannot tell. Auto-text verifies the document actually holds the
77
+ * trigger word before deleting it — the buffer alone can lie after a
78
+ * caret move the host failed to report.
79
+ */
80
+ getTextBefore?(count: number): Promise<string | null>;
65
81
  /** Replaces every occurrence of `query` with `replacement`. Returns how many were replaced. */
66
82
  replaceAll(query: string, replacement: string): Promise<{
67
83
  ok: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superdoc-macros",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",