superdoc-macros 0.4.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/dist/messages.js CHANGED
@@ -33,7 +33,15 @@ export const ENGLISH_MESSAGES = {
33
33
  snippetNotFound: 'Snippet not found',
34
34
  cannotRunWhileRecording: 'Cannot run a macro while recording',
35
35
  anotherMacroRunning: 'Another macro is still running',
36
+ scriptsDisabled: 'Scripted macros are disabled',
36
37
  invalidImport: 'The file is not a valid macro export',
38
+ importRejectedShortcut: (itemName, detail) => `Import rejected: the shortcut of "${itemName}" is not acceptable — ${detail}`,
39
+ importTooLarge: 'Import rejected: the merged result exceeds the item limits',
40
+ tooManyItems: 'The list is full — delete items before adding new ones',
41
+ nameRequired: 'A name is required',
42
+ fieldTooLong: (field, max) => `${field} is too long (limit: ${max} characters)`,
43
+ saveFailed: 'Saving failed — the change was not applied',
44
+ recordingTooLarge: 'The recording is too large to save',
37
45
  shortcutInvalid: 'Invalid shortcut — use a form like Ctrl+Alt+M',
38
46
  shortcutNeedsModifier: 'A shortcut must include Ctrl, Alt or Meta',
39
47
  shortcutReserved: 'This shortcut is reserved by the editor',
@@ -64,7 +72,15 @@ export const HEBREW_MESSAGES = {
64
72
  snippetNotFound: 'הקטע לא נמצא',
65
73
  cannotRunWhileRecording: 'אי אפשר להריץ מאקרו בזמן הקלטה',
66
74
  anotherMacroRunning: 'מאקרו אחר עדיין רץ',
75
+ scriptsDisabled: 'מאקרו כתובים מושבתים',
67
76
  invalidImport: 'הקובץ אינו ייצוא מאקרו תקין',
77
+ importRejectedShortcut: (itemName, detail) => `הייבוא נדחה: הקיצור של "${itemName}" אינו קביל — ${detail}`,
78
+ importTooLarge: 'הייבוא נדחה: התוצאה הממוזגת חורגת מתקרת הפריטים',
79
+ tooManyItems: 'הרשימה מלאה — יש למחוק פריטים לפני הוספה',
80
+ nameRequired: 'חובה לתת שם',
81
+ fieldTooLong: (field, max) => `${field} ארוך מדי (התקרה: ${max} תווים)`,
82
+ saveFailed: 'השמירה נכשלה — השינוי לא הוחל',
83
+ recordingTooLarge: 'ההקלטה גדולה מכדי להישמר',
68
84
  shortcutInvalid: 'קיצור לא תקין — הצורה הנדרשת היא למשל Ctrl+Alt+M',
69
85
  shortcutNeedsModifier: 'קיצור חייב לכלול Ctrl,‏ Alt או Meta',
70
86
  shortcutReserved: 'הקיצור הזה שמור לעורך',
@@ -1,41 +1,66 @@
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. */
18
4
  shouldRecordCommand?: (id: string) => boolean;
19
5
  /** Step cap per recording, against a recording left running by mistake. */
20
6
  maxSteps?: number;
7
+ /**
8
+ * Called when the step cap stops the recording. The steps are kept —
9
+ * `stop()` still returns them — but listening has ceased, and a UI that
10
+ * shows "recording" must be told, or its indicator would keep promising a
11
+ * recording that is no longer happening.
12
+ */
13
+ onAutoStop?: () => void;
21
14
  }
22
15
  export declare class MacroRecorder {
23
16
  private readonly host;
24
17
  private readonly shouldRecordCommand;
25
18
  private readonly maxSteps;
19
+ private readonly onAutoStop?;
26
20
  private steps;
27
21
  private disposers;
28
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;
29
29
  constructor(host: MacroHost, options?: RecorderOptions);
30
30
  get recording(): boolean;
31
31
  get stepCount(): number;
32
32
  start(): void;
33
- /** Stops and returns the steps. Empty when nothing was recorded. */
33
+ /**
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.
37
+ */
34
38
  stop(): MacroStep[];
35
39
  /** Stops and discards whatever was recorded. */
36
40
  cancel(): void;
41
+ /**
42
+ * Rewrites the recorded tail after an auto-text expansion: the user typed
43
+ * a trigger word plus the expansion character, but what the document now
44
+ * holds is the expanded text — a replay of the raw keystrokes would
45
+ * diverge (and would depend on auto-text being active at replay time).
46
+ * The trailing `consumed` characters are removed from the recorded
47
+ * insert-text steps and the expanded text is recorded in their place.
48
+ *
49
+ * If the tail does not hold `consumed` plain characters (a command landed
50
+ * mid-word, or the recording started mid-trigger), the rewrite is skipped
51
+ * and the raw keystrokes stay — a truthful raw recording beats a guessed
52
+ * edit of steps that do not match.
53
+ */
54
+ applyAutoTextExpansion(consumed: number, replacement: string): void;
37
55
  private teardown;
38
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;
39
64
  private recordCommand;
40
65
  private recordTextInput;
41
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) {
@@ -7,13 +22,21 @@ export class MacroRecorder {
7
22
  host;
8
23
  shouldRecordCommand;
9
24
  maxSteps;
25
+ onAutoStop;
10
26
  steps = [];
11
27
  disposers = [];
12
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;
13
35
  constructor(host, options = {}) {
14
36
  this.host = host;
15
37
  this.shouldRecordCommand = options.shouldRecordCommand ?? defaultShouldRecord;
16
38
  this.maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
39
+ this.onAutoStop = options.onAutoStop;
17
40
  }
18
41
  get recording() {
19
42
  return this.active;
@@ -31,10 +54,12 @@ export class MacroRecorder {
31
54
  this.host.onTextInput((event) => this.recordTextInput(event)),
32
55
  ];
33
56
  }
34
- /** Stops and returns the steps. Empty when nothing was recorded. */
57
+ /**
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.
61
+ */
35
62
  stop() {
36
- if (!this.active)
37
- return [];
38
63
  this.teardown();
39
64
  const recorded = this.steps;
40
65
  this.steps = [];
@@ -42,34 +67,108 @@ export class MacroRecorder {
42
67
  }
43
68
  /** Stops and discards whatever was recorded. */
44
69
  cancel() {
45
- if (!this.active)
46
- return;
47
70
  this.teardown();
48
71
  this.steps = [];
49
72
  }
73
+ /**
74
+ * Rewrites the recorded tail after an auto-text expansion: the user typed
75
+ * a trigger word plus the expansion character, but what the document now
76
+ * holds is the expanded text — a replay of the raw keystrokes would
77
+ * diverge (and would depend on auto-text being active at replay time).
78
+ * The trailing `consumed` characters are removed from the recorded
79
+ * insert-text steps and the expanded text is recorded in their place.
80
+ *
81
+ * If the tail does not hold `consumed` plain characters (a command landed
82
+ * mid-word, or the recording started mid-trigger), the rewrite is skipped
83
+ * and the raw keystrokes stay — a truthful raw recording beats a guessed
84
+ * edit of steps that do not match.
85
+ */
86
+ applyAutoTextExpansion(consumed, replacement) {
87
+ if (!this.active || consumed <= 0)
88
+ return;
89
+ // Verify the tail is entirely typed text before touching anything.
90
+ let remaining = consumed;
91
+ let index = this.steps.length - 1;
92
+ while (remaining > 0 && index >= 0) {
93
+ const step = this.steps[index];
94
+ if (step?.type !== 'insert-text')
95
+ return;
96
+ remaining -= step.text.length;
97
+ index -= 1;
98
+ }
99
+ if (remaining > 0)
100
+ return;
101
+ let toRemove = consumed;
102
+ while (toRemove > 0) {
103
+ const last = this.steps[this.steps.length - 1];
104
+ if (last?.type !== 'insert-text')
105
+ return; // unreachable after the check above
106
+ if (last.text.length > toRemove) {
107
+ last.text = last.text.slice(0, -toRemove);
108
+ break;
109
+ }
110
+ toRemove -= last.text.length;
111
+ this.steps.pop();
112
+ }
113
+ this.recordTextInput({ kind: 'insert-text', text: replacement });
114
+ }
50
115
  teardown() {
51
116
  this.active = false;
52
117
  for (const dispose of this.disposers.splice(0))
53
118
  dispose();
54
119
  }
55
120
  push(step) {
121
+ this.steps.push(step);
56
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.
57
125
  this.teardown();
58
- return;
126
+ this.onAutoStop?.();
59
127
  }
60
- this.steps.push(step);
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 });
61
139
  }
62
140
  recordCommand(id, payload) {
63
141
  if (!this.shouldRecordCommand(id))
64
142
  return;
65
- 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) });
66
162
  }
67
163
  recordTextInput(event) {
164
+ const interrupted = this.tailInterrupted;
165
+ this.tailInterrupted = false;
68
166
  const last = this.steps[this.steps.length - 1];
69
167
  switch (event.kind) {
70
168
  case 'insert-text': {
71
- // Consecutive keystrokes coalesce into one step — more readable, faster to replay.
72
- 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') {
73
172
  last.text += event.text;
74
173
  return;
75
174
  }
@@ -80,7 +179,7 @@ export class MacroRecorder {
80
179
  this.push({ type: 'insert-paragraph' });
81
180
  return;
82
181
  case 'delete-backward': {
83
- if (last?.type === 'delete-backward') {
182
+ if (!interrupted && last?.type === 'delete-backward') {
84
183
  last.count += 1;
85
184
  return;
86
185
  }
@@ -88,13 +187,18 @@ export class MacroRecorder {
88
187
  return;
89
188
  }
90
189
  case 'delete-forward': {
91
- if (last?.type === 'delete-forward') {
190
+ if (!interrupted && last?.type === 'delete-forward') {
92
191
  last.count += 1;
93
192
  return;
94
193
  }
95
194
  this.push({ type: 'delete-forward', count: 1 });
96
195
  return;
97
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;
98
202
  }
99
203
  }
100
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))
@@ -15,13 +15,22 @@
15
15
  * better than an expansion that deletes the wrong text.
16
16
  */
17
17
  import type { MacroHost, Snippet } from '../types.js';
18
+ /** What an expansion actually did — what a recorder needs to stay truthful. */
19
+ export interface AutoTextExpansion {
20
+ /** The trigger word the user typed. */
21
+ trigger: string;
22
+ /** The character that fired the expansion (and was restored at the end). */
23
+ expandChar: string;
24
+ /** The rendered snippet text that replaced the trigger. */
25
+ rendered: string;
26
+ }
18
27
  export interface AutoTextOptions {
19
28
  /** The expansion characters. Default: space only. */
20
29
  expandOn?: readonly string[];
21
30
  /** Buffer size. A trigger word longer than this will not be recognized. */
22
31
  bufferSize?: number;
23
32
  /** Called after a successful expansion. */
24
- onExpand?: (snippet: Snippet) => void;
33
+ onExpand?: (snippet: Snippet, expansion: AutoTextExpansion) => void;
25
34
  /** Called when an expansion failed (e.g. a read-only document). */
26
35
  onError?: (message: string) => void;
27
36
  }
@@ -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;
@@ -94,7 +108,7 @@ export class AutoText {
94
108
  this.onError?.(inserted.message);
95
109
  return;
96
110
  }
97
- this.onExpand?.(snippet);
111
+ this.onExpand?.(snippet, { trigger, expandChar, rendered });
98
112
  }
99
113
  finally {
100
114
  this.busy = false;
@@ -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,7 +38,19 @@ 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
  };
44
+ /**
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.
50
+ */
51
+ export declare function serializePersistable(value: unknown): string | null;
52
+ /** Whether `serializePersistable` would accept the state. */
53
+ export declare function isPersistableState(value: unknown): value is PersistedMacroState;
37
54
  /**
38
55
  * Parses saved/imported state. `null` on any unexpected shape, oversized
39
56
  * field or oversized file — never throws, never partially accepts: one
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':
@@ -91,6 +105,29 @@ function isValidState(value) {
91
105
  state.snippets.length <= IMPORT_LIMITS.maxItems &&
92
106
  state.snippets.every(isValidSnippet));
93
107
  }
108
+ /**
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.
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. */
128
+ export function isPersistableState(value) {
129
+ return serializePersistable(value) !== null;
130
+ }
94
131
  /**
95
132
  * Parses saved/imported state. `null` on any unexpected shape, oversized
96
133
  * field or oversized file — never throws, never partially accepts: one
@@ -133,10 +170,17 @@ export function createLocalStorage(key = DEFAULT_STORAGE_KEY, storage) {
133
170
  },
134
171
  save(state) {
135
172
  try {
136
- 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;
137
178
  }
138
179
  catch (error) {
180
+ // Quota or serialization — reported, not swallowed: the caller must
181
+ // know the change did not land.
139
182
  console.warn('[superdoc-macros] saving macros failed', error);
183
+ return false;
140
184
  }
141
185
  },
142
186
  };
@@ -148,6 +192,7 @@ export function createMemoryStorage() {
148
192
  load: () => (saved ? JSON.parse(JSON.stringify(saved)) : null),
149
193
  save(state) {
150
194
  saved = JSON.parse(JSON.stringify(state));
195
+ return true;
151
196
  },
152
197
  };
153
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;