superdoc-macros 0.6.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/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 +28 -4
- package/dist/manager.js +99 -36
- package/dist/messages.d.ts +1 -0
- package/dist/messages.js +2 -0
- package/dist/recorder/recorder.d.ts +31 -5
- package/dist/recorder/recorder.js +48 -11
- 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
|
@@ -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;
|
|
@@ -99,12 +111,24 @@ export declare class MacroKit {
|
|
|
99
111
|
startRecording(): void;
|
|
100
112
|
/**
|
|
101
113
|
* Stops and saves. `null` when no step was recorded — there is nothing to
|
|
102
|
-
* save.
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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).
|
|
105
125
|
*/
|
|
106
|
-
stopRecording(name: string, shortcut?: string
|
|
126
|
+
stopRecording(name: string, shortcut?: string, options?: {
|
|
127
|
+
allowIncomplete?: boolean;
|
|
128
|
+
}): RecordedMacro | null;
|
|
107
129
|
cancelRecording(): void;
|
|
130
|
+
/** Whether a stopped recording awaits a retried save (see stopRecording). */
|
|
131
|
+
get hasPendingRecording(): boolean;
|
|
108
132
|
listRecordings(): readonly RecordedMacro[];
|
|
109
133
|
removeRecording(id: string): void;
|
|
110
134
|
/** 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 = [
|
|
@@ -187,19 +230,34 @@ export class MacroKit {
|
|
|
187
230
|
}
|
|
188
231
|
/**
|
|
189
232
|
* Stops and saves. `null` when no step was recorded — there is nothing to
|
|
190
|
-
* save.
|
|
191
|
-
*
|
|
192
|
-
*
|
|
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).
|
|
193
244
|
*/
|
|
194
|
-
stopRecording(name, shortcut) {
|
|
245
|
+
stopRecording(name, shortcut, options = {}) {
|
|
195
246
|
this.requireValidShortcut(shortcut);
|
|
196
247
|
this.requireItemLimits({ name, shortcut });
|
|
197
248
|
this.requireRoom(this.state.recordings);
|
|
198
|
-
const
|
|
249
|
+
const pending = this.recorder.stop();
|
|
250
|
+
const { steps, truncated } = splitOversizedSteps(pending.steps);
|
|
199
251
|
if (truncated)
|
|
200
252
|
throw new MacroError(macroMessages().recordingTooLarge, 'recording-too-large');
|
|
201
|
-
if (steps.length === 0)
|
|
253
|
+
if (steps.length === 0) {
|
|
254
|
+
this.recorder.discard();
|
|
202
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
|
+
}
|
|
203
261
|
const recording = {
|
|
204
262
|
version: 1,
|
|
205
263
|
id: newId(),
|
|
@@ -208,14 +266,22 @@ export class MacroKit {
|
|
|
208
266
|
...(shortcut ? { shortcut } : {}),
|
|
209
267
|
steps,
|
|
210
268
|
};
|
|
211
|
-
|
|
269
|
+
const saved = this.commit((draft) => {
|
|
212
270
|
draft.recordings.push(recording);
|
|
213
271
|
return recording;
|
|
214
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;
|
|
215
277
|
}
|
|
216
278
|
cancelRecording() {
|
|
217
279
|
this.recorder.cancel();
|
|
218
280
|
}
|
|
281
|
+
/** Whether a stopped recording awaits a retried save (see stopRecording). */
|
|
282
|
+
get hasPendingRecording() {
|
|
283
|
+
return this.recorder.hasPending;
|
|
284
|
+
}
|
|
219
285
|
listRecordings() {
|
|
220
286
|
return this.state.recordings;
|
|
221
287
|
}
|
|
@@ -416,17 +482,14 @@ export class MacroKit {
|
|
|
416
482
|
for (const item of items) {
|
|
417
483
|
if (!item.shortcut)
|
|
418
484
|
continue;
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
if (
|
|
424
|
-
return { ok: false, message: macroMessages().importRejectedShortcut(item.name,
|
|
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) };
|
|
425
491
|
}
|
|
426
|
-
for (const signature of shortcutSignatures(
|
|
427
|
-
if (this.reservedSignatures.has(signature)) {
|
|
428
|
-
return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutReserved) };
|
|
429
|
-
}
|
|
492
|
+
for (const signature of shortcutSignatures(parseShortcut(item.shortcut))) {
|
|
430
493
|
const owner = seen.get(signature);
|
|
431
494
|
if (owner !== undefined) {
|
|
432
495
|
return { ok: false, message: macroMessages().importRejectedShortcut(item.name, macroMessages().shortcutTaken(owner)) };
|
package/dist/messages.d.ts
CHANGED
|
@@ -42,6 +42,7 @@ export interface MacroMessages {
|
|
|
42
42
|
fieldTooLong: (field: string, max: number) => string;
|
|
43
43
|
saveFailed: string;
|
|
44
44
|
recordingTooLarge: string;
|
|
45
|
+
recordingIncomplete: (commandIds: string) => string;
|
|
45
46
|
shortcutInvalid: string;
|
|
46
47
|
shortcutNeedsModifier: string;
|
|
47
48
|
shortcutReserved: string;
|
package/dist/messages.js
CHANGED
|
@@ -42,6 +42,7 @@ 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})`,
|
|
45
46
|
shortcutInvalid: 'Invalid shortcut — use a form like Ctrl+Alt+M',
|
|
46
47
|
shortcutNeedsModifier: 'A shortcut must include Ctrl, Alt or Meta',
|
|
47
48
|
shortcutReserved: 'This shortcut is reserved by the editor',
|
|
@@ -81,6 +82,7 @@ export const HEBREW_MESSAGES = {
|
|
|
81
82
|
fieldTooLong: (field, max) => `${field} ארוך מדי (התקרה: ${max} תווים)`,
|
|
82
83
|
saveFailed: 'השמירה נכשלה — השינוי לא הוחל',
|
|
83
84
|
recordingTooLarge: 'ההקלטה גדולה מכדי להישמר',
|
|
85
|
+
recordingIncomplete: (commandIds) => `בהקלטה חסרות פעולות שאינן ניתנות להקלטה (${commandIds})`,
|
|
84
86
|
shortcutInvalid: 'קיצור לא תקין — הצורה הנדרשת היא למשל Ctrl+Alt+M',
|
|
85
87
|
shortcutNeedsModifier: 'קיצור חייב לכלול Ctrl, Alt או Meta',
|
|
86
88
|
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
|
|
@@ -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
|
|
@@ -147,17 +174,27 @@ export class MacroRecorder {
|
|
|
147
174
|
// The payload is opaque engine data, but not unlimited: it must survive
|
|
148
175
|
// a JSON round-trip within the persistence cap, or the recording would
|
|
149
176
|
// be rejected by the loader. A command whose payload cannot be kept
|
|
150
|
-
// faithfully is
|
|
151
|
-
//
|
|
177
|
+
// faithfully is not stepped — replaying it with a mangled payload would
|
|
178
|
+
// do something other than what was recorded — but it is never dropped
|
|
179
|
+
// *silently*: the warning is what lets the owner refuse to present the
|
|
180
|
+
// recording as complete. Inserting an image is the concrete case — its
|
|
181
|
+
// payload carries the whole file as a data URL.
|
|
152
182
|
let json;
|
|
153
183
|
try {
|
|
154
184
|
json = JSON.stringify(payload);
|
|
155
185
|
}
|
|
156
186
|
catch {
|
|
187
|
+
this.warnings.push({ commandId: id, reason: 'payload-not-serializable' });
|
|
157
188
|
return;
|
|
158
189
|
}
|
|
159
|
-
if (typeof json !== 'string'
|
|
190
|
+
if (typeof json !== 'string') {
|
|
191
|
+
this.warnings.push({ commandId: id, reason: 'payload-not-serializable' });
|
|
160
192
|
return;
|
|
193
|
+
}
|
|
194
|
+
if (json.length > IMPORT_LIMITS.maxPayloadLength) {
|
|
195
|
+
this.warnings.push({ commandId: id, reason: 'payload-too-large' });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
161
198
|
this.push({ type: 'command', id, payload: JSON.parse(json) });
|
|
162
199
|
}
|
|
163
200
|
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.0",
|
|
4
4
|
"description": "Macro toolkit for SuperDoc-based editors: sandboxed scripted macros, a Word-style macro recorder, and snippets with auto-text expansion.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|