superdoc-macros 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/host/superdoc-host.js +22 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/manager.d.ts +35 -5
- package/dist/manager.js +119 -41
- package/dist/messages.d.ts +7 -0
- package/dist/messages.js +4 -0
- package/dist/recorder/recorder.d.ts +38 -5
- package/dist/recorder/recorder.js +69 -17
- package/dist/shortcuts.d.ts +2 -0
- package/dist/shortcuts.js +3 -2
- package/dist/snippets/autotext.js +30 -13
- package/dist/types.d.ts +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -87,7 +87,9 @@ If you must waive isolation (e.g. a CSP that blocks `srcdoc`), switch to the dir
|
|
|
87
87
|
## 2. Macro recorder
|
|
88
88
|
|
|
89
89
|
```ts
|
|
90
|
-
kit.startRecording()
|
|
90
|
+
if (!kit.startRecording()) {
|
|
91
|
+
throw new Error('A macro is running or an unsaved recording is pending');
|
|
92
|
+
}
|
|
91
93
|
// the user works normally: typing, formatting, lists...
|
|
92
94
|
const recording = kit.stopRecording('Standard intro', 'Ctrl+Alt+1');
|
|
93
95
|
|
|
@@ -97,6 +99,8 @@ await kit.replayRecording(recording.id);
|
|
|
97
99
|
|
|
98
100
|
The recorder captures **commands and typing**, not caret positions — exactly like Word's recorder: replay applies wherever the caret stands. Consecutive keystrokes coalesce into one step, `undo`/`redo` are not recorded (configurable via `RecorderOptions`), and recordings persist as clean JSON that can be exported and shared. `updateRecording({id, name?, shortcut?})` renames a recording or edits its shortcut.
|
|
99
101
|
|
|
102
|
+
Finalization is loss-aware. A command payload that cannot be stored faithfully is reported instead of silently omitted; `stopRecording()` keeps the stopped capture pending until it is saved or explicitly cancelled. A mixed capture requires `{ allowIncomplete: true }` after the host has obtained user consent, while a capture containing no replayable step is rejected as `recording-uncapturable`. Storage, capacity, and validation failures are retryable: fix the problem and call `stopRecording()` again. Starting a new recording while one is pending is rejected, preventing accidental loss.
|
|
103
|
+
|
|
100
104
|
## 3. Snippets and auto-text
|
|
101
105
|
|
|
102
106
|
```ts
|
|
@@ -327,6 +327,28 @@ export function createSuperdocHost(options) {
|
|
|
327
327
|
async getSelection(options) {
|
|
328
328
|
return (await readSelectionDetailed(options?.includeText ?? false)).snapshot;
|
|
329
329
|
},
|
|
330
|
+
async replaceTextBefore(expected, replacement) {
|
|
331
|
+
// Auto-text's atomic path: verify and replace inside one ProseMirror
|
|
332
|
+
// transaction — no window in which the trigger is deleted but the
|
|
333
|
+
// replacement not yet in.
|
|
334
|
+
const pm = view();
|
|
335
|
+
if (!pm)
|
|
336
|
+
return failed(macroMessages().deletionUnavailable, 'view-unavailable');
|
|
337
|
+
try {
|
|
338
|
+
const { from } = pm.state.selection;
|
|
339
|
+
const start = from - expected.length;
|
|
340
|
+
if (start < 0 || pm.state.doc.textBetween(start, from) !== expected) {
|
|
341
|
+
return failed(macroMessages().actionFailed, 'text-mismatch');
|
|
342
|
+
}
|
|
343
|
+
const tr = pm.state.tr;
|
|
344
|
+
tr.insertText(replacement, start, from);
|
|
345
|
+
pm.dispatch(tr);
|
|
346
|
+
return { ok: true };
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
return failed(error instanceof Error ? error.message : macroMessages().actionFailed, 'threw');
|
|
350
|
+
}
|
|
351
|
+
},
|
|
330
352
|
async getTextBefore(count) {
|
|
331
353
|
// Auto-text's verification before it deletes: the answer must reflect
|
|
332
354
|
// the live document, so `null` (unknown) is the only honest reply when
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { createMacroApi, MacroError, type MacroApi, type MacroBridge, type Scrip
|
|
|
6
6
|
export { createEvalRunner } from './scripting/eval-runner.js';
|
|
7
7
|
export { createIframeRunner, SANDBOX_BOOTSTRAP, isProtocolMessage } from './scripting/iframe-runner.js';
|
|
8
8
|
export type { MacroRunner, MacroRunOptions, MacroRunResult } from './scripting/runner.js';
|
|
9
|
-
export { MacroRecorder, replayMacro, type RecorderOptions, type ReplayOptions, type ReplayResult } from './recorder/recorder.js';
|
|
9
|
+
export { MacroRecorder, replayMacro, type RecorderOptions, type RecordingWarning, type ReplayOptions, type ReplayResult, } from './recorder/recorder.js';
|
|
10
10
|
export { renderSnippet, renderSnippetForHost, expandSnippet, usesSelection, type ExpandOptions, type RenderContext, } from './snippets/snippets.js';
|
|
11
11
|
export { AutoText, type AutoTextOptions, type AutoTextExpansion } from './snippets/autotext.js';
|
|
12
12
|
export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, isBindableKey, codesForKey, type ParsedShortcut, type ShortcutBinding, type ShortcutTarget, } from './shortcuts.js';
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ export { createSuperdocHost } from './host/superdoc-host.js';
|
|
|
4
4
|
export { createMacroApi, MacroError } from './scripting/macro-api.js';
|
|
5
5
|
export { createEvalRunner } from './scripting/eval-runner.js';
|
|
6
6
|
export { createIframeRunner, SANDBOX_BOOTSTRAP, isProtocolMessage } from './scripting/iframe-runner.js';
|
|
7
|
-
export { MacroRecorder, replayMacro } from './recorder/recorder.js';
|
|
7
|
+
export { MacroRecorder, replayMacro, } from './recorder/recorder.js';
|
|
8
8
|
export { renderSnippet, renderSnippetForHost, expandSnippet, usesSelection, } from './snippets/snippets.js';
|
|
9
9
|
export { AutoText } from './snippets/autotext.js';
|
|
10
10
|
export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, isBindableKey, codesForKey, } from './shortcuts.js';
|
package/dist/manager.d.ts
CHANGED
|
@@ -80,6 +80,18 @@ export declare class MacroKit {
|
|
|
80
80
|
* UIs call it directly to show the message before saving.
|
|
81
81
|
*/
|
|
82
82
|
validateShortcut(shortcut: string | undefined, excludeId?: string): ShortcutValidation;
|
|
83
|
+
/**
|
|
84
|
+
* The context-free binding rules, in one place: parseable, real modifier,
|
|
85
|
+
* physically-mappable key, not host-reserved. Manual save, import and
|
|
86
|
+
* legacy-state load all judge a shortcut by exactly this — a file or an
|
|
87
|
+
* old store must not smuggle in what typing cannot.
|
|
88
|
+
*/
|
|
89
|
+
private bindingIssue;
|
|
90
|
+
/**
|
|
91
|
+
* Strips loaded shortcuts that today's rules reject — see the constructor.
|
|
92
|
+
* Items survive; only their bindings are dropped.
|
|
93
|
+
*/
|
|
94
|
+
private sanitizeLoadedShortcuts;
|
|
83
95
|
private findShortcutOwner;
|
|
84
96
|
/** Throws a MacroError when the shortcut is unacceptable. The save paths call this. */
|
|
85
97
|
private requireValidShortcut;
|
|
@@ -96,15 +108,33 @@ export declare class MacroKit {
|
|
|
96
108
|
runSource(source: string): Promise<MacroRunResult>;
|
|
97
109
|
get isRecording(): boolean;
|
|
98
110
|
get recordedStepCount(): number;
|
|
99
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Starts a fresh recording. Returns false instead of overwriting an active
|
|
113
|
+
* or stopped-but-unsaved capture, or while another macro is running.
|
|
114
|
+
*/
|
|
115
|
+
startRecording(): boolean;
|
|
100
116
|
/**
|
|
101
117
|
* Stops and saves. `null` when no step was recorded — there is nothing to
|
|
102
|
-
* save.
|
|
103
|
-
*
|
|
104
|
-
*
|
|
118
|
+
* save.
|
|
119
|
+
*
|
|
120
|
+
* Throws — with the stopped recording **retained for retry** (call again;
|
|
121
|
+
* `cancelRecording` is the explicit way to drop it) — when:
|
|
122
|
+
* - `recording-uncapturable`: every action was uncapturable, so even an
|
|
123
|
+
* explicitly incomplete save would create an empty macro.
|
|
124
|
+
* - `recording-incomplete`: some actions could not be captured (e.g. an
|
|
125
|
+
* inserted image, whose payload is the whole file). Saving that as-is
|
|
126
|
+
* would present a macro that replays less than what the user did, so it
|
|
127
|
+
* needs an explicit `allowIncomplete: true` from a confirming UI.
|
|
128
|
+
* - `recording-too-large`: even split, the steps exceed the loader's cap —
|
|
129
|
+
* a silently partial macro is not produced.
|
|
130
|
+
* - a persistence failure (quota, oversized state).
|
|
105
131
|
*/
|
|
106
|
-
stopRecording(name: string, shortcut?: string
|
|
132
|
+
stopRecording(name: string, shortcut?: string, options?: {
|
|
133
|
+
allowIncomplete?: boolean;
|
|
134
|
+
}): RecordedMacro | null;
|
|
107
135
|
cancelRecording(): void;
|
|
136
|
+
/** Whether a stopped recording awaits a retried save (see stopRecording). */
|
|
137
|
+
get hasPendingRecording(): boolean;
|
|
108
138
|
listRecordings(): readonly RecordedMacro[];
|
|
109
139
|
removeRecording(id: string): void;
|
|
110
140
|
/** Renames a recording or edits its shortcut. `null` when the recording was not found. */
|
package/dist/manager.js
CHANGED
|
@@ -49,7 +49,20 @@ export class MacroKit {
|
|
|
49
49
|
this.runner =
|
|
50
50
|
runner === 'iframe' ? createIframeRunner() : runner === 'eval' ? createEvalRunner() : runner;
|
|
51
51
|
this.scriptsEnabled = options.scriptsEnabled ?? true;
|
|
52
|
+
const reserved = new Set();
|
|
53
|
+
for (const shortcut of options.reservedShortcuts ?? []) {
|
|
54
|
+
const parsed = parseShortcut(shortcut);
|
|
55
|
+
if (parsed)
|
|
56
|
+
for (const signature of shortcutSignatures(parsed))
|
|
57
|
+
reserved.add(signature);
|
|
58
|
+
}
|
|
59
|
+
this.reservedSignatures = reserved;
|
|
52
60
|
this.state = this.storage.load() ?? emptyState();
|
|
61
|
+
// Loaded state passed the *structural* validation only. The semantic
|
|
62
|
+
// shortcut rules (bindable key, reserved list, duplicates) may have
|
|
63
|
+
// tightened since it was saved — a stale binding must not stay armed.
|
|
64
|
+
// Offending shortcuts are stripped, their items kept.
|
|
65
|
+
this.sanitizeLoadedShortcuts();
|
|
53
66
|
this.recorder = new MacroRecorder(this.host, { onAutoStop: options.onRecordingAutoStop });
|
|
54
67
|
this.autoText = new AutoText(this.host, () => this.state.snippets, {
|
|
55
68
|
...options.autoText,
|
|
@@ -61,14 +74,6 @@ export class MacroKit {
|
|
|
61
74
|
options.autoText?.onExpand?.(snippet, expansion);
|
|
62
75
|
},
|
|
63
76
|
});
|
|
64
|
-
const reserved = new Set();
|
|
65
|
-
for (const shortcut of options.reservedShortcuts ?? []) {
|
|
66
|
-
const parsed = parseShortcut(shortcut);
|
|
67
|
-
if (parsed)
|
|
68
|
-
for (const signature of shortcutSignatures(parsed))
|
|
69
|
-
reserved.add(signature);
|
|
70
|
-
}
|
|
71
|
-
this.reservedSignatures = reserved;
|
|
72
77
|
}
|
|
73
78
|
/* ---------- Shortcut validation ---------- */
|
|
74
79
|
/**
|
|
@@ -82,23 +87,61 @@ export class MacroKit {
|
|
|
82
87
|
const trimmed = shortcut?.trim();
|
|
83
88
|
if (!trimmed)
|
|
84
89
|
return { ok: true };
|
|
85
|
-
const
|
|
90
|
+
const issue = this.bindingIssue(trimmed);
|
|
91
|
+
if (issue)
|
|
92
|
+
return { ok: false, message: issue.message };
|
|
93
|
+
const owner = this.findShortcutOwner(shortcutSignatures(parseShortcut(trimmed)), excludeId);
|
|
94
|
+
if (owner)
|
|
95
|
+
return { ok: false, message: macroMessages().shortcutTaken(owner) };
|
|
96
|
+
return { ok: true };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The context-free binding rules, in one place: parseable, real modifier,
|
|
100
|
+
* physically-mappable key, not host-reserved. Manual save, import and
|
|
101
|
+
* legacy-state load all judge a shortcut by exactly this — a file or an
|
|
102
|
+
* old store must not smuggle in what typing cannot.
|
|
103
|
+
*/
|
|
104
|
+
bindingIssue(shortcut) {
|
|
105
|
+
const parsed = parseShortcut(shortcut);
|
|
86
106
|
if (!parsed)
|
|
87
|
-
return {
|
|
107
|
+
return { message: macroMessages().shortcutInvalid };
|
|
88
108
|
if (!hasBindingModifier(parsed))
|
|
89
|
-
return {
|
|
109
|
+
return { message: macroMessages().shortcutNeedsModifier };
|
|
90
110
|
// Only keys with a physical-code mapping (letters, digits, F-keys):
|
|
91
111
|
// matching is by event.code, so it survives a Hebrew keyboard layout.
|
|
92
112
|
if (!isBindableKey(parsed))
|
|
93
|
-
return {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
113
|
+
return { message: macroMessages().shortcutInvalid };
|
|
114
|
+
if (shortcutSignatures(parsed).some((signature) => this.reservedSignatures.has(signature))) {
|
|
115
|
+
return { message: macroMessages().shortcutReserved };
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Strips loaded shortcuts that today's rules reject — see the constructor.
|
|
121
|
+
* Items survive; only their bindings are dropped.
|
|
122
|
+
*/
|
|
123
|
+
sanitizeLoadedShortcuts() {
|
|
124
|
+
const seen = new Set();
|
|
125
|
+
const items = [
|
|
126
|
+
...this.state.scripts,
|
|
127
|
+
...this.state.recordings,
|
|
128
|
+
...this.state.snippets,
|
|
129
|
+
];
|
|
130
|
+
for (const item of items) {
|
|
131
|
+
if (!item.shortcut)
|
|
132
|
+
continue;
|
|
133
|
+
if (this.bindingIssue(item.shortcut)) {
|
|
134
|
+
delete item.shortcut;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const signatures = shortcutSignatures(parseShortcut(item.shortcut));
|
|
138
|
+
if (signatures.some((signature) => seen.has(signature))) {
|
|
139
|
+
delete item.shortcut;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
for (const signature of signatures)
|
|
143
|
+
seen.add(signature);
|
|
97
144
|
}
|
|
98
|
-
const owner = this.findShortcutOwner(signatures, excludeId);
|
|
99
|
-
if (owner)
|
|
100
|
-
return { ok: false, message: macroMessages().shortcutTaken(owner) };
|
|
101
|
-
return { ok: true };
|
|
102
145
|
}
|
|
103
146
|
findShortcutOwner(signatures, excludeId) {
|
|
104
147
|
const items = [
|
|
@@ -180,26 +223,56 @@ export class MacroKit {
|
|
|
180
223
|
get recordedStepCount() {
|
|
181
224
|
return this.recorder.stepCount;
|
|
182
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Starts a fresh recording. Returns false instead of overwriting an active
|
|
228
|
+
* or stopped-but-unsaved capture, or while another macro is running.
|
|
229
|
+
*/
|
|
183
230
|
startRecording() {
|
|
184
|
-
if (this.running)
|
|
185
|
-
return;
|
|
231
|
+
if (this.running || this.recorder.recording || this.recorder.hasPending)
|
|
232
|
+
return false;
|
|
186
233
|
this.recorder.start();
|
|
234
|
+
return this.recorder.recording;
|
|
187
235
|
}
|
|
188
236
|
/**
|
|
189
237
|
* Stops and saves. `null` when no step was recorded — there is nothing to
|
|
190
|
-
* save.
|
|
191
|
-
*
|
|
192
|
-
*
|
|
238
|
+
* save.
|
|
239
|
+
*
|
|
240
|
+
* Throws — with the stopped recording **retained for retry** (call again;
|
|
241
|
+
* `cancelRecording` is the explicit way to drop it) — when:
|
|
242
|
+
* - `recording-uncapturable`: every action was uncapturable, so even an
|
|
243
|
+
* explicitly incomplete save would create an empty macro.
|
|
244
|
+
* - `recording-incomplete`: some actions could not be captured (e.g. an
|
|
245
|
+
* inserted image, whose payload is the whole file). Saving that as-is
|
|
246
|
+
* would present a macro that replays less than what the user did, so it
|
|
247
|
+
* needs an explicit `allowIncomplete: true` from a confirming UI.
|
|
248
|
+
* - `recording-too-large`: even split, the steps exceed the loader's cap —
|
|
249
|
+
* a silently partial macro is not produced.
|
|
250
|
+
* - a persistence failure (quota, oversized state).
|
|
193
251
|
*/
|
|
194
|
-
stopRecording(name, shortcut) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
252
|
+
stopRecording(name, shortcut, options = {}) {
|
|
253
|
+
// Stop first. Every failure below must leave the recorder inactive and
|
|
254
|
+
// its snapshot retryable. Checking capacity before stop() used to leave a
|
|
255
|
+
// manual recording active behind a stopped UI, and could lose an
|
|
256
|
+
// auto-stopped recording on the next start.
|
|
257
|
+
const pending = this.recorder.stop();
|
|
258
|
+
const { steps, truncated } = splitOversizedSteps(pending.steps);
|
|
199
259
|
if (truncated)
|
|
200
260
|
throw new MacroError(macroMessages().recordingTooLarge, 'recording-too-large');
|
|
201
|
-
|
|
261
|
+
const commandIds = [...new Set(pending.warnings.map((warning) => warning.commandId))].join(', ');
|
|
262
|
+
if (pending.warnings.length > 0 && steps.length === 0) {
|
|
263
|
+
const messages = macroMessages();
|
|
264
|
+
throw new MacroError(messages.recordingUncapturable?.(commandIds) ?? messages.recordingIncomplete(commandIds), 'recording-uncapturable');
|
|
265
|
+
}
|
|
266
|
+
if (steps.length === 0) {
|
|
267
|
+
this.recorder.discard();
|
|
202
268
|
return null;
|
|
269
|
+
}
|
|
270
|
+
this.requireValidShortcut(shortcut);
|
|
271
|
+
this.requireItemLimits({ name, shortcut });
|
|
272
|
+
this.requireRoom(this.state.recordings);
|
|
273
|
+
if (pending.warnings.length > 0 && !options.allowIncomplete) {
|
|
274
|
+
throw new MacroError(macroMessages().recordingIncomplete(commandIds), 'recording-incomplete');
|
|
275
|
+
}
|
|
203
276
|
const recording = {
|
|
204
277
|
version: 1,
|
|
205
278
|
id: newId(),
|
|
@@ -208,14 +281,22 @@ export class MacroKit {
|
|
|
208
281
|
...(shortcut ? { shortcut } : {}),
|
|
209
282
|
steps,
|
|
210
283
|
};
|
|
211
|
-
|
|
284
|
+
const saved = this.commit((draft) => {
|
|
212
285
|
draft.recordings.push(recording);
|
|
213
286
|
return recording;
|
|
214
287
|
});
|
|
288
|
+
// Only after the commit landed: a failed save keeps the recording
|
|
289
|
+
// retrievable for another attempt (delete an old macro, stop again).
|
|
290
|
+
this.recorder.discard();
|
|
291
|
+
return saved;
|
|
215
292
|
}
|
|
216
293
|
cancelRecording() {
|
|
217
294
|
this.recorder.cancel();
|
|
218
295
|
}
|
|
296
|
+
/** Whether a stopped recording awaits a retried save (see stopRecording). */
|
|
297
|
+
get hasPendingRecording() {
|
|
298
|
+
return this.recorder.hasPending;
|
|
299
|
+
}
|
|
219
300
|
listRecordings() {
|
|
220
301
|
return this.state.recordings;
|
|
221
302
|
}
|
|
@@ -416,17 +497,14 @@ export class MacroKit {
|
|
|
416
497
|
for (const item of items) {
|
|
417
498
|
if (!item.shortcut)
|
|
418
499
|
continue;
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
if (
|
|
424
|
-
return { ok: false, message: macroMessages().importRejectedShortcut(item.name,
|
|
500
|
+
// The exact same context-free rules the manual save path applies —
|
|
501
|
+
// including the bindable-key restriction (an imported Ctrl+Tab must
|
|
502
|
+
// not slip in through the event.key fallback).
|
|
503
|
+
const issue = this.bindingIssue(item.shortcut);
|
|
504
|
+
if (issue) {
|
|
505
|
+
return { ok: false, message: macroMessages().importRejectedShortcut(item.name, issue.message) };
|
|
425
506
|
}
|
|
426
|
-
for (const signature of shortcutSignatures(
|
|
427
|
-
if (this.reservedSignatures.has(signature)) {
|
|
428
|
-
return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutReserved) };
|
|
429
|
-
}
|
|
507
|
+
for (const signature of shortcutSignatures(parseShortcut(item.shortcut))) {
|
|
430
508
|
const owner = seen.get(signature);
|
|
431
509
|
if (owner !== undefined) {
|
|
432
510
|
return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutTaken(owner)) };
|
package/dist/messages.d.ts
CHANGED
|
@@ -42,6 +42,13 @@ export interface MacroMessages {
|
|
|
42
42
|
fieldTooLong: (field: string, max: number) => string;
|
|
43
43
|
saveFailed: string;
|
|
44
44
|
recordingTooLarge: string;
|
|
45
|
+
recordingIncomplete: (commandIds: string) => string;
|
|
46
|
+
/**
|
|
47
|
+
* No usable step was captured; saving with allowIncomplete would create an
|
|
48
|
+
* empty macro. Optional for source compatibility with existing full locale
|
|
49
|
+
* objects compiled against 0.7.0.
|
|
50
|
+
*/
|
|
51
|
+
recordingUncapturable?: (commandIds: string) => string;
|
|
45
52
|
shortcutInvalid: string;
|
|
46
53
|
shortcutNeedsModifier: string;
|
|
47
54
|
shortcutReserved: string;
|
package/dist/messages.js
CHANGED
|
@@ -42,6 +42,8 @@ export const ENGLISH_MESSAGES = {
|
|
|
42
42
|
fieldTooLong: (field, max) => `${field} is too long (limit: ${max} characters)`,
|
|
43
43
|
saveFailed: 'Saving failed — the change was not applied',
|
|
44
44
|
recordingTooLarge: 'The recording is too large to save',
|
|
45
|
+
recordingIncomplete: (commandIds) => `The recording is missing actions that cannot be recorded (${commandIds})`,
|
|
46
|
+
recordingUncapturable: (commandIds) => `The recording contains only actions that cannot be recorded (${commandIds})`,
|
|
45
47
|
shortcutInvalid: 'Invalid shortcut — use a form like Ctrl+Alt+M',
|
|
46
48
|
shortcutNeedsModifier: 'A shortcut must include Ctrl, Alt or Meta',
|
|
47
49
|
shortcutReserved: 'This shortcut is reserved by the editor',
|
|
@@ -81,6 +83,8 @@ export const HEBREW_MESSAGES = {
|
|
|
81
83
|
fieldTooLong: (field, max) => `${field} ארוך מדי (התקרה: ${max} תווים)`,
|
|
82
84
|
saveFailed: 'השמירה נכשלה — השינוי לא הוחל',
|
|
83
85
|
recordingTooLarge: 'ההקלטה גדולה מכדי להישמר',
|
|
86
|
+
recordingIncomplete: (commandIds) => `בהקלטה חסרות פעולות שאינן ניתנות להקלטה (${commandIds})`,
|
|
87
|
+
recordingUncapturable: (commandIds) => `ההקלטה מכילה רק פעולות שאינן ניתנות להקלטה (${commandIds})`,
|
|
84
88
|
shortcutInvalid: 'קיצור לא תקין — הצורה הנדרשת היא למשל Ctrl+Alt+M',
|
|
85
89
|
shortcutNeedsModifier: 'קיצור חייב לכלול Ctrl, Alt או Meta',
|
|
86
90
|
shortcutReserved: 'הקיצור הזה שמור לעורך',
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import type { MacroHost, MacroStep } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* A user action the recorder could not capture faithfully. Surfaced, never
|
|
4
|
+
* swallowed: a macro that "saved successfully" but silently misses an image
|
|
5
|
+
* the user watched go in is a lie told at replay time.
|
|
6
|
+
*/
|
|
7
|
+
export interface RecordingWarning {
|
|
8
|
+
commandId: string;
|
|
9
|
+
reason: 'payload-too-large' | 'payload-not-serializable';
|
|
10
|
+
}
|
|
2
11
|
export interface RecorderOptions {
|
|
3
12
|
/** Command filter. The default records everything except undo/redo. */
|
|
4
13
|
shouldRecordCommand?: (id: string) => boolean;
|
|
@@ -18,6 +27,15 @@ export declare class MacroRecorder {
|
|
|
18
27
|
private readonly maxSteps;
|
|
19
28
|
private readonly onAutoStop?;
|
|
20
29
|
private steps;
|
|
30
|
+
private warnings;
|
|
31
|
+
/**
|
|
32
|
+
* A stopped recording, held until `discard()`: the owner may fail to save
|
|
33
|
+
* it (storage quota, an incomplete recording awaiting confirmation) and
|
|
34
|
+
* must be able to come back for it. Clearing on stop() was the old
|
|
35
|
+
* behavior, and it lost recordings at exactly the moments they were
|
|
36
|
+
* hardest to redo.
|
|
37
|
+
*/
|
|
38
|
+
private pending;
|
|
21
39
|
private disposers;
|
|
22
40
|
private active;
|
|
23
41
|
/**
|
|
@@ -29,14 +47,22 @@ export declare class MacroRecorder {
|
|
|
29
47
|
constructor(host: MacroHost, options?: RecorderOptions);
|
|
30
48
|
get recording(): boolean;
|
|
31
49
|
get stepCount(): number;
|
|
50
|
+
/** Whether a stopped recording is waiting to be saved or discarded. */
|
|
51
|
+
get hasPending(): boolean;
|
|
32
52
|
start(): void;
|
|
33
53
|
/**
|
|
34
|
-
* Stops and returns the
|
|
35
|
-
*
|
|
36
|
-
*
|
|
54
|
+
* Stops and returns the recording — steps plus any warnings about actions
|
|
55
|
+
* that could not be captured. The result stays retrievable (calling
|
|
56
|
+
* `stop()` again returns the same recording) until `discard()`, `cancel()`
|
|
57
|
+
* or a new `start()`: a save that fails must be retryable.
|
|
37
58
|
*/
|
|
38
|
-
stop():
|
|
39
|
-
|
|
59
|
+
stop(): {
|
|
60
|
+
steps: MacroStep[];
|
|
61
|
+
warnings: RecordingWarning[];
|
|
62
|
+
};
|
|
63
|
+
/** Releases a stopped recording after it was successfully saved (or knowingly dropped). */
|
|
64
|
+
discard(): void;
|
|
65
|
+
/** Stops and discards whatever was recorded — including a pending stopped recording. */
|
|
40
66
|
cancel(): void;
|
|
41
67
|
/**
|
|
42
68
|
* Rewrites the recorded tail after an auto-text expansion: the user typed
|
|
@@ -54,6 +80,13 @@ export declare class MacroRecorder {
|
|
|
54
80
|
applyAutoTextExpansion(consumed: number, replacement: string): void;
|
|
55
81
|
private teardown;
|
|
56
82
|
private push;
|
|
83
|
+
private addWarning;
|
|
84
|
+
/**
|
|
85
|
+
* Warnings are part of the stopped snapshot just like steps. Counting both
|
|
86
|
+
* prevents a warning-only recording from growing without bound while never
|
|
87
|
+
* reaching the ordinary step cap.
|
|
88
|
+
*/
|
|
89
|
+
private maybeAutoStop;
|
|
57
90
|
/**
|
|
58
91
|
* Records a programmatic insertion the host will not report as typing —
|
|
59
92
|
* e.g. a snippet expanded from a button or shortcut, which writes through
|
|
@@ -24,6 +24,15 @@ export class MacroRecorder {
|
|
|
24
24
|
maxSteps;
|
|
25
25
|
onAutoStop;
|
|
26
26
|
steps = [];
|
|
27
|
+
warnings = [];
|
|
28
|
+
/**
|
|
29
|
+
* A stopped recording, held until `discard()`: the owner may fail to save
|
|
30
|
+
* it (storage quota, an incomplete recording awaiting confirmation) and
|
|
31
|
+
* must be able to come back for it. Clearing on stop() was the old
|
|
32
|
+
* behavior, and it lost recordings at exactly the moments they were
|
|
33
|
+
* hardest to redo.
|
|
34
|
+
*/
|
|
35
|
+
pending = null;
|
|
27
36
|
disposers = [];
|
|
28
37
|
active = false;
|
|
29
38
|
/**
|
|
@@ -44,31 +53,49 @@ export class MacroRecorder {
|
|
|
44
53
|
get stepCount() {
|
|
45
54
|
return this.steps.length;
|
|
46
55
|
}
|
|
56
|
+
/** Whether a stopped recording is waiting to be saved or discarded. */
|
|
57
|
+
get hasPending() {
|
|
58
|
+
return this.pending !== null;
|
|
59
|
+
}
|
|
47
60
|
start() {
|
|
48
61
|
if (this.active)
|
|
49
62
|
return;
|
|
50
63
|
this.active = true;
|
|
51
64
|
this.steps = [];
|
|
65
|
+
this.warnings = [];
|
|
66
|
+
// Starting anew is the explicit "I no longer want the unsaved one".
|
|
67
|
+
this.pending = null;
|
|
52
68
|
this.disposers = [
|
|
53
69
|
this.host.onCommand((id, payload) => this.recordCommand(id, payload)),
|
|
54
70
|
this.host.onTextInput((event) => this.recordTextInput(event)),
|
|
55
71
|
];
|
|
56
72
|
}
|
|
57
73
|
/**
|
|
58
|
-
* Stops and returns the
|
|
59
|
-
*
|
|
60
|
-
*
|
|
74
|
+
* Stops and returns the recording — steps plus any warnings about actions
|
|
75
|
+
* that could not be captured. The result stays retrievable (calling
|
|
76
|
+
* `stop()` again returns the same recording) until `discard()`, `cancel()`
|
|
77
|
+
* or a new `start()`: a save that fails must be retryable.
|
|
61
78
|
*/
|
|
62
79
|
stop() {
|
|
63
|
-
this.
|
|
64
|
-
|
|
65
|
-
this.steps
|
|
66
|
-
|
|
80
|
+
if (this.active)
|
|
81
|
+
this.teardown();
|
|
82
|
+
if (!this.pending && (this.steps.length > 0 || this.warnings.length > 0)) {
|
|
83
|
+
this.pending = { steps: this.steps, warnings: this.warnings };
|
|
84
|
+
this.steps = [];
|
|
85
|
+
this.warnings = [];
|
|
86
|
+
}
|
|
87
|
+
return this.pending ?? { steps: [], warnings: [] };
|
|
88
|
+
}
|
|
89
|
+
/** Releases a stopped recording after it was successfully saved (or knowingly dropped). */
|
|
90
|
+
discard() {
|
|
91
|
+
this.pending = null;
|
|
67
92
|
}
|
|
68
|
-
/** Stops and discards whatever was recorded. */
|
|
93
|
+
/** Stops and discards whatever was recorded — including a pending stopped recording. */
|
|
69
94
|
cancel() {
|
|
70
95
|
this.teardown();
|
|
71
96
|
this.steps = [];
|
|
97
|
+
this.warnings = [];
|
|
98
|
+
this.pending = null;
|
|
72
99
|
}
|
|
73
100
|
/**
|
|
74
101
|
* Rewrites the recorded tail after an auto-text expansion: the user typed
|
|
@@ -119,12 +146,27 @@ export class MacroRecorder {
|
|
|
119
146
|
}
|
|
120
147
|
push(step) {
|
|
121
148
|
this.steps.push(step);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
149
|
+
this.maybeAutoStop();
|
|
150
|
+
}
|
|
151
|
+
addWarning(warning) {
|
|
152
|
+
this.warnings.push(warning);
|
|
153
|
+
this.maybeAutoStop();
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Warnings are part of the stopped snapshot just like steps. Counting both
|
|
157
|
+
* prevents a warning-only recording from growing without bound while never
|
|
158
|
+
* reaching the ordinary step cap.
|
|
159
|
+
*/
|
|
160
|
+
maybeAutoStop() {
|
|
161
|
+
if (!this.active)
|
|
162
|
+
return;
|
|
163
|
+
if (this.steps.length + this.warnings.length < this.maxSteps)
|
|
164
|
+
return;
|
|
165
|
+
this.teardown();
|
|
166
|
+
this.pending = { steps: this.steps, warnings: this.warnings };
|
|
167
|
+
this.steps = [];
|
|
168
|
+
this.warnings = [];
|
|
169
|
+
this.onAutoStop?.();
|
|
128
170
|
}
|
|
129
171
|
/**
|
|
130
172
|
* Records a programmatic insertion the host will not report as typing —
|
|
@@ -147,17 +189,27 @@ export class MacroRecorder {
|
|
|
147
189
|
// The payload is opaque engine data, but not unlimited: it must survive
|
|
148
190
|
// a JSON round-trip within the persistence cap, or the recording would
|
|
149
191
|
// be rejected by the loader. A command whose payload cannot be kept
|
|
150
|
-
// faithfully is
|
|
151
|
-
//
|
|
192
|
+
// faithfully is not stepped — replaying it with a mangled payload would
|
|
193
|
+
// do something other than what was recorded — but it is never dropped
|
|
194
|
+
// *silently*: the warning is what lets the owner refuse to present the
|
|
195
|
+
// recording as complete. Inserting an image is the concrete case — its
|
|
196
|
+
// payload carries the whole file as a data URL.
|
|
152
197
|
let json;
|
|
153
198
|
try {
|
|
154
199
|
json = JSON.stringify(payload);
|
|
155
200
|
}
|
|
156
201
|
catch {
|
|
202
|
+
this.addWarning({ commandId: id, reason: 'payload-not-serializable' });
|
|
157
203
|
return;
|
|
158
204
|
}
|
|
159
|
-
if (typeof json !== 'string'
|
|
205
|
+
if (typeof json !== 'string') {
|
|
206
|
+
this.addWarning({ commandId: id, reason: 'payload-not-serializable' });
|
|
160
207
|
return;
|
|
208
|
+
}
|
|
209
|
+
if (json.length > IMPORT_LIMITS.maxPayloadLength) {
|
|
210
|
+
this.addWarning({ commandId: id, reason: 'payload-too-large' });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
161
213
|
this.push({ type: 'command', id, payload: JSON.parse(json) });
|
|
162
214
|
}
|
|
163
215
|
recordTextInput(event) {
|
package/dist/shortcuts.d.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface KeyEventLike {
|
|
|
26
26
|
repeat?: boolean;
|
|
27
27
|
/** Mid-IME-composition — keys belong to the composition, not to bindings. */
|
|
28
28
|
isComposing?: boolean;
|
|
29
|
+
/** Legacy IME marker: some WebViews report keyCode 229 without isComposing. */
|
|
30
|
+
keyCode?: number;
|
|
29
31
|
preventDefault?(): void;
|
|
30
32
|
stopPropagation?(): void;
|
|
31
33
|
}
|
package/dist/shortcuts.js
CHANGED
|
@@ -117,8 +117,9 @@ export function hasBindingModifier(parsed) {
|
|
|
117
117
|
export function bindShortcuts(target, getBindings) {
|
|
118
118
|
const listener = (event) => {
|
|
119
119
|
// Auto-repeat must not replay a macro per repeat tick, and keys mid-IME
|
|
120
|
-
// composition belong to the composition
|
|
121
|
-
|
|
120
|
+
// composition belong to the composition — including the legacy keyCode
|
|
121
|
+
// 229 marker some WebViews report without setting isComposing.
|
|
122
|
+
if (event.repeat || event.isComposing || event.keyCode === 229)
|
|
122
123
|
return;
|
|
123
124
|
for (const binding of getBindings()) {
|
|
124
125
|
const parsed = parseShortcut(binding.shortcut);
|
|
@@ -84,27 +84,44 @@ export class AutoText {
|
|
|
84
84
|
// to the document. Deferring to the task queue guarantees the expansion
|
|
85
85
|
// character is already in before it is deleted along with the trigger.
|
|
86
86
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
87
|
+
// Fail closed, in both directions: the document must *verifiably*
|
|
88
|
+
// hold the trigger right before the caret. A mismatch means the caret
|
|
89
|
+
// moved (or another writer raced us); `null`/`undefined` means the
|
|
90
|
+
// host cannot tell — and a feature that deletes text on its own
|
|
91
|
+
// initiative does not get the benefit of the doubt. Auto-text simply
|
|
92
|
+
// does not expand where it cannot verify.
|
|
91
93
|
const expected = trigger + expandChar;
|
|
94
|
+
const rendered = renderSnippet(snippet.text, {
|
|
95
|
+
selectionText: usesSelection(snippet.text)
|
|
96
|
+
? (await this.host.getSelection({ includeText: true })).text
|
|
97
|
+
: undefined,
|
|
98
|
+
});
|
|
99
|
+
const replacement = rendered + expandChar;
|
|
100
|
+
// Preferred path: one atomic engine transaction that verifies and
|
|
101
|
+
// replaces together — nothing to roll back.
|
|
102
|
+
if (this.host.replaceTextBefore) {
|
|
103
|
+
const replaced = await this.host.replaceTextBefore(expected, replacement);
|
|
104
|
+
if (!replaced.ok) {
|
|
105
|
+
this.onError?.(replaced.message);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
this.onExpand?.(snippet, { trigger, expandChar, rendered });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
92
111
|
const actual = await this.host.getTextBefore?.(expected.length);
|
|
93
|
-
if (
|
|
112
|
+
if (actual !== expected)
|
|
94
113
|
return;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
// The expansion character is already in the document by now, so it is
|
|
100
|
-
// included in the deletion and restored at the end.
|
|
101
|
-
const deleted = await this.host.deleteBackward(trigger.length + 1);
|
|
114
|
+
// Two-operation fallback for hosts without an atomic replace. If the
|
|
115
|
+
// insertion fails after the deletion succeeded, the trigger is put
|
|
116
|
+
// back — the user must not be left with their word silently eaten.
|
|
117
|
+
const deleted = await this.host.deleteBackward(expected.length);
|
|
102
118
|
if (!deleted.ok) {
|
|
103
119
|
this.onError?.(deleted.message);
|
|
104
120
|
return;
|
|
105
121
|
}
|
|
106
|
-
const inserted = await this.host.insertText(
|
|
122
|
+
const inserted = await this.host.insertText(replacement);
|
|
107
123
|
if (!inserted.ok) {
|
|
124
|
+
await this.host.insertText(expected);
|
|
108
125
|
this.onError?.(inserted.message);
|
|
109
126
|
return;
|
|
110
127
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -78,6 +78,13 @@ export interface MacroHost {
|
|
|
78
78
|
* caret move the host failed to report.
|
|
79
79
|
*/
|
|
80
80
|
getTextBefore?(count: number): Promise<string | null>;
|
|
81
|
+
/**
|
|
82
|
+
* Atomically replaces the `expected.length` characters before the caret
|
|
83
|
+
* with `replacement` — verifying they equal `expected` first, all inside
|
|
84
|
+
* one engine transaction. Auto-text prefers this over delete+insert: two
|
|
85
|
+
* operations leave the trigger deleted when the second fails.
|
|
86
|
+
*/
|
|
87
|
+
replaceTextBefore?(expected: string, replacement: string): Promise<MacroOutcome>;
|
|
81
88
|
/** Replaces every occurrence of `query` with `replacement`. Returns how many were replaced. */
|
|
82
89
|
replaceAll(query: string, replacement: string): Promise<{
|
|
83
90
|
ok: boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superdoc-macros",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Macro toolkit for SuperDoc-based editors: sandboxed scripted macros, a Word-style macro recorder, and snippets with auto-text expansion.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|