superdoc-macros 0.5.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 +2 -2
- package/dist/host/superdoc-host.js +39 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/manager.d.ts +21 -2
- package/dist/manager.js +109 -58
- package/dist/messages.d.ts +2 -0
- package/dist/messages.js +4 -0
- package/dist/recorder/recorder.d.ts +13 -14
- package/dist/recorder/recorder.js +63 -5
- package/dist/shortcuts.d.ts +19 -0
- package/dist/shortcuts.js +37 -1
- package/dist/snippets/autotext.js +14 -0
- 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 +16 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -140,11 +140,11 @@ kit.importState(json, { merge: true });
|
|
|
140
140
|
|
|
141
141
|
Imports are strictly validated **atomically on the final result**: every item and every recorded step is type-checked and size-bounded (see `IMPORT_LIMITS`), every shortcut in the merged state must pass the same binding rules the save paths enforce (modifier required, not host-reserved, no duplicates), and any failure rejects the whole file with the current state untouched — no partial imports.
|
|
142
142
|
|
|
143
|
-
The same limits hold as a persistence invariant:
|
|
143
|
+
The same limits hold as a persistence invariant, transactionally: every mutation runs on a clone that must serialize under the loader's exact rules (shape, field caps, whole-file size) *and* be accepted by the storage before it becomes the state — a quota failure or an oversized save leaves memory and disk agreeing on the previous state, and can never wipe the store on the next startup. An oversized recorded paste is split into loadable steps; a recording that cannot be saved whole is refused with a message rather than saved partially.
|
|
144
144
|
|
|
145
145
|
## Shortcut safety
|
|
146
146
|
|
|
147
|
-
Saved bindings go through `kit.validateShortcut(shortcut, excludeId?)` — enforced on every save: a binding must parse, must carry a real modifier (Ctrl/Alt/Meta — a bare letter would fire on ordinary typing), must not collide with another saved item, and must not collide with shortcuts the host declared as reserved:
|
|
147
|
+
Saved bindings go through `kit.validateShortcut(shortcut, excludeId?)` — enforced on every save and on the merged result of every import: a binding must parse, must carry a real modifier (Ctrl/Alt/Meta — a bare letter would fire on ordinary typing), must use a physically-mappable key (letters, digits, F-keys — matching is by `event.code`, so bindings survive non-Latin keyboard layouts), must not collide with another saved item, and must not collide with shortcuts the host declared as reserved. Auto-repeat and keys mid-IME-composition never fire bindings:
|
|
148
148
|
|
|
149
149
|
```ts
|
|
150
150
|
const kit = new MacroKit({ host, reservedShortcuts: ['Ctrl+S', 'Ctrl+P', /* … the editor's registry … */] });
|
|
@@ -113,6 +113,25 @@ export function createSuperdocHost(options) {
|
|
|
113
113
|
if (typeof data === 'string' && data.length > 0)
|
|
114
114
|
emitInput({ kind: 'insert-text', text: data });
|
|
115
115
|
};
|
|
116
|
+
/* Caret movement: a click or a navigation key breaks the link between the
|
|
117
|
+
recently-typed characters and what actually sits before the caret.
|
|
118
|
+
Auto-text resets its buffer on this, and the recorder stops coalescing
|
|
119
|
+
across it. Reported as an input event so any MacroHost can supply it. */
|
|
120
|
+
const NAVIGATION_KEYS = new Set([
|
|
121
|
+
'ArrowLeft',
|
|
122
|
+
'ArrowRight',
|
|
123
|
+
'ArrowUp',
|
|
124
|
+
'ArrowDown',
|
|
125
|
+
'Home',
|
|
126
|
+
'End',
|
|
127
|
+
'PageUp',
|
|
128
|
+
'PageDown',
|
|
129
|
+
]);
|
|
130
|
+
const onPointerDown = () => emitInput({ kind: 'caret-moved' });
|
|
131
|
+
const onKeydown = (event) => {
|
|
132
|
+
if (NAVIGATION_KEYS.has(event.key))
|
|
133
|
+
emitInput({ kind: 'caret-moved' });
|
|
134
|
+
};
|
|
116
135
|
const onBeforeInput = (event) => {
|
|
117
136
|
const input = event;
|
|
118
137
|
let mapped = null;
|
|
@@ -156,6 +175,8 @@ export function createSuperdocHost(options) {
|
|
|
156
175
|
container?.addEventListener('beforeinput', onBeforeInput, true);
|
|
157
176
|
container?.addEventListener('compositionstart', onCompositionStart, true);
|
|
158
177
|
container?.addEventListener('compositionend', onCompositionEnd, true);
|
|
178
|
+
container?.addEventListener('pointerdown', onPointerDown, true);
|
|
179
|
+
container?.addEventListener('keydown', onKeydown, true);
|
|
159
180
|
/**
|
|
160
181
|
* `failed: true` means the engine call itself threw — as opposed to a
|
|
161
182
|
* clean "no selection" answer. Callers that write relative to the caret
|
|
@@ -306,6 +327,22 @@ export function createSuperdocHost(options) {
|
|
|
306
327
|
async getSelection(options) {
|
|
307
328
|
return (await readSelectionDetailed(options?.includeText ?? false)).snapshot;
|
|
308
329
|
},
|
|
330
|
+
async getTextBefore(count) {
|
|
331
|
+
// Auto-text's verification before it deletes: the answer must reflect
|
|
332
|
+
// the live document, so `null` (unknown) is the only honest reply when
|
|
333
|
+
// the view is unavailable — never a guess.
|
|
334
|
+
const pm = view();
|
|
335
|
+
if (!pm)
|
|
336
|
+
return null;
|
|
337
|
+
try {
|
|
338
|
+
const { from } = pm.state.selection;
|
|
339
|
+
const start = Math.max(0, from - Math.max(0, Math.trunc(count)));
|
|
340
|
+
return pm.state.doc.textBetween(start, from);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
},
|
|
309
346
|
async replaceAll(query, replacement) {
|
|
310
347
|
const handle = search();
|
|
311
348
|
if (!handle)
|
|
@@ -367,6 +404,8 @@ export function createSuperdocHost(options) {
|
|
|
367
404
|
container?.removeEventListener('beforeinput', onBeforeInput, true);
|
|
368
405
|
container?.removeEventListener('compositionstart', onCompositionStart, true);
|
|
369
406
|
container?.removeEventListener('compositionend', onCompositionEnd, true);
|
|
407
|
+
container?.removeEventListener('pointerdown', onPointerDown, true);
|
|
408
|
+
container?.removeEventListener('keydown', onKeydown, true);
|
|
370
409
|
commandListeners.clear();
|
|
371
410
|
inputListeners.clear();
|
|
372
411
|
},
|
package/dist/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ 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
9
|
export { MacroRecorder, replayMacro, type RecorderOptions, type ReplayOptions, type ReplayResult } from './recorder/recorder.js';
|
|
10
|
-
export { renderSnippet, expandSnippet, usesSelection, type ExpandOptions, type RenderContext } from './snippets/snippets.js';
|
|
10
|
+
export { renderSnippet, renderSnippetForHost, expandSnippet, usesSelection, type ExpandOptions, type RenderContext, } from './snippets/snippets.js';
|
|
11
11
|
export { AutoText, type AutoTextOptions, type AutoTextExpansion } from './snippets/autotext.js';
|
|
12
|
-
export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, type ParsedShortcut, type ShortcutBinding, type ShortcutTarget, } from './shortcuts.js';
|
|
13
|
-
export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, type MacroStorage, type PersistedMacroState, } from './storage.js';
|
|
12
|
+
export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, isBindableKey, codesForKey, type ParsedShortcut, type ShortcutBinding, type ShortcutTarget, } from './shortcuts.js';
|
|
13
|
+
export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, serializePersistable, type MacroStorage, type PersistedMacroState, } from './storage.js';
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ 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
7
|
export { MacroRecorder, replayMacro } from './recorder/recorder.js';
|
|
8
|
-
export { renderSnippet, expandSnippet, usesSelection } from './snippets/snippets.js';
|
|
8
|
+
export { renderSnippet, renderSnippetForHost, expandSnippet, usesSelection, } from './snippets/snippets.js';
|
|
9
9
|
export { AutoText } from './snippets/autotext.js';
|
|
10
|
-
export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, } from './shortcuts.js';
|
|
11
|
-
export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, } from './storage.js';
|
|
10
|
+
export { parseShortcut, eventMatches, bindShortcuts, shortcutSignatures, hasBindingModifier, isBindableKey, codesForKey, } from './shortcuts.js';
|
|
11
|
+
export { createLocalStorage, createMemoryStorage, emptyState, parsePersistedState, DEFAULT_STORAGE_KEY, IMPORT_LIMITS, isPersistableState, serializePersistable, } from './storage.js';
|
package/dist/manager.d.ts
CHANGED
|
@@ -97,7 +97,12 @@ export declare class MacroKit {
|
|
|
97
97
|
get isRecording(): boolean;
|
|
98
98
|
get recordedStepCount(): number;
|
|
99
99
|
startRecording(): void;
|
|
100
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Stops and saves. `null` when no step was recorded — there is nothing to
|
|
102
|
+
* save. Throws when the recording cannot be saved *whole* (its splittable
|
|
103
|
+
* steps still exceed the loader's step cap): a silently partial macro
|
|
104
|
+
* would replay something other than what the user did.
|
|
105
|
+
*/
|
|
101
106
|
stopRecording(name: string, shortcut?: string): RecordedMacro | null;
|
|
102
107
|
cancelRecording(): void;
|
|
103
108
|
listRecordings(): readonly RecordedMacro[];
|
|
@@ -169,5 +174,19 @@ export declare class MacroKit {
|
|
|
169
174
|
/** The item-count half of the invariant. `existingId` exempts an in-place update. */
|
|
170
175
|
private requireRoom;
|
|
171
176
|
private upsert;
|
|
172
|
-
|
|
177
|
+
/**
|
|
178
|
+
* Applies a mutation transactionally: the change runs on a clone, and the
|
|
179
|
+
* clone becomes the state only through `adopt` — validation and storage
|
|
180
|
+
* included. A failure at any stage leaves the previous state fully
|
|
181
|
+
* intact. Before this, the in-memory state mutated first and a quota
|
|
182
|
+
* failure left memory and disk silently disagreeing until the next
|
|
183
|
+
* successful save rewrote history.
|
|
184
|
+
*/
|
|
185
|
+
private commit;
|
|
186
|
+
/**
|
|
187
|
+
* The persistence invariant, in one place: a candidate becomes the state
|
|
188
|
+
* only if it serializes under the loader's exact rules (shape, field
|
|
189
|
+
* caps, whole-file size) *and* the storage actually accepted it.
|
|
190
|
+
*/
|
|
191
|
+
private adopt;
|
|
173
192
|
}
|
package/dist/manager.js
CHANGED
|
@@ -12,9 +12,9 @@ import { createEvalRunner } from './scripting/eval-runner.js';
|
|
|
12
12
|
import { createIframeRunner } from './scripting/iframe-runner.js';
|
|
13
13
|
import { MacroRecorder, replayMacro } from './recorder/recorder.js';
|
|
14
14
|
import { AutoText } from './snippets/autotext.js';
|
|
15
|
-
import {
|
|
16
|
-
import { IMPORT_LIMITS, isPersistableState } from './storage.js';
|
|
17
|
-
import { bindShortcuts, hasBindingModifier, parseShortcut, shortcutSignatures, } from './shortcuts.js';
|
|
15
|
+
import { renderSnippetForHost } from './snippets/snippets.js';
|
|
16
|
+
import { IMPORT_LIMITS, isPersistableState, serializePersistable } from './storage.js';
|
|
17
|
+
import { bindShortcuts, hasBindingModifier, isBindableKey, parseShortcut, shortcutSignatures, } from './shortcuts.js';
|
|
18
18
|
import { MacroError } from './scripting/macro-api.js';
|
|
19
19
|
import { createLocalStorage, emptyState, parsePersistedState } from './storage.js';
|
|
20
20
|
import { macroMessages } from './messages.js';
|
|
@@ -87,6 +87,10 @@ export class MacroKit {
|
|
|
87
87
|
return { ok: false, message: macroMessages().shortcutInvalid };
|
|
88
88
|
if (!hasBindingModifier(parsed))
|
|
89
89
|
return { ok: false, message: macroMessages().shortcutNeedsModifier };
|
|
90
|
+
// Only keys with a physical-code mapping (letters, digits, F-keys):
|
|
91
|
+
// matching is by event.code, so it survives a Hebrew keyboard layout.
|
|
92
|
+
if (!isBindableKey(parsed))
|
|
93
|
+
return { ok: false, message: macroMessages().shortcutInvalid };
|
|
90
94
|
const signatures = shortcutSignatures(parsed);
|
|
91
95
|
if (signatures.some((signature) => this.reservedSignatures.has(signature))) {
|
|
92
96
|
return { ok: false, message: macroMessages().shortcutReserved };
|
|
@@ -126,7 +130,7 @@ export class MacroKit {
|
|
|
126
130
|
}
|
|
127
131
|
saveScript(input) {
|
|
128
132
|
this.requireValidShortcut(input.shortcut, input.id);
|
|
129
|
-
this.requireItemLimits({ name: input.name, source: input.source });
|
|
133
|
+
this.requireItemLimits({ name: input.name, source: input.source, shortcut: input.shortcut });
|
|
130
134
|
this.requireRoom(this.state.scripts, input.id);
|
|
131
135
|
const script = {
|
|
132
136
|
id: input.id ?? newId(),
|
|
@@ -134,13 +138,15 @@ export class MacroKit {
|
|
|
134
138
|
source: input.source,
|
|
135
139
|
...(input.shortcut ? { shortcut: input.shortcut } : {}),
|
|
136
140
|
};
|
|
137
|
-
this.
|
|
138
|
-
|
|
139
|
-
|
|
141
|
+
return this.commit((draft) => {
|
|
142
|
+
this.upsert(draft.scripts, script);
|
|
143
|
+
return script;
|
|
144
|
+
});
|
|
140
145
|
}
|
|
141
146
|
removeScript(id) {
|
|
142
|
-
this.
|
|
143
|
-
|
|
147
|
+
this.commit((draft) => {
|
|
148
|
+
draft.scripts = draft.scripts.filter((script) => script.id !== id);
|
|
149
|
+
});
|
|
144
150
|
}
|
|
145
151
|
async runScript(id) {
|
|
146
152
|
const script = this.state.scripts.find((entry) => entry.id === id);
|
|
@@ -179,12 +185,19 @@ export class MacroKit {
|
|
|
179
185
|
return;
|
|
180
186
|
this.recorder.start();
|
|
181
187
|
}
|
|
182
|
-
/**
|
|
188
|
+
/**
|
|
189
|
+
* Stops and saves. `null` when no step was recorded — there is nothing to
|
|
190
|
+
* save. Throws when the recording cannot be saved *whole* (its splittable
|
|
191
|
+
* steps still exceed the loader's step cap): a silently partial macro
|
|
192
|
+
* would replay something other than what the user did.
|
|
193
|
+
*/
|
|
183
194
|
stopRecording(name, shortcut) {
|
|
184
195
|
this.requireValidShortcut(shortcut);
|
|
185
|
-
this.requireItemLimits({ name });
|
|
196
|
+
this.requireItemLimits({ name, shortcut });
|
|
186
197
|
this.requireRoom(this.state.recordings);
|
|
187
|
-
const steps = splitOversizedSteps(this.recorder.stop());
|
|
198
|
+
const { steps, truncated } = splitOversizedSteps(this.recorder.stop());
|
|
199
|
+
if (truncated)
|
|
200
|
+
throw new MacroError(macroMessages().recordingTooLarge, 'recording-too-large');
|
|
188
201
|
if (steps.length === 0)
|
|
189
202
|
return null;
|
|
190
203
|
const recording = {
|
|
@@ -195,9 +208,10 @@ export class MacroKit {
|
|
|
195
208
|
...(shortcut ? { shortcut } : {}),
|
|
196
209
|
steps,
|
|
197
210
|
};
|
|
198
|
-
this.
|
|
199
|
-
|
|
200
|
-
|
|
211
|
+
return this.commit((draft) => {
|
|
212
|
+
draft.recordings.push(recording);
|
|
213
|
+
return recording;
|
|
214
|
+
});
|
|
201
215
|
}
|
|
202
216
|
cancelRecording() {
|
|
203
217
|
this.recorder.cancel();
|
|
@@ -206,28 +220,30 @@ export class MacroKit {
|
|
|
206
220
|
return this.state.recordings;
|
|
207
221
|
}
|
|
208
222
|
removeRecording(id) {
|
|
209
|
-
this.
|
|
210
|
-
|
|
223
|
+
this.commit((draft) => {
|
|
224
|
+
draft.recordings = draft.recordings.filter((recording) => recording.id !== id);
|
|
225
|
+
});
|
|
211
226
|
}
|
|
212
227
|
/** Renames a recording or edits its shortcut. `null` when the recording was not found. */
|
|
213
228
|
updateRecording(input) {
|
|
214
|
-
|
|
215
|
-
if (!recording)
|
|
229
|
+
if (!this.state.recordings.some((entry) => entry.id === input.id))
|
|
216
230
|
return null;
|
|
217
231
|
if (input.shortcut !== undefined)
|
|
218
232
|
this.requireValidShortcut(input.shortcut, input.id);
|
|
219
233
|
if (input.name !== undefined)
|
|
220
|
-
this.requireItemLimits({ name: input.name });
|
|
221
|
-
|
|
222
|
-
recording
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
234
|
+
this.requireItemLimits({ name: input.name, shortcut: input.shortcut });
|
|
235
|
+
return this.commit((draft) => {
|
|
236
|
+
const recording = draft.recordings.find((entry) => entry.id === input.id);
|
|
237
|
+
if (input.name !== undefined)
|
|
238
|
+
recording.name = input.name;
|
|
239
|
+
if (input.shortcut !== undefined) {
|
|
240
|
+
if (input.shortcut)
|
|
241
|
+
recording.shortcut = input.shortcut;
|
|
242
|
+
else
|
|
243
|
+
delete recording.shortcut;
|
|
244
|
+
}
|
|
245
|
+
return recording;
|
|
246
|
+
});
|
|
231
247
|
}
|
|
232
248
|
async replayRecording(id, options) {
|
|
233
249
|
const recording = this.state.recordings.find((entry) => entry.id === id);
|
|
@@ -256,7 +272,12 @@ export class MacroKit {
|
|
|
256
272
|
}
|
|
257
273
|
saveSnippet(input) {
|
|
258
274
|
this.requireValidShortcut(input.shortcut, input.id);
|
|
259
|
-
this.requireItemLimits({
|
|
275
|
+
this.requireItemLimits({
|
|
276
|
+
name: input.name,
|
|
277
|
+
text: input.text,
|
|
278
|
+
trigger: input.trigger,
|
|
279
|
+
shortcut: input.shortcut,
|
|
280
|
+
});
|
|
260
281
|
this.requireRoom(this.state.snippets, input.id);
|
|
261
282
|
const snippet = {
|
|
262
283
|
id: input.id ?? newId(),
|
|
@@ -265,20 +286,30 @@ export class MacroKit {
|
|
|
265
286
|
...(input.trigger ? { trigger: input.trigger } : {}),
|
|
266
287
|
...(input.shortcut ? { shortcut: input.shortcut } : {}),
|
|
267
288
|
};
|
|
268
|
-
this.
|
|
269
|
-
|
|
270
|
-
|
|
289
|
+
return this.commit((draft) => {
|
|
290
|
+
this.upsert(draft.snippets, snippet);
|
|
291
|
+
return snippet;
|
|
292
|
+
});
|
|
271
293
|
}
|
|
272
294
|
removeSnippet(id) {
|
|
273
|
-
this.
|
|
274
|
-
|
|
295
|
+
this.commit((draft) => {
|
|
296
|
+
draft.snippets = draft.snippets.filter((snippet) => snippet.id !== id);
|
|
297
|
+
});
|
|
275
298
|
}
|
|
276
299
|
async expandSnippet(id, options) {
|
|
277
300
|
const snippet = this.state.snippets.find((entry) => entry.id === id);
|
|
278
301
|
if (!snippet)
|
|
279
302
|
return { ok: false, message: macroMessages().snippetNotFound };
|
|
280
|
-
const
|
|
281
|
-
|
|
303
|
+
const rendered = await renderSnippetForHost(this.host, snippet, options);
|
|
304
|
+
const outcome = await this.host.insertText(rendered);
|
|
305
|
+
if (!outcome.ok)
|
|
306
|
+
return { ok: false, message: outcome.message };
|
|
307
|
+
// A snippet expanded from a button or shortcut writes through the
|
|
308
|
+
// document API and fires no typing events — recorded explicitly, or a
|
|
309
|
+
// replay would silently miss text the user watched appear. The auto-text
|
|
310
|
+
// path needs nothing here: its expansion rewrites the typed tail.
|
|
311
|
+
this.recorder.recordInsert(rendered);
|
|
312
|
+
return { ok: true };
|
|
282
313
|
}
|
|
283
314
|
/** Enables auto-text (trigger + space). Returns a disable function. */
|
|
284
315
|
enableAutoText() {
|
|
@@ -361,8 +392,12 @@ export class MacroKit {
|
|
|
361
392
|
const shortcutsOk = this.validateStateShortcuts(candidate);
|
|
362
393
|
if (!shortcutsOk.ok)
|
|
363
394
|
return shortcutsOk;
|
|
364
|
-
|
|
365
|
-
|
|
395
|
+
try {
|
|
396
|
+
this.adopt(candidate);
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
return { ok: false, message: error instanceof Error ? error.message : macroMessages().saveFailed };
|
|
400
|
+
}
|
|
366
401
|
return { ok: true };
|
|
367
402
|
}
|
|
368
403
|
/**
|
|
@@ -424,6 +459,9 @@ export class MacroKit {
|
|
|
424
459
|
if (fields.name.length > limits.maxNameLength) {
|
|
425
460
|
throw new MacroError(macroMessages().fieldTooLong('name', limits.maxNameLength), 'invalid-item');
|
|
426
461
|
}
|
|
462
|
+
if (fields.shortcut !== undefined && fields.shortcut.length > limits.maxShortcutLength) {
|
|
463
|
+
throw new MacroError(macroMessages().fieldTooLong('shortcut', limits.maxShortcutLength), 'invalid-item');
|
|
464
|
+
}
|
|
427
465
|
if (fields.text !== undefined && fields.text.length > limits.maxTextLength) {
|
|
428
466
|
throw new MacroError(macroMessages().fieldTooLong('text', limits.maxTextLength), 'invalid-item');
|
|
429
467
|
}
|
|
@@ -449,22 +487,42 @@ export class MacroKit {
|
|
|
449
487
|
else
|
|
450
488
|
list.push(item);
|
|
451
489
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
490
|
+
/**
|
|
491
|
+
* Applies a mutation transactionally: the change runs on a clone, and the
|
|
492
|
+
* clone becomes the state only through `adopt` — validation and storage
|
|
493
|
+
* included. A failure at any stage leaves the previous state fully
|
|
494
|
+
* intact. Before this, the in-memory state mutated first and a quota
|
|
495
|
+
* failure left memory and disk silently disagreeing until the next
|
|
496
|
+
* successful save rewrote history.
|
|
497
|
+
*/
|
|
498
|
+
commit(mutate) {
|
|
499
|
+
const draft = JSON.parse(JSON.stringify(this.state));
|
|
500
|
+
const result = mutate(draft);
|
|
501
|
+
this.adopt(draft);
|
|
502
|
+
return result;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* The persistence invariant, in one place: a candidate becomes the state
|
|
506
|
+
* only if it serializes under the loader's exact rules (shape, field
|
|
507
|
+
* caps, whole-file size) *and* the storage actually accepted it.
|
|
508
|
+
*/
|
|
509
|
+
adopt(candidate) {
|
|
510
|
+
if (serializePersistable(candidate) === null) {
|
|
511
|
+
throw new MacroError(macroMessages().saveFailed, 'invalid-state');
|
|
512
|
+
}
|
|
513
|
+
if (!this.storage.save(candidate)) {
|
|
514
|
+
throw new MacroError(macroMessages().saveFailed, 'storage-failed');
|
|
460
515
|
}
|
|
461
|
-
this.
|
|
516
|
+
this.state = candidate;
|
|
462
517
|
}
|
|
463
518
|
}
|
|
464
519
|
/**
|
|
465
520
|
* A recorded insert-text step can exceed the loader's per-step cap (one huge
|
|
466
521
|
* paste coalesces into one step). Splitting preserves the exact text while
|
|
467
|
-
* keeping the recording loadable.
|
|
522
|
+
* keeping the recording loadable. `truncated` reports the pathological case
|
|
523
|
+
* where even the split exceeds the loader's step limit — the caller refuses
|
|
524
|
+
* to save then, with a message: a silently partial macro is worse than no
|
|
525
|
+
* macro.
|
|
468
526
|
*/
|
|
469
527
|
function splitOversizedSteps(steps) {
|
|
470
528
|
const max = IMPORT_LIMITS.maxTextLength;
|
|
@@ -477,12 +535,5 @@ function splitOversizedSteps(steps) {
|
|
|
477
535
|
}
|
|
478
536
|
return chunks;
|
|
479
537
|
});
|
|
480
|
-
|
|
481
|
-
// Truncating the tail is the honest option left: the alternative is a
|
|
482
|
-
// recording the loader rejects, which loses the whole store's worth more.
|
|
483
|
-
if (split.length > IMPORT_LIMITS.maxStepsPerRecording) {
|
|
484
|
-
console.warn('[superdoc-macros] recording truncated to the step limit');
|
|
485
|
-
return split.slice(0, IMPORT_LIMITS.maxStepsPerRecording);
|
|
486
|
-
}
|
|
487
|
-
return split;
|
|
538
|
+
return { steps: split, truncated: split.length > IMPORT_LIMITS.maxStepsPerRecording };
|
|
488
539
|
}
|
package/dist/messages.d.ts
CHANGED
|
@@ -40,6 +40,8 @@ export interface MacroMessages {
|
|
|
40
40
|
importTooLarge: string;
|
|
41
41
|
tooManyItems: string;
|
|
42
42
|
fieldTooLong: (field: string, max: number) => string;
|
|
43
|
+
saveFailed: string;
|
|
44
|
+
recordingTooLarge: string;
|
|
43
45
|
shortcutInvalid: string;
|
|
44
46
|
shortcutNeedsModifier: string;
|
|
45
47
|
shortcutReserved: string;
|
package/dist/messages.js
CHANGED
|
@@ -40,6 +40,8 @@ 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',
|
|
43
45
|
shortcutInvalid: 'Invalid shortcut — use a form like Ctrl+Alt+M',
|
|
44
46
|
shortcutNeedsModifier: 'A shortcut must include Ctrl, Alt or Meta',
|
|
45
47
|
shortcutReserved: 'This shortcut is reserved by the editor',
|
|
@@ -77,6 +79,8 @@ export const HEBREW_MESSAGES = {
|
|
|
77
79
|
tooManyItems: 'הרשימה מלאה — יש למחוק פריטים לפני הוספה',
|
|
78
80
|
nameRequired: 'חובה לתת שם',
|
|
79
81
|
fieldTooLong: (field, max) => `${field} ארוך מדי (התקרה: ${max} תווים)`,
|
|
82
|
+
saveFailed: 'השמירה נכשלה — השינוי לא הוחל',
|
|
83
|
+
recordingTooLarge: 'ההקלטה גדולה מכדי להישמר',
|
|
80
84
|
shortcutInvalid: 'קיצור לא תקין — הצורה הנדרשת היא למשל Ctrl+Alt+M',
|
|
81
85
|
shortcutNeedsModifier: 'קיצור חייב לכלול Ctrl, Alt או Meta',
|
|
82
86
|
shortcutReserved: 'הקיצור הזה שמור לעורך',
|
|
@@ -1,17 +1,3 @@
|
|
|
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. */
|
|
@@ -34,6 +20,12 @@ export declare class MacroRecorder {
|
|
|
34
20
|
private steps;
|
|
35
21
|
private disposers;
|
|
36
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;
|
|
37
29
|
constructor(host: MacroHost, options?: RecorderOptions);
|
|
38
30
|
get recording(): boolean;
|
|
39
31
|
get stepCount(): number;
|
|
@@ -62,6 +54,13 @@ export declare class MacroRecorder {
|
|
|
62
54
|
applyAutoTextExpansion(consumed: number, replacement: string): void;
|
|
63
55
|
private teardown;
|
|
64
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;
|
|
65
64
|
private recordCommand;
|
|
66
65
|
private recordTextInput;
|
|
67
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) {
|
|
@@ -11,6 +26,12 @@ export class MacroRecorder {
|
|
|
11
26
|
steps = [];
|
|
12
27
|
disposers = [];
|
|
13
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;
|
|
14
35
|
constructor(host, options = {}) {
|
|
15
36
|
this.host = host;
|
|
16
37
|
this.shouldRecordCommand = options.shouldRecordCommand ?? defaultShouldRecord;
|
|
@@ -105,17 +126,49 @@ export class MacroRecorder {
|
|
|
105
126
|
this.onAutoStop?.();
|
|
106
127
|
}
|
|
107
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 });
|
|
139
|
+
}
|
|
108
140
|
recordCommand(id, payload) {
|
|
109
141
|
if (!this.shouldRecordCommand(id))
|
|
110
142
|
return;
|
|
111
|
-
|
|
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) });
|
|
112
162
|
}
|
|
113
163
|
recordTextInput(event) {
|
|
164
|
+
const interrupted = this.tailInterrupted;
|
|
165
|
+
this.tailInterrupted = false;
|
|
114
166
|
const last = this.steps[this.steps.length - 1];
|
|
115
167
|
switch (event.kind) {
|
|
116
168
|
case 'insert-text': {
|
|
117
|
-
// Consecutive keystrokes coalesce into one step — more readable,
|
|
118
|
-
|
|
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') {
|
|
119
172
|
last.text += event.text;
|
|
120
173
|
return;
|
|
121
174
|
}
|
|
@@ -126,7 +179,7 @@ export class MacroRecorder {
|
|
|
126
179
|
this.push({ type: 'insert-paragraph' });
|
|
127
180
|
return;
|
|
128
181
|
case 'delete-backward': {
|
|
129
|
-
if (last?.type === 'delete-backward') {
|
|
182
|
+
if (!interrupted && last?.type === 'delete-backward') {
|
|
130
183
|
last.count += 1;
|
|
131
184
|
return;
|
|
132
185
|
}
|
|
@@ -134,13 +187,18 @@ export class MacroRecorder {
|
|
|
134
187
|
return;
|
|
135
188
|
}
|
|
136
189
|
case 'delete-forward': {
|
|
137
|
-
if (last?.type === 'delete-forward') {
|
|
190
|
+
if (!interrupted && last?.type === 'delete-forward') {
|
|
138
191
|
last.count += 1;
|
|
139
192
|
return;
|
|
140
193
|
}
|
|
141
194
|
this.push({ type: 'delete-forward', count: 1 });
|
|
142
195
|
return;
|
|
143
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;
|
|
144
202
|
}
|
|
145
203
|
}
|
|
146
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))
|
|
@@ -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;
|
|
@@ -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
|
}
|
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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superdoc-macros",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|