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 +2 -2
- package/dist/host/superdoc-host.js +61 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/manager.d.ts +46 -3
- package/dist/manager.js +202 -88
- package/dist/messages.d.ts +3 -0
- package/dist/messages.js +6 -0
- package/dist/recorder/recorder.d.ts +43 -18
- package/dist/recorder/recorder.js +108 -13
- package/dist/shortcuts.d.ts +21 -0
- package/dist/shortcuts.js +38 -1
- package/dist/snippets/autotext.js +39 -8
- package/dist/snippets/snippets.d.ts +6 -0
- package/dist/snippets/snippets.js +11 -5
- package/dist/storage.d.ts +15 -5
- package/dist/storage.js +44 -8
- package/dist/types.d.ts +23 -0
- package/package.json +1 -1
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:
|
|
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
|
-
/**
|
|
101
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
86
|
-
if (
|
|
87
|
-
return { ok: false, message:
|
|
88
|
-
|
|
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.
|
|
138
|
-
|
|
139
|
-
|
|
184
|
+
return this.commit((draft) => {
|
|
185
|
+
this.upsert(draft.scripts, script);
|
|
186
|
+
return script;
|
|
187
|
+
});
|
|
140
188
|
}
|
|
141
189
|
removeScript(id) {
|
|
142
|
-
this.
|
|
143
|
-
|
|
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
|
-
/**
|
|
183
|
-
|
|
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
|
|
188
|
-
|
|
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.
|
|
199
|
-
|
|
200
|
-
|
|
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.
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
recording
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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({
|
|
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.
|
|
269
|
-
|
|
270
|
-
|
|
355
|
+
return this.commit((draft) => {
|
|
356
|
+
this.upsert(draft.snippets, snippet);
|
|
357
|
+
return snippet;
|
|
358
|
+
});
|
|
271
359
|
}
|
|
272
360
|
removeSnippet(id) {
|
|
273
|
-
this.
|
|
274
|
-
|
|
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
|
|
281
|
-
|
|
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
|
-
|
|
365
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
package/dist/messages.d.ts
CHANGED
|
@@ -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;
|