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/dist/messages.js
CHANGED
|
@@ -40,6 +40,9 @@ export const ENGLISH_MESSAGES = {
|
|
|
40
40
|
tooManyItems: 'The list is full — delete items before adding new ones',
|
|
41
41
|
nameRequired: 'A name is required',
|
|
42
42
|
fieldTooLong: (field, max) => `${field} is too long (limit: ${max} characters)`,
|
|
43
|
+
saveFailed: 'Saving failed — the change was not applied',
|
|
44
|
+
recordingTooLarge: 'The recording is too large to save',
|
|
45
|
+
recordingIncomplete: (commandIds) => `The recording is missing actions that cannot be recorded (${commandIds})`,
|
|
43
46
|
shortcutInvalid: 'Invalid shortcut — use a form like Ctrl+Alt+M',
|
|
44
47
|
shortcutNeedsModifier: 'A shortcut must include Ctrl, Alt or Meta',
|
|
45
48
|
shortcutReserved: 'This shortcut is reserved by the editor',
|
|
@@ -77,6 +80,9 @@ export const HEBREW_MESSAGES = {
|
|
|
77
80
|
tooManyItems: 'הרשימה מלאה — יש למחוק פריטים לפני הוספה',
|
|
78
81
|
nameRequired: 'חובה לתת שם',
|
|
79
82
|
fieldTooLong: (field, max) => `${field} ארוך מדי (התקרה: ${max} תווים)`,
|
|
83
|
+
saveFailed: 'השמירה נכשלה — השינוי לא הוחל',
|
|
84
|
+
recordingTooLarge: 'ההקלטה גדולה מכדי להישמר',
|
|
85
|
+
recordingIncomplete: (commandIds) => `בהקלטה חסרות פעולות שאינן ניתנות להקלטה (${commandIds})`,
|
|
80
86
|
shortcutInvalid: 'קיצור לא תקין — הצורה הנדרשת היא למשל Ctrl+Alt+M',
|
|
81
87
|
shortcutNeedsModifier: 'קיצור חייב לכלול Ctrl, Alt או Meta',
|
|
82
88
|
shortcutReserved: 'הקיצור הזה שמור לעורך',
|
|
@@ -1,18 +1,13 @@
|
|
|
1
|
+
import type { MacroHost, MacroStep } from '../types.js';
|
|
1
2
|
/**
|
|
2
|
-
* A
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* That choice is deliberate. Recording raw ProseMirror steps (with absolute
|
|
6
|
-
* positions) breaks the moment the document differs from what it was at
|
|
7
|
-
* recording time; recording commands ("bold", "bullet-list", typing a
|
|
8
|
-
* greeting) behaves like Word's recorder — the actions apply wherever the
|
|
9
|
-
* caret is at replay time. It is also what makes a recording saveable and
|
|
10
|
-
* shareable: the steps are plain JSON.
|
|
11
|
-
*
|
|
12
|
-
* What is not recorded: caret movement and mouse selection. As in Word, a
|
|
13
|
-
* recorded macro acts from wherever the caret stands when it runs.
|
|
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.
|
|
14
6
|
*/
|
|
15
|
-
|
|
7
|
+
export interface RecordingWarning {
|
|
8
|
+
commandId: string;
|
|
9
|
+
reason: 'payload-too-large' | 'payload-not-serializable';
|
|
10
|
+
}
|
|
16
11
|
export interface RecorderOptions {
|
|
17
12
|
/** Command filter. The default records everything except undo/redo. */
|
|
18
13
|
shouldRecordCommand?: (id: string) => boolean;
|
|
@@ -32,19 +27,42 @@ export declare class MacroRecorder {
|
|
|
32
27
|
private readonly maxSteps;
|
|
33
28
|
private readonly onAutoStop?;
|
|
34
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;
|
|
35
39
|
private disposers;
|
|
36
40
|
private active;
|
|
41
|
+
/**
|
|
42
|
+
* Set by a caret move: the next typed character starts a fresh step
|
|
43
|
+
* instead of coalescing — text typed at a new position is not a
|
|
44
|
+
* continuation of the text typed at the old one.
|
|
45
|
+
*/
|
|
46
|
+
private tailInterrupted;
|
|
37
47
|
constructor(host: MacroHost, options?: RecorderOptions);
|
|
38
48
|
get recording(): boolean;
|
|
39
49
|
get stepCount(): number;
|
|
50
|
+
/** Whether a stopped recording is waiting to be saved or discarded. */
|
|
51
|
+
get hasPending(): boolean;
|
|
40
52
|
start(): void;
|
|
41
53
|
/**
|
|
42
|
-
* Stops and returns the
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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.
|
|
45
58
|
*/
|
|
46
|
-
stop():
|
|
47
|
-
|
|
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. */
|
|
48
66
|
cancel(): void;
|
|
49
67
|
/**
|
|
50
68
|
* Rewrites the recorded tail after an auto-text expansion: the user typed
|
|
@@ -62,6 +80,13 @@ export declare class MacroRecorder {
|
|
|
62
80
|
applyAutoTextExpansion(consumed: number, replacement: string): void;
|
|
63
81
|
private teardown;
|
|
64
82
|
private push;
|
|
83
|
+
/**
|
|
84
|
+
* Records a programmatic insertion the host will not report as typing —
|
|
85
|
+
* e.g. a snippet expanded from a button or shortcut, which writes through
|
|
86
|
+
* the document API and never fires beforeinput. Without this, a replay
|
|
87
|
+
* would silently miss text the user watched appear.
|
|
88
|
+
*/
|
|
89
|
+
recordInsert(text: string): void;
|
|
65
90
|
private recordCommand;
|
|
66
91
|
private recordTextInput;
|
|
67
92
|
}
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Word-style macro recorder: records **commands** and typing, not caret
|
|
3
|
+
* positions.
|
|
4
|
+
*
|
|
5
|
+
* That choice is deliberate. Recording raw ProseMirror steps (with absolute
|
|
6
|
+
* positions) breaks the moment the document differs from what it was at
|
|
7
|
+
* recording time; recording commands ("bold", "bullet-list", typing a
|
|
8
|
+
* greeting) behaves like Word's recorder — the actions apply wherever the
|
|
9
|
+
* caret is at replay time. It is also what makes a recording saveable and
|
|
10
|
+
* shareable: the steps are plain JSON.
|
|
11
|
+
*
|
|
12
|
+
* What is not recorded: caret movement and mouse selection. As in Word, a
|
|
13
|
+
* recorded macro acts from wherever the caret stands when it runs.
|
|
14
|
+
*/
|
|
15
|
+
import { IMPORT_LIMITS } from '../storage.js';
|
|
1
16
|
const DEFAULT_MAX_STEPS = 5_000;
|
|
2
17
|
/** Undo/redo during recording fix the recording itself — replaying them would replay the mistake too. */
|
|
3
18
|
function defaultShouldRecord(id) {
|
|
@@ -9,8 +24,23 @@ export class MacroRecorder {
|
|
|
9
24
|
maxSteps;
|
|
10
25
|
onAutoStop;
|
|
11
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;
|
|
12
36
|
disposers = [];
|
|
13
37
|
active = false;
|
|
38
|
+
/**
|
|
39
|
+
* Set by a caret move: the next typed character starts a fresh step
|
|
40
|
+
* instead of coalescing — text typed at a new position is not a
|
|
41
|
+
* continuation of the text typed at the old one.
|
|
42
|
+
*/
|
|
43
|
+
tailInterrupted = false;
|
|
14
44
|
constructor(host, options = {}) {
|
|
15
45
|
this.host = host;
|
|
16
46
|
this.shouldRecordCommand = options.shouldRecordCommand ?? defaultShouldRecord;
|
|
@@ -23,31 +53,49 @@ export class MacroRecorder {
|
|
|
23
53
|
get stepCount() {
|
|
24
54
|
return this.steps.length;
|
|
25
55
|
}
|
|
56
|
+
/** Whether a stopped recording is waiting to be saved or discarded. */
|
|
57
|
+
get hasPending() {
|
|
58
|
+
return this.pending !== null;
|
|
59
|
+
}
|
|
26
60
|
start() {
|
|
27
61
|
if (this.active)
|
|
28
62
|
return;
|
|
29
63
|
this.active = true;
|
|
30
64
|
this.steps = [];
|
|
65
|
+
this.warnings = [];
|
|
66
|
+
// Starting anew is the explicit "I no longer want the unsaved one".
|
|
67
|
+
this.pending = null;
|
|
31
68
|
this.disposers = [
|
|
32
69
|
this.host.onCommand((id, payload) => this.recordCommand(id, payload)),
|
|
33
70
|
this.host.onTextInput((event) => this.recordTextInput(event)),
|
|
34
71
|
];
|
|
35
72
|
}
|
|
36
73
|
/**
|
|
37
|
-
* Stops and returns the
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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.
|
|
40
78
|
*/
|
|
41
79
|
stop() {
|
|
42
|
-
this.
|
|
43
|
-
|
|
44
|
-
this.steps
|
|
45
|
-
|
|
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: [] };
|
|
46
88
|
}
|
|
47
|
-
/**
|
|
89
|
+
/** Releases a stopped recording after it was successfully saved (or knowingly dropped). */
|
|
90
|
+
discard() {
|
|
91
|
+
this.pending = null;
|
|
92
|
+
}
|
|
93
|
+
/** Stops and discards whatever was recorded — including a pending stopped recording. */
|
|
48
94
|
cancel() {
|
|
49
95
|
this.teardown();
|
|
50
96
|
this.steps = [];
|
|
97
|
+
this.warnings = [];
|
|
98
|
+
this.pending = null;
|
|
51
99
|
}
|
|
52
100
|
/**
|
|
53
101
|
* Rewrites the recorded tail after an auto-text expansion: the user typed
|
|
@@ -105,17 +153,59 @@ export class MacroRecorder {
|
|
|
105
153
|
this.onAutoStop?.();
|
|
106
154
|
}
|
|
107
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Records a programmatic insertion the host will not report as typing —
|
|
158
|
+
* e.g. a snippet expanded from a button or shortcut, which writes through
|
|
159
|
+
* the document API and never fires beforeinput. Without this, a replay
|
|
160
|
+
* would silently miss text the user watched appear.
|
|
161
|
+
*/
|
|
162
|
+
recordInsert(text) {
|
|
163
|
+
if (!this.active || text.length === 0)
|
|
164
|
+
return;
|
|
165
|
+
this.recordTextInput({ kind: 'insert-text', text });
|
|
166
|
+
}
|
|
108
167
|
recordCommand(id, payload) {
|
|
109
168
|
if (!this.shouldRecordCommand(id))
|
|
110
169
|
return;
|
|
111
|
-
|
|
170
|
+
if (payload === undefined) {
|
|
171
|
+
this.push({ type: 'command', id });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// The payload is opaque engine data, but not unlimited: it must survive
|
|
175
|
+
// a JSON round-trip within the persistence cap, or the recording would
|
|
176
|
+
// be rejected by the loader. A command whose payload cannot be kept
|
|
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.
|
|
182
|
+
let json;
|
|
183
|
+
try {
|
|
184
|
+
json = JSON.stringify(payload);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
this.warnings.push({ commandId: id, reason: 'payload-not-serializable' });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (typeof json !== 'string') {
|
|
191
|
+
this.warnings.push({ commandId: id, reason: 'payload-not-serializable' });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (json.length > IMPORT_LIMITS.maxPayloadLength) {
|
|
195
|
+
this.warnings.push({ commandId: id, reason: 'payload-too-large' });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
this.push({ type: 'command', id, payload: JSON.parse(json) });
|
|
112
199
|
}
|
|
113
200
|
recordTextInput(event) {
|
|
201
|
+
const interrupted = this.tailInterrupted;
|
|
202
|
+
this.tailInterrupted = false;
|
|
114
203
|
const last = this.steps[this.steps.length - 1];
|
|
115
204
|
switch (event.kind) {
|
|
116
205
|
case 'insert-text': {
|
|
117
|
-
// Consecutive keystrokes coalesce into one step — more readable,
|
|
118
|
-
|
|
206
|
+
// Consecutive keystrokes coalesce into one step — more readable,
|
|
207
|
+
// faster to replay — but never across a caret move.
|
|
208
|
+
if (!interrupted && last?.type === 'insert-text') {
|
|
119
209
|
last.text += event.text;
|
|
120
210
|
return;
|
|
121
211
|
}
|
|
@@ -126,7 +216,7 @@ export class MacroRecorder {
|
|
|
126
216
|
this.push({ type: 'insert-paragraph' });
|
|
127
217
|
return;
|
|
128
218
|
case 'delete-backward': {
|
|
129
|
-
if (last?.type === 'delete-backward') {
|
|
219
|
+
if (!interrupted && last?.type === 'delete-backward') {
|
|
130
220
|
last.count += 1;
|
|
131
221
|
return;
|
|
132
222
|
}
|
|
@@ -134,13 +224,18 @@ export class MacroRecorder {
|
|
|
134
224
|
return;
|
|
135
225
|
}
|
|
136
226
|
case 'delete-forward': {
|
|
137
|
-
if (last?.type === 'delete-forward') {
|
|
227
|
+
if (!interrupted && last?.type === 'delete-forward') {
|
|
138
228
|
last.count += 1;
|
|
139
229
|
return;
|
|
140
230
|
}
|
|
141
231
|
this.push({ type: 'delete-forward', count: 1 });
|
|
142
232
|
return;
|
|
143
233
|
}
|
|
234
|
+
case 'caret-moved':
|
|
235
|
+
// Not a step — replay acts from the live caret — but the recorded
|
|
236
|
+
// tail is no longer "where the user is typing".
|
|
237
|
+
this.tailInterrupted = true;
|
|
238
|
+
return;
|
|
144
239
|
}
|
|
145
240
|
}
|
|
146
241
|
}
|
package/dist/shortcuts.d.ts
CHANGED
|
@@ -16,14 +16,35 @@ export interface ParsedShortcut {
|
|
|
16
16
|
/** The subset of KeyboardEvent that matching needs. Enables DOM-free tests. */
|
|
17
17
|
export interface KeyEventLike {
|
|
18
18
|
key: string;
|
|
19
|
+
/** The physical key. When present, letters and digits match by it — see `eventMatches`. */
|
|
20
|
+
code?: string;
|
|
19
21
|
ctrlKey: boolean;
|
|
20
22
|
altKey: boolean;
|
|
21
23
|
shiftKey: boolean;
|
|
22
24
|
metaKey: boolean;
|
|
25
|
+
/** Key held down — auto-repeat must not re-fire a macro. */
|
|
26
|
+
repeat?: boolean;
|
|
27
|
+
/** Mid-IME-composition — keys belong to the composition, not to bindings. */
|
|
28
|
+
isComposing?: boolean;
|
|
29
|
+
/** Legacy IME marker: some WebViews report keyCode 229 without isComposing. */
|
|
30
|
+
keyCode?: number;
|
|
23
31
|
preventDefault?(): void;
|
|
24
32
|
stopPropagation?(): void;
|
|
25
33
|
}
|
|
26
34
|
export declare function parseShortcut(shortcut: string): ParsedShortcut | null;
|
|
35
|
+
/**
|
|
36
|
+
* The physical `event.code` values a binding key stands for. Letters and
|
|
37
|
+
* digits get a deterministic mapping; anything else returns empty and falls
|
|
38
|
+
* back to `event.key`.
|
|
39
|
+
*
|
|
40
|
+
* Physical-key matching is what keeps a binding alive across keyboard
|
|
41
|
+
* layouts: on a Hebrew layout Ctrl+Alt+R reports `key: 'ר'`, and a
|
|
42
|
+
* key-based match would die the moment the user switches to Hebrew — the
|
|
43
|
+
* exact bug the host editor once had with its own shortcuts.
|
|
44
|
+
*/
|
|
45
|
+
export declare function codesForKey(key: string): readonly string[];
|
|
46
|
+
/** Whether the key can be bound reliably (has a physical-code mapping). */
|
|
47
|
+
export declare function isBindableKey(parsed: ParsedShortcut): boolean;
|
|
27
48
|
export declare function eventMatches(parsed: ParsedShortcut, event: KeyEventLike): boolean;
|
|
28
49
|
/**
|
|
29
50
|
* Comparable signatures for collision checks. `Mod` matches either Ctrl or
|
package/dist/shortcuts.js
CHANGED
|
@@ -44,8 +44,40 @@ function normalizeKey(key) {
|
|
|
44
44
|
return 'escape';
|
|
45
45
|
return lower;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* The physical `event.code` values a binding key stands for. Letters and
|
|
49
|
+
* digits get a deterministic mapping; anything else returns empty and falls
|
|
50
|
+
* back to `event.key`.
|
|
51
|
+
*
|
|
52
|
+
* Physical-key matching is what keeps a binding alive across keyboard
|
|
53
|
+
* layouts: on a Hebrew layout Ctrl+Alt+R reports `key: 'ר'`, and a
|
|
54
|
+
* key-based match would die the moment the user switches to Hebrew — the
|
|
55
|
+
* exact bug the host editor once had with its own shortcuts.
|
|
56
|
+
*/
|
|
57
|
+
export function codesForKey(key) {
|
|
58
|
+
if (/^[a-z]$/.test(key))
|
|
59
|
+
return [`Key${key.toUpperCase()}`];
|
|
60
|
+
if (/^[0-9]$/.test(key))
|
|
61
|
+
return [`Digit${key}`, `Numpad${key}`];
|
|
62
|
+
if (/^f([1-9]|1[0-2])$/.test(key))
|
|
63
|
+
return [key.toUpperCase()];
|
|
64
|
+
if (key === ' ')
|
|
65
|
+
return ['Space'];
|
|
66
|
+
if (key === 'escape')
|
|
67
|
+
return ['Escape'];
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
/** Whether the key can be bound reliably (has a physical-code mapping). */
|
|
71
|
+
export function isBindableKey(parsed) {
|
|
72
|
+
return codesForKey(parsed.key).length > 0;
|
|
73
|
+
}
|
|
47
74
|
export function eventMatches(parsed, event) {
|
|
48
|
-
|
|
75
|
+
const codes = codesForKey(parsed.key);
|
|
76
|
+
const keyMatched = normalizeKey(event.key) === parsed.key;
|
|
77
|
+
// The physical code decides whenever both sides have one; `event.key` is
|
|
78
|
+
// the fallback for keys with no mapping or hosts that do not report codes.
|
|
79
|
+
const matched = codes.length > 0 && event.code !== undefined ? codes.includes(event.code) : keyMatched;
|
|
80
|
+
if (!matched)
|
|
49
81
|
return false;
|
|
50
82
|
if (parsed.mod) {
|
|
51
83
|
if (!event.ctrlKey && !event.metaKey)
|
|
@@ -84,6 +116,11 @@ export function hasBindingModifier(parsed) {
|
|
|
84
116
|
*/
|
|
85
117
|
export function bindShortcuts(target, getBindings) {
|
|
86
118
|
const listener = (event) => {
|
|
119
|
+
// Auto-repeat must not replay a macro per repeat tick, and keys mid-IME
|
|
120
|
+
// composition belong to the composition — including the legacy keyCode
|
|
121
|
+
// 229 marker some WebViews report without setting isComposing.
|
|
122
|
+
if (event.repeat || event.isComposing || event.keyCode === 229)
|
|
123
|
+
return;
|
|
87
124
|
for (const binding of getBindings()) {
|
|
88
125
|
const parsed = parseShortcut(binding.shortcut);
|
|
89
126
|
if (!parsed || !eventMatches(parsed, event))
|
|
@@ -50,6 +50,12 @@ export class AutoText {
|
|
|
50
50
|
case 'delete-forward':
|
|
51
51
|
this.buffer = '';
|
|
52
52
|
return;
|
|
53
|
+
// A click or navigation key moved the caret: the buffer no longer
|
|
54
|
+
// describes what sits before it, and expanding on it would delete
|
|
55
|
+
// text at the new position. Missing an expansion is the cheap error.
|
|
56
|
+
case 'caret-moved':
|
|
57
|
+
this.buffer = '';
|
|
58
|
+
return;
|
|
53
59
|
case 'delete-backward':
|
|
54
60
|
this.buffer = this.buffer.slice(0, -1);
|
|
55
61
|
return;
|
|
@@ -78,19 +84,44 @@ export class AutoText {
|
|
|
78
84
|
// to the document. Deferring to the task queue guarantees the expansion
|
|
79
85
|
// character is already in before it is deleted along with the trigger.
|
|
80
86
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
const
|
|
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.
|
|
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
|
+
}
|
|
111
|
+
const actual = await this.host.getTextBefore?.(expected.length);
|
|
112
|
+
if (actual !== expected)
|
|
113
|
+
return;
|
|
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);
|
|
88
118
|
if (!deleted.ok) {
|
|
89
119
|
this.onError?.(deleted.message);
|
|
90
120
|
return;
|
|
91
121
|
}
|
|
92
|
-
const inserted = await this.host.insertText(
|
|
122
|
+
const inserted = await this.host.insertText(replacement);
|
|
93
123
|
if (!inserted.ok) {
|
|
124
|
+
await this.host.insertText(expected);
|
|
94
125
|
this.onError?.(inserted.message);
|
|
95
126
|
return;
|
|
96
127
|
}
|
|
@@ -26,5 +26,11 @@ export interface ExpandOptions {
|
|
|
26
26
|
now?: Date;
|
|
27
27
|
locale?: string;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Renders a snippet against the live document (reads the selection only
|
|
31
|
+
* when the snippet needs it). Split from the insertion so a caller that
|
|
32
|
+
* must know what text actually landed — e.g. a recorder — can.
|
|
33
|
+
*/
|
|
34
|
+
export declare function renderSnippetForHost(host: MacroHost, snippet: Pick<Snippet, 'text'>, options?: ExpandOptions): Promise<string>;
|
|
29
35
|
/** Expands a snippet at the caret. */
|
|
30
36
|
export declare function expandSnippet(host: MacroHost, snippet: Pick<Snippet, 'text'>, options?: ExpandOptions): Promise<MacroOutcome>;
|
|
@@ -24,17 +24,23 @@ export function renderSnippet(text, context = {}) {
|
|
|
24
24
|
export function usesSelection(text) {
|
|
25
25
|
return /\{\{\s*selection\s*\}\}/iu.test(text);
|
|
26
26
|
}
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Renders a snippet against the live document (reads the selection only
|
|
29
|
+
* when the snippet needs it). Split from the insertion so a caller that
|
|
30
|
+
* must know what text actually landed — e.g. a recorder — can.
|
|
31
|
+
*/
|
|
32
|
+
export async function renderSnippetForHost(host, snippet, options = {}) {
|
|
30
33
|
const selectionText = usesSelection(snippet.text)
|
|
31
34
|
? (await host.getSelection({ includeText: true })).text
|
|
32
35
|
: undefined;
|
|
33
|
-
|
|
36
|
+
return renderSnippet(snippet.text, {
|
|
34
37
|
variables: options.variables,
|
|
35
38
|
selectionText,
|
|
36
39
|
now: options.now,
|
|
37
40
|
locale: options.locale,
|
|
38
41
|
});
|
|
39
|
-
|
|
42
|
+
}
|
|
43
|
+
/** Expands a snippet at the caret. */
|
|
44
|
+
export async function expandSnippet(host, snippet, options = {}) {
|
|
45
|
+
return host.insertText(await renderSnippetForHost(host, snippet, options));
|
|
40
46
|
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -13,7 +13,12 @@ export interface PersistedMacroState {
|
|
|
13
13
|
export interface MacroStorage {
|
|
14
14
|
/** `null` when there is no saved state or the saved state is unreadable. */
|
|
15
15
|
load(): PersistedMacroState | null;
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Persists the state. Returns whether it actually landed — quota and
|
|
18
|
+
* serialization failures come back as `false`, never as a throw, so the
|
|
19
|
+
* caller can refuse to adopt an in-memory change its storage rejected.
|
|
20
|
+
*/
|
|
21
|
+
save(state: PersistedMacroState): boolean;
|
|
17
22
|
}
|
|
18
23
|
export declare function emptyState(): PersistedMacroState;
|
|
19
24
|
/**
|
|
@@ -33,13 +38,18 @@ export declare const IMPORT_LIMITS: {
|
|
|
33
38
|
/** Snippet text and single recorded insert-text step. */
|
|
34
39
|
readonly maxTextLength: 100000;
|
|
35
40
|
readonly maxSourceLength: 200000;
|
|
41
|
+
/** A recorded command payload, serialized. Engine payloads are small config objects. */
|
|
42
|
+
readonly maxPayloadLength: 10000;
|
|
36
43
|
};
|
|
37
44
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
45
|
+
* Serializes state iff it passes the exact validation the loader applies —
|
|
46
|
+
* including the whole-file size cap the loader enforces on read. `null`
|
|
47
|
+
* otherwise. The save paths hold this as an invariant: state that would be
|
|
48
|
+
* rejected on the next load must never be persisted — otherwise a single
|
|
49
|
+
* oversized save silently wipes everything at the next startup.
|
|
42
50
|
*/
|
|
51
|
+
export declare function serializePersistable(value: unknown): string | null;
|
|
52
|
+
/** Whether `serializePersistable` would accept the state. */
|
|
43
53
|
export declare function isPersistableState(value: unknown): value is PersistedMacroState;
|
|
44
54
|
/**
|
|
45
55
|
* Parses saved/imported state. `null` on any unexpected shape, oversized
|
package/dist/storage.js
CHANGED
|
@@ -18,6 +18,8 @@ export const IMPORT_LIMITS = {
|
|
|
18
18
|
/** Snippet text and single recorded insert-text step. */
|
|
19
19
|
maxTextLength: 100_000,
|
|
20
20
|
maxSourceLength: 200_000,
|
|
21
|
+
/** A recorded command payload, serialized. Engine payloads are small config objects. */
|
|
22
|
+
maxPayloadLength: 10_000,
|
|
21
23
|
};
|
|
22
24
|
function boundedString(value, maxLength, allowEmpty = false) {
|
|
23
25
|
return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0);
|
|
@@ -25,14 +27,26 @@ function boundedString(value, maxLength, allowEmpty = false) {
|
|
|
25
27
|
function optionalBoundedString(value, maxLength) {
|
|
26
28
|
return value === undefined || boundedString(value, maxLength);
|
|
27
29
|
}
|
|
30
|
+
/** Whether a recorded payload is JSON-clean and bounded. Opaque otherwise — but not unlimited. */
|
|
31
|
+
function isValidPayload(payload) {
|
|
32
|
+
if (payload === undefined)
|
|
33
|
+
return true;
|
|
34
|
+
let json;
|
|
35
|
+
try {
|
|
36
|
+
json = JSON.stringify(payload);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return typeof json === 'string' && json.length <= IMPORT_LIMITS.maxPayloadLength;
|
|
42
|
+
}
|
|
28
43
|
function isValidStep(value) {
|
|
29
44
|
if (typeof value !== 'object' || value === null)
|
|
30
45
|
return false;
|
|
31
46
|
const step = value;
|
|
32
47
|
switch (step.type) {
|
|
33
48
|
case 'command':
|
|
34
|
-
|
|
35
|
-
return boundedString(step.id, IMPORT_LIMITS.maxNameLength);
|
|
49
|
+
return boundedString(step.id, IMPORT_LIMITS.maxNameLength) && isValidPayload(step.payload);
|
|
36
50
|
case 'insert-text':
|
|
37
51
|
return boundedString(step.text, IMPORT_LIMITS.maxTextLength, true);
|
|
38
52
|
case 'insert-paragraph':
|
|
@@ -92,13 +106,27 @@ function isValidState(value) {
|
|
|
92
106
|
state.snippets.every(isValidSnippet));
|
|
93
107
|
}
|
|
94
108
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
109
|
+
* Serializes state iff it passes the exact validation the loader applies —
|
|
110
|
+
* including the whole-file size cap the loader enforces on read. `null`
|
|
111
|
+
* otherwise. The save paths hold this as an invariant: state that would be
|
|
112
|
+
* rejected on the next load must never be persisted — otherwise a single
|
|
113
|
+
* oversized save silently wipes everything at the next startup.
|
|
99
114
|
*/
|
|
115
|
+
export function serializePersistable(value) {
|
|
116
|
+
if (!isValidState(value))
|
|
117
|
+
return null;
|
|
118
|
+
let json;
|
|
119
|
+
try {
|
|
120
|
+
json = JSON.stringify(value);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
return json.length <= IMPORT_LIMITS.maxJsonLength ? json : null;
|
|
126
|
+
}
|
|
127
|
+
/** Whether `serializePersistable` would accept the state. */
|
|
100
128
|
export function isPersistableState(value) {
|
|
101
|
-
return
|
|
129
|
+
return serializePersistable(value) !== null;
|
|
102
130
|
}
|
|
103
131
|
/**
|
|
104
132
|
* Parses saved/imported state. `null` on any unexpected shape, oversized
|
|
@@ -142,10 +170,17 @@ export function createLocalStorage(key = DEFAULT_STORAGE_KEY, storage) {
|
|
|
142
170
|
},
|
|
143
171
|
save(state) {
|
|
144
172
|
try {
|
|
145
|
-
backing()
|
|
173
|
+
const store = backing();
|
|
174
|
+
if (!store)
|
|
175
|
+
return false;
|
|
176
|
+
store.setItem(key, JSON.stringify(state));
|
|
177
|
+
return true;
|
|
146
178
|
}
|
|
147
179
|
catch (error) {
|
|
180
|
+
// Quota or serialization — reported, not swallowed: the caller must
|
|
181
|
+
// know the change did not land.
|
|
148
182
|
console.warn('[superdoc-macros] saving macros failed', error);
|
|
183
|
+
return false;
|
|
149
184
|
}
|
|
150
185
|
},
|
|
151
186
|
};
|
|
@@ -157,6 +192,7 @@ export function createMemoryStorage() {
|
|
|
157
192
|
load: () => (saved ? JSON.parse(JSON.stringify(saved)) : null),
|
|
158
193
|
save(state) {
|
|
159
194
|
saved = JSON.parse(JSON.stringify(state));
|
|
195
|
+
return true;
|
|
160
196
|
},
|
|
161
197
|
};
|
|
162
198
|
}
|