superdoc-macros 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/dist/host/superdoc-host.js +39 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/manager.d.ts +58 -2
- package/dist/manager.js +245 -44
- package/dist/messages.d.ts +8 -0
- package/dist/messages.js +16 -0
- package/dist/recorder/recorder.d.ts +40 -15
- package/dist/recorder/recorder.js +116 -12
- package/dist/shortcuts.d.ts +19 -0
- package/dist/shortcuts.js +37 -1
- package/dist/snippets/autotext.d.ts +10 -1
- package/dist/snippets/autotext.js +15 -1
- package/dist/snippets/snippets.d.ts +6 -0
- package/dist/snippets/snippets.js +11 -5
- package/dist/storage.d.ts +18 -1
- package/dist/storage.js +48 -3
- package/dist/types.d.ts +16 -0
- package/package.json +1 -1
package/dist/messages.js
CHANGED
|
@@ -33,7 +33,15 @@ export const ENGLISH_MESSAGES = {
|
|
|
33
33
|
snippetNotFound: 'Snippet not found',
|
|
34
34
|
cannotRunWhileRecording: 'Cannot run a macro while recording',
|
|
35
35
|
anotherMacroRunning: 'Another macro is still running',
|
|
36
|
+
scriptsDisabled: 'Scripted macros are disabled',
|
|
36
37
|
invalidImport: 'The file is not a valid macro export',
|
|
38
|
+
importRejectedShortcut: (itemName, detail) => `Import rejected: the shortcut of "${itemName}" is not acceptable — ${detail}`,
|
|
39
|
+
importTooLarge: 'Import rejected: the merged result exceeds the item limits',
|
|
40
|
+
tooManyItems: 'The list is full — delete items before adding new ones',
|
|
41
|
+
nameRequired: 'A name is required',
|
|
42
|
+
fieldTooLong: (field, max) => `${field} is too long (limit: ${max} characters)`,
|
|
43
|
+
saveFailed: 'Saving failed — the change was not applied',
|
|
44
|
+
recordingTooLarge: 'The recording is too large to save',
|
|
37
45
|
shortcutInvalid: 'Invalid shortcut — use a form like Ctrl+Alt+M',
|
|
38
46
|
shortcutNeedsModifier: 'A shortcut must include Ctrl, Alt or Meta',
|
|
39
47
|
shortcutReserved: 'This shortcut is reserved by the editor',
|
|
@@ -64,7 +72,15 @@ export const HEBREW_MESSAGES = {
|
|
|
64
72
|
snippetNotFound: 'הקטע לא נמצא',
|
|
65
73
|
cannotRunWhileRecording: 'אי אפשר להריץ מאקרו בזמן הקלטה',
|
|
66
74
|
anotherMacroRunning: 'מאקרו אחר עדיין רץ',
|
|
75
|
+
scriptsDisabled: 'מאקרו כתובים מושבתים',
|
|
67
76
|
invalidImport: 'הקובץ אינו ייצוא מאקרו תקין',
|
|
77
|
+
importRejectedShortcut: (itemName, detail) => `הייבוא נדחה: הקיצור של "${itemName}" אינו קביל — ${detail}`,
|
|
78
|
+
importTooLarge: 'הייבוא נדחה: התוצאה הממוזגת חורגת מתקרת הפריטים',
|
|
79
|
+
tooManyItems: 'הרשימה מלאה — יש למחוק פריטים לפני הוספה',
|
|
80
|
+
nameRequired: 'חובה לתת שם',
|
|
81
|
+
fieldTooLong: (field, max) => `${field} ארוך מדי (התקרה: ${max} תווים)`,
|
|
82
|
+
saveFailed: 'השמירה נכשלה — השינוי לא הוחל',
|
|
83
|
+
recordingTooLarge: 'ההקלטה גדולה מכדי להישמר',
|
|
68
84
|
shortcutInvalid: 'קיצור לא תקין — הצורה הנדרשת היא למשל Ctrl+Alt+M',
|
|
69
85
|
shortcutNeedsModifier: 'קיצור חייב לכלול Ctrl, Alt או Meta',
|
|
70
86
|
shortcutReserved: 'הקיצור הזה שמור לעורך',
|
|
@@ -1,41 +1,66 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A Word-style macro recorder: records **commands** and typing, not caret
|
|
3
|
-
* positions.
|
|
4
|
-
*
|
|
5
|
-
* That choice is deliberate. Recording raw ProseMirror steps (with absolute
|
|
6
|
-
* positions) breaks the moment the document differs from what it was at
|
|
7
|
-
* recording time; recording commands ("bold", "bullet-list", typing a
|
|
8
|
-
* greeting) behaves like Word's recorder — the actions apply wherever the
|
|
9
|
-
* caret is at replay time. It is also what makes a recording saveable and
|
|
10
|
-
* shareable: the steps are plain JSON.
|
|
11
|
-
*
|
|
12
|
-
* What is not recorded: caret movement and mouse selection. As in Word, a
|
|
13
|
-
* recorded macro acts from wherever the caret stands when it runs.
|
|
14
|
-
*/
|
|
15
1
|
import type { MacroHost, MacroStep } from '../types.js';
|
|
16
2
|
export interface RecorderOptions {
|
|
17
3
|
/** Command filter. The default records everything except undo/redo. */
|
|
18
4
|
shouldRecordCommand?: (id: string) => boolean;
|
|
19
5
|
/** Step cap per recording, against a recording left running by mistake. */
|
|
20
6
|
maxSteps?: number;
|
|
7
|
+
/**
|
|
8
|
+
* Called when the step cap stops the recording. The steps are kept —
|
|
9
|
+
* `stop()` still returns them — but listening has ceased, and a UI that
|
|
10
|
+
* shows "recording" must be told, or its indicator would keep promising a
|
|
11
|
+
* recording that is no longer happening.
|
|
12
|
+
*/
|
|
13
|
+
onAutoStop?: () => void;
|
|
21
14
|
}
|
|
22
15
|
export declare class MacroRecorder {
|
|
23
16
|
private readonly host;
|
|
24
17
|
private readonly shouldRecordCommand;
|
|
25
18
|
private readonly maxSteps;
|
|
19
|
+
private readonly onAutoStop?;
|
|
26
20
|
private steps;
|
|
27
21
|
private disposers;
|
|
28
22
|
private active;
|
|
23
|
+
/**
|
|
24
|
+
* Set by a caret move: the next typed character starts a fresh step
|
|
25
|
+
* instead of coalescing — text typed at a new position is not a
|
|
26
|
+
* continuation of the text typed at the old one.
|
|
27
|
+
*/
|
|
28
|
+
private tailInterrupted;
|
|
29
29
|
constructor(host: MacroHost, options?: RecorderOptions);
|
|
30
30
|
get recording(): boolean;
|
|
31
31
|
get stepCount(): number;
|
|
32
32
|
start(): void;
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Stops and returns the steps. Empty when nothing was recorded. Also the
|
|
35
|
+
* way to collect a recording that auto-stopped at the cap — the steps are
|
|
36
|
+
* kept until someone asks for them.
|
|
37
|
+
*/
|
|
34
38
|
stop(): MacroStep[];
|
|
35
39
|
/** Stops and discards whatever was recorded. */
|
|
36
40
|
cancel(): void;
|
|
41
|
+
/**
|
|
42
|
+
* Rewrites the recorded tail after an auto-text expansion: the user typed
|
|
43
|
+
* a trigger word plus the expansion character, but what the document now
|
|
44
|
+
* holds is the expanded text — a replay of the raw keystrokes would
|
|
45
|
+
* diverge (and would depend on auto-text being active at replay time).
|
|
46
|
+
* The trailing `consumed` characters are removed from the recorded
|
|
47
|
+
* insert-text steps and the expanded text is recorded in their place.
|
|
48
|
+
*
|
|
49
|
+
* If the tail does not hold `consumed` plain characters (a command landed
|
|
50
|
+
* mid-word, or the recording started mid-trigger), the rewrite is skipped
|
|
51
|
+
* and the raw keystrokes stay — a truthful raw recording beats a guessed
|
|
52
|
+
* edit of steps that do not match.
|
|
53
|
+
*/
|
|
54
|
+
applyAutoTextExpansion(consumed: number, replacement: string): void;
|
|
37
55
|
private teardown;
|
|
38
56
|
private push;
|
|
57
|
+
/**
|
|
58
|
+
* Records a programmatic insertion the host will not report as typing —
|
|
59
|
+
* e.g. a snippet expanded from a button or shortcut, which writes through
|
|
60
|
+
* the document API and never fires beforeinput. Without this, a replay
|
|
61
|
+
* would silently miss text the user watched appear.
|
|
62
|
+
*/
|
|
63
|
+
recordInsert(text: string): void;
|
|
39
64
|
private recordCommand;
|
|
40
65
|
private recordTextInput;
|
|
41
66
|
}
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Word-style macro recorder: records **commands** and typing, not caret
|
|
3
|
+
* positions.
|
|
4
|
+
*
|
|
5
|
+
* That choice is deliberate. Recording raw ProseMirror steps (with absolute
|
|
6
|
+
* positions) breaks the moment the document differs from what it was at
|
|
7
|
+
* recording time; recording commands ("bold", "bullet-list", typing a
|
|
8
|
+
* greeting) behaves like Word's recorder — the actions apply wherever the
|
|
9
|
+
* caret is at replay time. It is also what makes a recording saveable and
|
|
10
|
+
* shareable: the steps are plain JSON.
|
|
11
|
+
*
|
|
12
|
+
* What is not recorded: caret movement and mouse selection. As in Word, a
|
|
13
|
+
* recorded macro acts from wherever the caret stands when it runs.
|
|
14
|
+
*/
|
|
15
|
+
import { IMPORT_LIMITS } from '../storage.js';
|
|
1
16
|
const DEFAULT_MAX_STEPS = 5_000;
|
|
2
17
|
/** Undo/redo during recording fix the recording itself — replaying them would replay the mistake too. */
|
|
3
18
|
function defaultShouldRecord(id) {
|
|
@@ -7,13 +22,21 @@ export class MacroRecorder {
|
|
|
7
22
|
host;
|
|
8
23
|
shouldRecordCommand;
|
|
9
24
|
maxSteps;
|
|
25
|
+
onAutoStop;
|
|
10
26
|
steps = [];
|
|
11
27
|
disposers = [];
|
|
12
28
|
active = false;
|
|
29
|
+
/**
|
|
30
|
+
* Set by a caret move: the next typed character starts a fresh step
|
|
31
|
+
* instead of coalescing — text typed at a new position is not a
|
|
32
|
+
* continuation of the text typed at the old one.
|
|
33
|
+
*/
|
|
34
|
+
tailInterrupted = false;
|
|
13
35
|
constructor(host, options = {}) {
|
|
14
36
|
this.host = host;
|
|
15
37
|
this.shouldRecordCommand = options.shouldRecordCommand ?? defaultShouldRecord;
|
|
16
38
|
this.maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
|
|
39
|
+
this.onAutoStop = options.onAutoStop;
|
|
17
40
|
}
|
|
18
41
|
get recording() {
|
|
19
42
|
return this.active;
|
|
@@ -31,10 +54,12 @@ export class MacroRecorder {
|
|
|
31
54
|
this.host.onTextInput((event) => this.recordTextInput(event)),
|
|
32
55
|
];
|
|
33
56
|
}
|
|
34
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* Stops and returns the steps. Empty when nothing was recorded. Also the
|
|
59
|
+
* way to collect a recording that auto-stopped at the cap — the steps are
|
|
60
|
+
* kept until someone asks for them.
|
|
61
|
+
*/
|
|
35
62
|
stop() {
|
|
36
|
-
if (!this.active)
|
|
37
|
-
return [];
|
|
38
63
|
this.teardown();
|
|
39
64
|
const recorded = this.steps;
|
|
40
65
|
this.steps = [];
|
|
@@ -42,34 +67,108 @@ export class MacroRecorder {
|
|
|
42
67
|
}
|
|
43
68
|
/** Stops and discards whatever was recorded. */
|
|
44
69
|
cancel() {
|
|
45
|
-
if (!this.active)
|
|
46
|
-
return;
|
|
47
70
|
this.teardown();
|
|
48
71
|
this.steps = [];
|
|
49
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Rewrites the recorded tail after an auto-text expansion: the user typed
|
|
75
|
+
* a trigger word plus the expansion character, but what the document now
|
|
76
|
+
* holds is the expanded text — a replay of the raw keystrokes would
|
|
77
|
+
* diverge (and would depend on auto-text being active at replay time).
|
|
78
|
+
* The trailing `consumed` characters are removed from the recorded
|
|
79
|
+
* insert-text steps and the expanded text is recorded in their place.
|
|
80
|
+
*
|
|
81
|
+
* If the tail does not hold `consumed` plain characters (a command landed
|
|
82
|
+
* mid-word, or the recording started mid-trigger), the rewrite is skipped
|
|
83
|
+
* and the raw keystrokes stay — a truthful raw recording beats a guessed
|
|
84
|
+
* edit of steps that do not match.
|
|
85
|
+
*/
|
|
86
|
+
applyAutoTextExpansion(consumed, replacement) {
|
|
87
|
+
if (!this.active || consumed <= 0)
|
|
88
|
+
return;
|
|
89
|
+
// Verify the tail is entirely typed text before touching anything.
|
|
90
|
+
let remaining = consumed;
|
|
91
|
+
let index = this.steps.length - 1;
|
|
92
|
+
while (remaining > 0 && index >= 0) {
|
|
93
|
+
const step = this.steps[index];
|
|
94
|
+
if (step?.type !== 'insert-text')
|
|
95
|
+
return;
|
|
96
|
+
remaining -= step.text.length;
|
|
97
|
+
index -= 1;
|
|
98
|
+
}
|
|
99
|
+
if (remaining > 0)
|
|
100
|
+
return;
|
|
101
|
+
let toRemove = consumed;
|
|
102
|
+
while (toRemove > 0) {
|
|
103
|
+
const last = this.steps[this.steps.length - 1];
|
|
104
|
+
if (last?.type !== 'insert-text')
|
|
105
|
+
return; // unreachable after the check above
|
|
106
|
+
if (last.text.length > toRemove) {
|
|
107
|
+
last.text = last.text.slice(0, -toRemove);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
toRemove -= last.text.length;
|
|
111
|
+
this.steps.pop();
|
|
112
|
+
}
|
|
113
|
+
this.recordTextInput({ kind: 'insert-text', text: replacement });
|
|
114
|
+
}
|
|
50
115
|
teardown() {
|
|
51
116
|
this.active = false;
|
|
52
117
|
for (const dispose of this.disposers.splice(0))
|
|
53
118
|
dispose();
|
|
54
119
|
}
|
|
55
120
|
push(step) {
|
|
121
|
+
this.steps.push(step);
|
|
56
122
|
if (this.steps.length >= this.maxSteps) {
|
|
123
|
+
// The cap stops the *listening*, not the data: the steps stay for
|
|
124
|
+
// stop() to collect, and the owner is told the recording ended.
|
|
57
125
|
this.teardown();
|
|
58
|
-
|
|
126
|
+
this.onAutoStop?.();
|
|
59
127
|
}
|
|
60
|
-
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Records a programmatic insertion the host will not report as typing —
|
|
131
|
+
* e.g. a snippet expanded from a button or shortcut, which writes through
|
|
132
|
+
* the document API and never fires beforeinput. Without this, a replay
|
|
133
|
+
* would silently miss text the user watched appear.
|
|
134
|
+
*/
|
|
135
|
+
recordInsert(text) {
|
|
136
|
+
if (!this.active || text.length === 0)
|
|
137
|
+
return;
|
|
138
|
+
this.recordTextInput({ kind: 'insert-text', text });
|
|
61
139
|
}
|
|
62
140
|
recordCommand(id, payload) {
|
|
63
141
|
if (!this.shouldRecordCommand(id))
|
|
64
142
|
return;
|
|
65
|
-
|
|
143
|
+
if (payload === undefined) {
|
|
144
|
+
this.push({ type: 'command', id });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// The payload is opaque engine data, but not unlimited: it must survive
|
|
148
|
+
// a JSON round-trip within the persistence cap, or the recording would
|
|
149
|
+
// be rejected by the loader. A command whose payload cannot be kept
|
|
150
|
+
// faithfully is skipped whole — replaying it with a mangled payload
|
|
151
|
+
// would do something other than what was recorded.
|
|
152
|
+
let json;
|
|
153
|
+
try {
|
|
154
|
+
json = JSON.stringify(payload);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (typeof json !== 'string' || json.length > IMPORT_LIMITS.maxPayloadLength)
|
|
160
|
+
return;
|
|
161
|
+
this.push({ type: 'command', id, payload: JSON.parse(json) });
|
|
66
162
|
}
|
|
67
163
|
recordTextInput(event) {
|
|
164
|
+
const interrupted = this.tailInterrupted;
|
|
165
|
+
this.tailInterrupted = false;
|
|
68
166
|
const last = this.steps[this.steps.length - 1];
|
|
69
167
|
switch (event.kind) {
|
|
70
168
|
case 'insert-text': {
|
|
71
|
-
// Consecutive keystrokes coalesce into one step — more readable,
|
|
72
|
-
|
|
169
|
+
// Consecutive keystrokes coalesce into one step — more readable,
|
|
170
|
+
// faster to replay — but never across a caret move.
|
|
171
|
+
if (!interrupted && last?.type === 'insert-text') {
|
|
73
172
|
last.text += event.text;
|
|
74
173
|
return;
|
|
75
174
|
}
|
|
@@ -80,7 +179,7 @@ export class MacroRecorder {
|
|
|
80
179
|
this.push({ type: 'insert-paragraph' });
|
|
81
180
|
return;
|
|
82
181
|
case 'delete-backward': {
|
|
83
|
-
if (last?.type === 'delete-backward') {
|
|
182
|
+
if (!interrupted && last?.type === 'delete-backward') {
|
|
84
183
|
last.count += 1;
|
|
85
184
|
return;
|
|
86
185
|
}
|
|
@@ -88,13 +187,18 @@ export class MacroRecorder {
|
|
|
88
187
|
return;
|
|
89
188
|
}
|
|
90
189
|
case 'delete-forward': {
|
|
91
|
-
if (last?.type === 'delete-forward') {
|
|
190
|
+
if (!interrupted && last?.type === 'delete-forward') {
|
|
92
191
|
last.count += 1;
|
|
93
192
|
return;
|
|
94
193
|
}
|
|
95
194
|
this.push({ type: 'delete-forward', count: 1 });
|
|
96
195
|
return;
|
|
97
196
|
}
|
|
197
|
+
case 'caret-moved':
|
|
198
|
+
// Not a step — replay acts from the live caret — but the recorded
|
|
199
|
+
// tail is no longer "where the user is typing".
|
|
200
|
+
this.tailInterrupted = true;
|
|
201
|
+
return;
|
|
98
202
|
}
|
|
99
203
|
}
|
|
100
204
|
}
|
package/dist/shortcuts.d.ts
CHANGED
|
@@ -16,14 +16,33 @@ export interface ParsedShortcut {
|
|
|
16
16
|
/** The subset of KeyboardEvent that matching needs. Enables DOM-free tests. */
|
|
17
17
|
export interface KeyEventLike {
|
|
18
18
|
key: string;
|
|
19
|
+
/** The physical key. When present, letters and digits match by it — see `eventMatches`. */
|
|
20
|
+
code?: string;
|
|
19
21
|
ctrlKey: boolean;
|
|
20
22
|
altKey: boolean;
|
|
21
23
|
shiftKey: boolean;
|
|
22
24
|
metaKey: boolean;
|
|
25
|
+
/** Key held down — auto-repeat must not re-fire a macro. */
|
|
26
|
+
repeat?: boolean;
|
|
27
|
+
/** Mid-IME-composition — keys belong to the composition, not to bindings. */
|
|
28
|
+
isComposing?: boolean;
|
|
23
29
|
preventDefault?(): void;
|
|
24
30
|
stopPropagation?(): void;
|
|
25
31
|
}
|
|
26
32
|
export declare function parseShortcut(shortcut: string): ParsedShortcut | null;
|
|
33
|
+
/**
|
|
34
|
+
* The physical `event.code` values a binding key stands for. Letters and
|
|
35
|
+
* digits get a deterministic mapping; anything else returns empty and falls
|
|
36
|
+
* back to `event.key`.
|
|
37
|
+
*
|
|
38
|
+
* Physical-key matching is what keeps a binding alive across keyboard
|
|
39
|
+
* layouts: on a Hebrew layout Ctrl+Alt+R reports `key: 'ר'`, and a
|
|
40
|
+
* key-based match would die the moment the user switches to Hebrew — the
|
|
41
|
+
* exact bug the host editor once had with its own shortcuts.
|
|
42
|
+
*/
|
|
43
|
+
export declare function codesForKey(key: string): readonly string[];
|
|
44
|
+
/** Whether the key can be bound reliably (has a physical-code mapping). */
|
|
45
|
+
export declare function isBindableKey(parsed: ParsedShortcut): boolean;
|
|
27
46
|
export declare function eventMatches(parsed: ParsedShortcut, event: KeyEventLike): boolean;
|
|
28
47
|
/**
|
|
29
48
|
* Comparable signatures for collision checks. `Mod` matches either Ctrl or
|
package/dist/shortcuts.js
CHANGED
|
@@ -44,8 +44,40 @@ function normalizeKey(key) {
|
|
|
44
44
|
return 'escape';
|
|
45
45
|
return lower;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* The physical `event.code` values a binding key stands for. Letters and
|
|
49
|
+
* digits get a deterministic mapping; anything else returns empty and falls
|
|
50
|
+
* back to `event.key`.
|
|
51
|
+
*
|
|
52
|
+
* Physical-key matching is what keeps a binding alive across keyboard
|
|
53
|
+
* layouts: on a Hebrew layout Ctrl+Alt+R reports `key: 'ר'`, and a
|
|
54
|
+
* key-based match would die the moment the user switches to Hebrew — the
|
|
55
|
+
* exact bug the host editor once had with its own shortcuts.
|
|
56
|
+
*/
|
|
57
|
+
export function codesForKey(key) {
|
|
58
|
+
if (/^[a-z]$/.test(key))
|
|
59
|
+
return [`Key${key.toUpperCase()}`];
|
|
60
|
+
if (/^[0-9]$/.test(key))
|
|
61
|
+
return [`Digit${key}`, `Numpad${key}`];
|
|
62
|
+
if (/^f([1-9]|1[0-2])$/.test(key))
|
|
63
|
+
return [key.toUpperCase()];
|
|
64
|
+
if (key === ' ')
|
|
65
|
+
return ['Space'];
|
|
66
|
+
if (key === 'escape')
|
|
67
|
+
return ['Escape'];
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
/** Whether the key can be bound reliably (has a physical-code mapping). */
|
|
71
|
+
export function isBindableKey(parsed) {
|
|
72
|
+
return codesForKey(parsed.key).length > 0;
|
|
73
|
+
}
|
|
47
74
|
export function eventMatches(parsed, event) {
|
|
48
|
-
|
|
75
|
+
const codes = codesForKey(parsed.key);
|
|
76
|
+
const keyMatched = normalizeKey(event.key) === parsed.key;
|
|
77
|
+
// The physical code decides whenever both sides have one; `event.key` is
|
|
78
|
+
// the fallback for keys with no mapping or hosts that do not report codes.
|
|
79
|
+
const matched = codes.length > 0 && event.code !== undefined ? codes.includes(event.code) : keyMatched;
|
|
80
|
+
if (!matched)
|
|
49
81
|
return false;
|
|
50
82
|
if (parsed.mod) {
|
|
51
83
|
if (!event.ctrlKey && !event.metaKey)
|
|
@@ -84,6 +116,10 @@ export function hasBindingModifier(parsed) {
|
|
|
84
116
|
*/
|
|
85
117
|
export function bindShortcuts(target, getBindings) {
|
|
86
118
|
const listener = (event) => {
|
|
119
|
+
// Auto-repeat must not replay a macro per repeat tick, and keys mid-IME
|
|
120
|
+
// composition belong to the composition.
|
|
121
|
+
if (event.repeat || event.isComposing)
|
|
122
|
+
return;
|
|
87
123
|
for (const binding of getBindings()) {
|
|
88
124
|
const parsed = parseShortcut(binding.shortcut);
|
|
89
125
|
if (!parsed || !eventMatches(parsed, event))
|
|
@@ -15,13 +15,22 @@
|
|
|
15
15
|
* better than an expansion that deletes the wrong text.
|
|
16
16
|
*/
|
|
17
17
|
import type { MacroHost, Snippet } from '../types.js';
|
|
18
|
+
/** What an expansion actually did — what a recorder needs to stay truthful. */
|
|
19
|
+
export interface AutoTextExpansion {
|
|
20
|
+
/** The trigger word the user typed. */
|
|
21
|
+
trigger: string;
|
|
22
|
+
/** The character that fired the expansion (and was restored at the end). */
|
|
23
|
+
expandChar: string;
|
|
24
|
+
/** The rendered snippet text that replaced the trigger. */
|
|
25
|
+
rendered: string;
|
|
26
|
+
}
|
|
18
27
|
export interface AutoTextOptions {
|
|
19
28
|
/** The expansion characters. Default: space only. */
|
|
20
29
|
expandOn?: readonly string[];
|
|
21
30
|
/** Buffer size. A trigger word longer than this will not be recognized. */
|
|
22
31
|
bufferSize?: number;
|
|
23
32
|
/** Called after a successful expansion. */
|
|
24
|
-
onExpand?: (snippet: Snippet) => void;
|
|
33
|
+
onExpand?: (snippet: Snippet, expansion: AutoTextExpansion) => void;
|
|
25
34
|
/** Called when an expansion failed (e.g. a read-only document). */
|
|
26
35
|
onError?: (message: string) => void;
|
|
27
36
|
}
|
|
@@ -50,6 +50,12 @@ export class AutoText {
|
|
|
50
50
|
case 'delete-forward':
|
|
51
51
|
this.buffer = '';
|
|
52
52
|
return;
|
|
53
|
+
// A click or navigation key moved the caret: the buffer no longer
|
|
54
|
+
// describes what sits before it, and expanding on it would delete
|
|
55
|
+
// text at the new position. Missing an expansion is the cheap error.
|
|
56
|
+
case 'caret-moved':
|
|
57
|
+
this.buffer = '';
|
|
58
|
+
return;
|
|
53
59
|
case 'delete-backward':
|
|
54
60
|
this.buffer = this.buffer.slice(0, -1);
|
|
55
61
|
return;
|
|
@@ -78,6 +84,14 @@ export class AutoText {
|
|
|
78
84
|
// to the document. Deferring to the task queue guarantees the expansion
|
|
79
85
|
// character is already in before it is deleted along with the trigger.
|
|
80
86
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
87
|
+
// Second line of defense, independent of the event stream: the
|
|
88
|
+
// document itself must hold the trigger right before the caret. A
|
|
89
|
+
// caret move the host failed to report (or a race with another
|
|
90
|
+
// writer) is caught here instead of deleting foreign text.
|
|
91
|
+
const expected = trigger + expandChar;
|
|
92
|
+
const actual = await this.host.getTextBefore?.(expected.length);
|
|
93
|
+
if (typeof actual === 'string' && actual !== expected)
|
|
94
|
+
return;
|
|
81
95
|
const selectionText = usesSelection(snippet.text)
|
|
82
96
|
? (await this.host.getSelection({ includeText: true })).text
|
|
83
97
|
: undefined;
|
|
@@ -94,7 +108,7 @@ export class AutoText {
|
|
|
94
108
|
this.onError?.(inserted.message);
|
|
95
109
|
return;
|
|
96
110
|
}
|
|
97
|
-
this.onExpand?.(snippet);
|
|
111
|
+
this.onExpand?.(snippet, { trigger, expandChar, rendered });
|
|
98
112
|
}
|
|
99
113
|
finally {
|
|
100
114
|
this.busy = false;
|
|
@@ -26,5 +26,11 @@ export interface ExpandOptions {
|
|
|
26
26
|
now?: Date;
|
|
27
27
|
locale?: string;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Renders a snippet against the live document (reads the selection only
|
|
31
|
+
* when the snippet needs it). Split from the insertion so a caller that
|
|
32
|
+
* must know what text actually landed — e.g. a recorder — can.
|
|
33
|
+
*/
|
|
34
|
+
export declare function renderSnippetForHost(host: MacroHost, snippet: Pick<Snippet, 'text'>, options?: ExpandOptions): Promise<string>;
|
|
29
35
|
/** Expands a snippet at the caret. */
|
|
30
36
|
export declare function expandSnippet(host: MacroHost, snippet: Pick<Snippet, 'text'>, options?: ExpandOptions): Promise<MacroOutcome>;
|
|
@@ -24,17 +24,23 @@ export function renderSnippet(text, context = {}) {
|
|
|
24
24
|
export function usesSelection(text) {
|
|
25
25
|
return /\{\{\s*selection\s*\}\}/iu.test(text);
|
|
26
26
|
}
|
|
27
|
-
/**
|
|
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,7 +38,19 @@ export declare const IMPORT_LIMITS: {
|
|
|
33
38
|
/** Snippet text and single recorded insert-text step. */
|
|
34
39
|
readonly maxTextLength: 100000;
|
|
35
40
|
readonly maxSourceLength: 200000;
|
|
41
|
+
/** A recorded command payload, serialized. Engine payloads are small config objects. */
|
|
42
|
+
readonly maxPayloadLength: 10000;
|
|
36
43
|
};
|
|
44
|
+
/**
|
|
45
|
+
* Serializes state iff it passes the exact validation the loader applies —
|
|
46
|
+
* including the whole-file size cap the loader enforces on read. `null`
|
|
47
|
+
* otherwise. The save paths hold this as an invariant: state that would be
|
|
48
|
+
* rejected on the next load must never be persisted — otherwise a single
|
|
49
|
+
* oversized save silently wipes everything at the next startup.
|
|
50
|
+
*/
|
|
51
|
+
export declare function serializePersistable(value: unknown): string | null;
|
|
52
|
+
/** Whether `serializePersistable` would accept the state. */
|
|
53
|
+
export declare function isPersistableState(value: unknown): value is PersistedMacroState;
|
|
37
54
|
/**
|
|
38
55
|
* Parses saved/imported state. `null` on any unexpected shape, oversized
|
|
39
56
|
* field or oversized file — never throws, never partially accepts: one
|
package/dist/storage.js
CHANGED
|
@@ -18,6 +18,8 @@ export const IMPORT_LIMITS = {
|
|
|
18
18
|
/** Snippet text and single recorded insert-text step. */
|
|
19
19
|
maxTextLength: 100_000,
|
|
20
20
|
maxSourceLength: 200_000,
|
|
21
|
+
/** A recorded command payload, serialized. Engine payloads are small config objects. */
|
|
22
|
+
maxPayloadLength: 10_000,
|
|
21
23
|
};
|
|
22
24
|
function boundedString(value, maxLength, allowEmpty = false) {
|
|
23
25
|
return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0);
|
|
@@ -25,14 +27,26 @@ function boundedString(value, maxLength, allowEmpty = false) {
|
|
|
25
27
|
function optionalBoundedString(value, maxLength) {
|
|
26
28
|
return value === undefined || boundedString(value, maxLength);
|
|
27
29
|
}
|
|
30
|
+
/** Whether a recorded payload is JSON-clean and bounded. Opaque otherwise — but not unlimited. */
|
|
31
|
+
function isValidPayload(payload) {
|
|
32
|
+
if (payload === undefined)
|
|
33
|
+
return true;
|
|
34
|
+
let json;
|
|
35
|
+
try {
|
|
36
|
+
json = JSON.stringify(payload);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return typeof json === 'string' && json.length <= IMPORT_LIMITS.maxPayloadLength;
|
|
42
|
+
}
|
|
28
43
|
function isValidStep(value) {
|
|
29
44
|
if (typeof value !== 'object' || value === null)
|
|
30
45
|
return false;
|
|
31
46
|
const step = value;
|
|
32
47
|
switch (step.type) {
|
|
33
48
|
case 'command':
|
|
34
|
-
|
|
35
|
-
return boundedString(step.id, IMPORT_LIMITS.maxNameLength);
|
|
49
|
+
return boundedString(step.id, IMPORT_LIMITS.maxNameLength) && isValidPayload(step.payload);
|
|
36
50
|
case 'insert-text':
|
|
37
51
|
return boundedString(step.text, IMPORT_LIMITS.maxTextLength, true);
|
|
38
52
|
case 'insert-paragraph':
|
|
@@ -91,6 +105,29 @@ function isValidState(value) {
|
|
|
91
105
|
state.snippets.length <= IMPORT_LIMITS.maxItems &&
|
|
92
106
|
state.snippets.every(isValidSnippet));
|
|
93
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Serializes state iff it passes the exact validation the loader applies —
|
|
110
|
+
* including the whole-file size cap the loader enforces on read. `null`
|
|
111
|
+
* otherwise. The save paths hold this as an invariant: state that would be
|
|
112
|
+
* rejected on the next load must never be persisted — otherwise a single
|
|
113
|
+
* oversized save silently wipes everything at the next startup.
|
|
114
|
+
*/
|
|
115
|
+
export function serializePersistable(value) {
|
|
116
|
+
if (!isValidState(value))
|
|
117
|
+
return null;
|
|
118
|
+
let json;
|
|
119
|
+
try {
|
|
120
|
+
json = JSON.stringify(value);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
return json.length <= IMPORT_LIMITS.maxJsonLength ? json : null;
|
|
126
|
+
}
|
|
127
|
+
/** Whether `serializePersistable` would accept the state. */
|
|
128
|
+
export function isPersistableState(value) {
|
|
129
|
+
return serializePersistable(value) !== null;
|
|
130
|
+
}
|
|
94
131
|
/**
|
|
95
132
|
* Parses saved/imported state. `null` on any unexpected shape, oversized
|
|
96
133
|
* field or oversized file — never throws, never partially accepts: one
|
|
@@ -133,10 +170,17 @@ export function createLocalStorage(key = DEFAULT_STORAGE_KEY, storage) {
|
|
|
133
170
|
},
|
|
134
171
|
save(state) {
|
|
135
172
|
try {
|
|
136
|
-
backing()
|
|
173
|
+
const store = backing();
|
|
174
|
+
if (!store)
|
|
175
|
+
return false;
|
|
176
|
+
store.setItem(key, JSON.stringify(state));
|
|
177
|
+
return true;
|
|
137
178
|
}
|
|
138
179
|
catch (error) {
|
|
180
|
+
// Quota or serialization — reported, not swallowed: the caller must
|
|
181
|
+
// know the change did not land.
|
|
139
182
|
console.warn('[superdoc-macros] saving macros failed', error);
|
|
183
|
+
return false;
|
|
140
184
|
}
|
|
141
185
|
},
|
|
142
186
|
};
|
|
@@ -148,6 +192,7 @@ export function createMemoryStorage() {
|
|
|
148
192
|
load: () => (saved ? JSON.parse(JSON.stringify(saved)) : null),
|
|
149
193
|
save(state) {
|
|
150
194
|
saved = JSON.parse(JSON.stringify(state));
|
|
195
|
+
return true;
|
|
151
196
|
},
|
|
152
197
|
};
|
|
153
198
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -38,6 +38,15 @@ export type TextInputEvent = {
|
|
|
38
38
|
kind: 'delete-backward';
|
|
39
39
|
} | {
|
|
40
40
|
kind: 'delete-forward';
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The caret moved by pointer or navigation keys. Not an edit — the
|
|
44
|
+
* recorder records no step — but both consumers depend on it: auto-text
|
|
45
|
+
* resets its typed-word buffer (the buffer no longer describes what sits
|
|
46
|
+
* before the caret), and the recorder stops coalescing across it.
|
|
47
|
+
*/
|
|
48
|
+
| {
|
|
49
|
+
kind: 'caret-moved';
|
|
41
50
|
};
|
|
42
51
|
/**
|
|
43
52
|
* What the toolkit needs from the editor. The SuperDoc v2 implementation is
|
|
@@ -62,6 +71,13 @@ export interface MacroHost {
|
|
|
62
71
|
getSelection(options?: {
|
|
63
72
|
includeText?: boolean;
|
|
64
73
|
}): Promise<SelectionSnapshot>;
|
|
74
|
+
/**
|
|
75
|
+
* The `count` characters immediately before the caret, or `null` when the
|
|
76
|
+
* host cannot tell. Auto-text verifies the document actually holds the
|
|
77
|
+
* trigger word before deleting it — the buffer alone can lie after a
|
|
78
|
+
* caret move the host failed to report.
|
|
79
|
+
*/
|
|
80
|
+
getTextBefore?(count: number): Promise<string | null>;
|
|
65
81
|
/** Replaces every occurrence of `query` with `replacement`. Returns how many were replaced. */
|
|
66
82
|
replaceAll(query: string, replacement: string): Promise<{
|
|
67
83
|
ok: boolean;
|