superdoc-macros 0.2.0 → 0.4.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 +81 -66
- package/dist/host/superdoc-host.d.ts +10 -21
- package/dist/host/superdoc-host.js +168 -51
- package/dist/index.d.ts +4 -3
- package/dist/index.js +3 -2
- package/dist/manager.d.ts +49 -21
- package/dist/manager.js +102 -29
- package/dist/messages.d.ts +55 -0
- package/dist/messages.js +88 -0
- package/dist/recorder/recorder.d.ts +17 -15
- package/dist/recorder/recorder.js +5 -6
- package/dist/scripting/eval-runner.d.ts +14 -12
- package/dist/scripting/eval-runner.js +51 -8
- package/dist/scripting/iframe-runner.d.ts +5 -4
- package/dist/scripting/iframe-runner.js +42 -12
- package/dist/scripting/macro-api.d.ts +12 -23
- package/dist/scripting/macro-api.js +24 -9
- package/dist/scripting/runner.d.ts +3 -3
- package/dist/shortcuts.d.ts +20 -6
- package/dist/shortcuts.js +24 -4
- package/dist/snippets/autotext.d.ts +16 -13
- package/dist/snippets/autotext.js +8 -6
- package/dist/snippets/snippets.d.ts +14 -10
- package/dist/snippets/snippets.js +8 -6
- package/dist/storage.d.ts +30 -6
- package/dist/storage.js +93 -5
- package/dist/types.d.ts +35 -32
- package/dist/types.js +6 -5
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type MacroRunner } from './runner.js';
|
|
2
|
-
/**
|
|
2
|
+
/** Protocol marker, so the messages cannot collide with others on the page. */
|
|
3
3
|
export declare const PROTOCOL_MARK: "__otzariaMacro";
|
|
4
4
|
export type SandboxMessage = {
|
|
5
5
|
[PROTOCOL_MARK]: true;
|
|
@@ -31,11 +31,12 @@ export type HostMessage = {
|
|
|
31
31
|
value?: unknown;
|
|
32
32
|
message?: string;
|
|
33
33
|
};
|
|
34
|
-
/**
|
|
34
|
+
/** Whether a message belongs to the protocol. Exposed for tests. */
|
|
35
35
|
export declare function isProtocolMessage(data: unknown): data is SandboxMessage;
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
37
|
+
* The code that runs inside the iframe. A string rather than a serialized
|
|
38
|
+
* function, so the build cannot touch it (minifying names would break the
|
|
39
|
+
* protocol).
|
|
39
40
|
*/
|
|
40
41
|
export declare const SANDBOX_BOOTSTRAP: string;
|
|
41
42
|
export declare function createIframeRunner(doc?: Document): MacroRunner;
|
|
@@ -1,8 +1,26 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Sandboxed script runner — an iframe with `sandbox="allow-scripts"` only.
|
|
3
|
+
*
|
|
4
|
+
* The iframe gets an opaque origin: no access to the page's DOM, to
|
|
5
|
+
* localStorage, to cookies, or to the network with the user's credentials.
|
|
6
|
+
* Its only way to touch the document is RPC over postMessage to the
|
|
7
|
+
* `MacroApi` methods — every call goes through `bridge.call`, which enforces
|
|
8
|
+
* a closed method list and a call cap.
|
|
9
|
+
*
|
|
10
|
+
* The time cap here is real: when it expires the iframe is removed from the
|
|
11
|
+
* DOM, which also kills an infinite synchronous loop — it runs on the
|
|
12
|
+
* iframe's event loop, not the page's.
|
|
13
|
+
*
|
|
14
|
+
* Return values and arguments cross a structured-clone boundary; the API is
|
|
15
|
+
* already shaped so everything it returns is JSON-safe (see
|
|
16
|
+
* `ScriptSelection`).
|
|
17
|
+
*/
|
|
18
|
+
import { macroMessages } from '../messages.js';
|
|
19
|
+
import { limitCalls, revocable } from './eval-runner.js';
|
|
2
20
|
import { DEFAULT_MAX_API_CALLS, DEFAULT_TIMEOUT_MS, } from './runner.js';
|
|
3
|
-
/**
|
|
21
|
+
/** Protocol marker, so the messages cannot collide with others on the page. */
|
|
4
22
|
export const PROTOCOL_MARK = '__otzariaMacro';
|
|
5
|
-
/**
|
|
23
|
+
/** Whether a message belongs to the protocol. Exposed for tests. */
|
|
6
24
|
export function isProtocolMessage(data) {
|
|
7
25
|
return (typeof data === 'object' &&
|
|
8
26
|
data !== null &&
|
|
@@ -10,8 +28,9 @@ export function isProtocolMessage(data) {
|
|
|
10
28
|
typeof data.kind === 'string');
|
|
11
29
|
}
|
|
12
30
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
31
|
+
* The code that runs inside the iframe. A string rather than a serialized
|
|
32
|
+
* function, so the build cannot touch it (minifying names would break the
|
|
33
|
+
* protocol).
|
|
15
34
|
*/
|
|
16
35
|
export const SANDBOX_BOOTSTRAP = `
|
|
17
36
|
'use strict';
|
|
@@ -28,7 +47,7 @@ export const SANDBOX_BOOTSTRAP = `
|
|
|
28
47
|
var api = new Proxy({}, {
|
|
29
48
|
get: function (_target, method) {
|
|
30
49
|
if (typeof method !== 'string') return undefined;
|
|
31
|
-
if (method === 'then') return undefined; //
|
|
50
|
+
if (method === 'then') return undefined; // so "await api" is not treated as a thenable
|
|
32
51
|
return function () {
|
|
33
52
|
var args = Array.prototype.slice.call(arguments);
|
|
34
53
|
return new Promise(function (resolve, reject) {
|
|
@@ -75,7 +94,7 @@ export const SANDBOX_BOOTSTRAP = `
|
|
|
75
94
|
post({ kind: 'ready' });
|
|
76
95
|
})();
|
|
77
96
|
`;
|
|
78
|
-
/**
|
|
97
|
+
/** A value safe to hand back to the iframe (structured clone can fail on engine objects). */
|
|
79
98
|
function toCloneSafe(value) {
|
|
80
99
|
if (value === undefined || value === null)
|
|
81
100
|
return value;
|
|
@@ -90,25 +109,36 @@ export function createIframeRunner(doc = document) {
|
|
|
90
109
|
return {
|
|
91
110
|
run(source, bridge, options = {}) {
|
|
92
111
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
93
|
-
|
|
112
|
+
// The revocable wrapper closes a small race: a call message that was
|
|
113
|
+
// already queued when the run finished must not dispatch to the host
|
|
114
|
+
// after the iframe is gone.
|
|
115
|
+
const { bridge: guarded, revoke } = revocable(limitCalls(bridge, options.maxApiCalls ?? DEFAULT_MAX_API_CALLS));
|
|
94
116
|
return new Promise((resolve) => {
|
|
95
117
|
const iframe = doc.createElement('iframe');
|
|
96
118
|
iframe.setAttribute('sandbox', 'allow-scripts');
|
|
97
119
|
iframe.style.display = 'none';
|
|
98
|
-
|
|
120
|
+
// The CSP closes the sandbox's remaining hole: an opaque-origin iframe
|
|
121
|
+
// cannot reach the app, but it can still fetch the public internet.
|
|
122
|
+
// `default-src 'none'` blocks fetch/XHR/WebSocket/resources inside it;
|
|
123
|
+
// only the inline bootstrap (and the AsyncFunction it compiles) runs.
|
|
124
|
+
iframe.srcdoc =
|
|
125
|
+
`<!doctype html><meta charset="utf-8">` +
|
|
126
|
+
`<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'">` +
|
|
127
|
+
`<script>${SANDBOX_BOOTSTRAP}</script>`;
|
|
99
128
|
let settled = false;
|
|
100
129
|
let timer;
|
|
101
130
|
const finish = (result) => {
|
|
102
131
|
if (settled)
|
|
103
132
|
return;
|
|
104
133
|
settled = true;
|
|
134
|
+
revoke();
|
|
105
135
|
clearTimeout(timer);
|
|
106
136
|
removeEventListener('message', onMessage);
|
|
107
137
|
iframe.remove();
|
|
108
138
|
resolve(result);
|
|
109
139
|
};
|
|
110
140
|
const onMessage = (event) => {
|
|
111
|
-
//
|
|
141
|
+
// Only messages from this iframe: a page may run several macros at once.
|
|
112
142
|
if (event.source !== iframe.contentWindow)
|
|
113
143
|
return;
|
|
114
144
|
const data = event.data;
|
|
@@ -120,7 +150,7 @@ export function createIframeRunner(doc = document) {
|
|
|
120
150
|
}
|
|
121
151
|
if (data.kind === 'call') {
|
|
122
152
|
const { id, method, args } = data;
|
|
123
|
-
|
|
153
|
+
guarded
|
|
124
154
|
.call(method, args)
|
|
125
155
|
.then((value) => {
|
|
126
156
|
iframe.contentWindow?.postMessage({ [PROTOCOL_MARK]: true, kind: 'result', id, ok: true, value: toCloneSafe(value) }, '*');
|
|
@@ -142,7 +172,7 @@ export function createIframeRunner(doc = document) {
|
|
|
142
172
|
finish({ ok: false, reason: 'error', message: data.message });
|
|
143
173
|
};
|
|
144
174
|
addEventListener('message', onMessage);
|
|
145
|
-
timer = setTimeout(() => finish({ ok: false, reason: 'timeout', message:
|
|
175
|
+
timer = setTimeout(() => finish({ ok: false, reason: 'timeout', message: macroMessages().timedOut(timeoutMs / 1000) }), timeoutMs);
|
|
146
176
|
doc.body.appendChild(iframe);
|
|
147
177
|
});
|
|
148
178
|
},
|
|
@@ -1,34 +1,23 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ה-API שסקריפט מאקרו מקבל.
|
|
3
|
-
*
|
|
4
|
-
* שני צרכנים לאותו מימוש: מריץ ה-eval מקבל את האובייקט `api` ישירות, ומריץ
|
|
5
|
-
* ה-iframe מדבר איתו דרך `call(method, args)` — RPC על postMessage. לכן כל
|
|
6
|
-
* מתודה רשומה במילון אחד, וה-proxy בתוך ה-iframe פונה לאותם שמות בדיוק.
|
|
7
|
-
*
|
|
8
|
-
* כללי כשל: פעולות כתיבה זורקות `MacroError` כשהן נכשלות, כדי שסקריפט ייעצר
|
|
9
|
-
* במקום להמשיך על מסמך במצב לא צפוי. `command()` הגולמית מחזירה את התוצאה
|
|
10
|
-
* ואינה זורקת — למי שרוצה לבדוק בעצמו.
|
|
11
|
-
*/
|
|
12
1
|
import type { MacroHost, MacroOutcome } from '../types.js';
|
|
13
|
-
/**
|
|
2
|
+
/** A macro operation failure. Named so a script can tell it apart from its own TypeError. */
|
|
14
3
|
export declare class MacroError extends Error {
|
|
15
4
|
readonly reason?: string;
|
|
16
5
|
constructor(message: string, reason?: string);
|
|
17
6
|
}
|
|
18
|
-
/**
|
|
7
|
+
/** Selection snapshot that is safe to hand to the iframe (without the engine's opaque target). */
|
|
19
8
|
export interface ScriptSelection {
|
|
20
9
|
text: string;
|
|
21
10
|
hasRange: boolean;
|
|
22
11
|
blockId: string | null;
|
|
23
12
|
empty: boolean;
|
|
24
13
|
}
|
|
25
|
-
/**
|
|
14
|
+
/** What a script receives as `api`. Every method is async. */
|
|
26
15
|
export interface MacroApi {
|
|
27
|
-
/**
|
|
16
|
+
/** Runs a command from the engine catalog. Returns the outcome, never throws. */
|
|
28
17
|
command(id: string, payload?: unknown): Promise<MacroOutcome>;
|
|
29
|
-
/**
|
|
18
|
+
/** Whether the engine recognizes the command. */
|
|
30
19
|
hasCommand(id: string): Promise<boolean>;
|
|
31
|
-
/**
|
|
20
|
+
/** The known command ids. */
|
|
32
21
|
commandIds(): Promise<readonly string[]>;
|
|
33
22
|
insertText(text: string): Promise<void>;
|
|
34
23
|
insertParagraph(): Promise<void>;
|
|
@@ -36,7 +25,7 @@ export interface MacroApi {
|
|
|
36
25
|
getSelection(): Promise<ScriptSelection>;
|
|
37
26
|
getSelectionText(): Promise<string>;
|
|
38
27
|
getDocumentText(): Promise<string>;
|
|
39
|
-
/**
|
|
28
|
+
/** Replaces every occurrence. Returns how many were replaced. */
|
|
40
29
|
replaceAll(query: string, replacement: string): Promise<number>;
|
|
41
30
|
bold(): Promise<void>;
|
|
42
31
|
italic(): Promise<void>;
|
|
@@ -51,19 +40,19 @@ export interface MacroApi {
|
|
|
51
40
|
directionLtr(): Promise<void>;
|
|
52
41
|
undo(): Promise<void>;
|
|
53
42
|
redo(): Promise<void>;
|
|
54
|
-
/**
|
|
43
|
+
/** Writes a line to the run log (shown to the user, not to the console). */
|
|
55
44
|
log(...parts: unknown[]): Promise<void>;
|
|
56
45
|
}
|
|
57
46
|
export interface MacroApiOptions {
|
|
58
|
-
/**
|
|
47
|
+
/** Receives every `api.log` line. Default: console.info. */
|
|
59
48
|
onLog?: (line: string) => void;
|
|
60
49
|
}
|
|
61
50
|
export interface MacroBridge {
|
|
62
51
|
api: MacroApi;
|
|
63
|
-
/**
|
|
52
|
+
/** The RPC path: invokes a method by name. Throws on an unknown method. */
|
|
64
53
|
call(method: string, args: readonly unknown[]): Promise<unknown>;
|
|
65
|
-
/**
|
|
54
|
+
/** Number of calls made so far. Used by the runners' call limit. */
|
|
66
55
|
callCount(): number;
|
|
67
56
|
}
|
|
68
|
-
/**
|
|
57
|
+
/** Builds the API on top of a host. */
|
|
69
58
|
export declare function createMacroApi(host: MacroHost, options?: MacroApiOptions): MacroBridge;
|
|
@@ -1,4 +1,19 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* The API a macro script receives.
|
|
3
|
+
*
|
|
4
|
+
* Two consumers share one implementation: the eval runner hands the `api`
|
|
5
|
+
* object to the script directly, and the iframe runner talks to it through
|
|
6
|
+
* `call(method, args)` — RPC over postMessage. Every method therefore lives
|
|
7
|
+
* in a single dictionary, and the proxy inside the iframe addresses exactly
|
|
8
|
+
* the same names.
|
|
9
|
+
*
|
|
10
|
+
* Failure rules: write operations throw a `MacroError` when they fail, so a
|
|
11
|
+
* script stops instead of continuing against a document in an unexpected
|
|
12
|
+
* state. The raw `command()` returns the outcome and does not throw — for
|
|
13
|
+
* scripts that want to check it themselves.
|
|
14
|
+
*/
|
|
15
|
+
import { macroMessages } from '../messages.js';
|
|
16
|
+
/** A macro operation failure. Named so a script can tell it apart from its own TypeError. */
|
|
2
17
|
export class MacroError extends Error {
|
|
3
18
|
reason;
|
|
4
19
|
constructor(message, reason) {
|
|
@@ -14,7 +29,7 @@ function requireOk(outcome, action) {
|
|
|
14
29
|
}
|
|
15
30
|
function asText(value, name) {
|
|
16
31
|
if (typeof value !== 'string')
|
|
17
|
-
throw new MacroError(
|
|
32
|
+
throw new MacroError(macroMessages().mustBeString(name));
|
|
18
33
|
return value;
|
|
19
34
|
}
|
|
20
35
|
function formatLogPart(part) {
|
|
@@ -27,27 +42,27 @@ function formatLogPart(part) {
|
|
|
27
42
|
return String(part);
|
|
28
43
|
}
|
|
29
44
|
}
|
|
30
|
-
/**
|
|
45
|
+
/** Builds the API on top of a host. */
|
|
31
46
|
export function createMacroApi(host, options = {}) {
|
|
32
47
|
const onLog = options.onLog ?? ((line) => console.info('[superdoc-macros]', line));
|
|
33
48
|
const commandSugar = async (id) => {
|
|
34
|
-
requireOk(await host.commands.execute(id),
|
|
49
|
+
requireOk(await host.commands.execute(id), macroMessages().commandFailed(id));
|
|
35
50
|
};
|
|
36
51
|
const api = {
|
|
37
52
|
command: (id, payload) => host.commands.execute(asText(id, 'id'), payload),
|
|
38
53
|
hasCommand: async (id) => host.commands.has(asText(id, 'id')),
|
|
39
54
|
commandIds: async () => host.commands.ids(),
|
|
40
55
|
async insertText(text) {
|
|
41
|
-
requireOk(await host.insertText(asText(text, 'text')),
|
|
56
|
+
requireOk(await host.insertText(asText(text, 'text')), macroMessages().insertTextFailed);
|
|
42
57
|
},
|
|
43
58
|
async insertParagraph() {
|
|
44
|
-
requireOk(await host.insertText('\n'),
|
|
59
|
+
requireOk(await host.insertText('\n'), macroMessages().insertParagraphFailed);
|
|
45
60
|
},
|
|
46
61
|
async deleteBackward(count = 1) {
|
|
47
62
|
const n = Math.max(0, Math.trunc(Number(count)));
|
|
48
63
|
if (n === 0)
|
|
49
64
|
return;
|
|
50
|
-
requireOk(await host.deleteBackward(n),
|
|
65
|
+
requireOk(await host.deleteBackward(n), macroMessages().deleteFailed);
|
|
51
66
|
},
|
|
52
67
|
async getSelection() {
|
|
53
68
|
const snapshot = await host.getSelection({ includeText: true });
|
|
@@ -65,7 +80,7 @@ export function createMacroApi(host, options = {}) {
|
|
|
65
80
|
async replaceAll(query, replacement) {
|
|
66
81
|
const result = await host.replaceAll(asText(query, 'query'), asText(replacement, 'replacement'));
|
|
67
82
|
if (!result.ok)
|
|
68
|
-
throw new MacroError(result.message ??
|
|
83
|
+
throw new MacroError(result.message ?? macroMessages().replaceFailed);
|
|
69
84
|
return result.replaced;
|
|
70
85
|
},
|
|
71
86
|
bold: () => commandSugar('bold'),
|
|
@@ -92,7 +107,7 @@ export function createMacroApi(host, options = {}) {
|
|
|
92
107
|
async call(method, args) {
|
|
93
108
|
const fn = methods[method];
|
|
94
109
|
if (typeof fn !== 'function' || !Object.prototype.hasOwnProperty.call(api, method)) {
|
|
95
|
-
throw new MacroError(
|
|
110
|
+
throw new MacroError(macroMessages().unknownMethod(String(method)));
|
|
96
111
|
}
|
|
97
112
|
calls += 1;
|
|
98
113
|
return fn.apply(api, args);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Contract shared by the two runners (eval and iframe). */
|
|
2
2
|
import type { MacroBridge } from './macro-api.js';
|
|
3
3
|
export type MacroRunResult = {
|
|
4
4
|
ok: true;
|
|
@@ -9,9 +9,9 @@ export type MacroRunResult = {
|
|
|
9
9
|
reason?: 'timeout' | 'error' | 'call-limit';
|
|
10
10
|
};
|
|
11
11
|
export interface MacroRunOptions {
|
|
12
|
-
/**
|
|
12
|
+
/** Time cap for the whole run. Default: 30 seconds. */
|
|
13
13
|
timeoutMs?: number;
|
|
14
|
-
/**
|
|
14
|
+
/** API call cap, against runaway loops. Default: 10,000. */
|
|
15
15
|
maxApiCalls?: number;
|
|
16
16
|
}
|
|
17
17
|
export declare const DEFAULT_TIMEOUT_MS = 30000;
|
package/dist/shortcuts.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Keyboard shortcuts for macros and snippets: parsing a `Ctrl+Alt+M` string
|
|
3
|
+
* and matching it against an event.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
+
* Matching is by lowercased `event.key`. `Mod` means Ctrl (or ⌘ on macOS).
|
|
5
6
|
*/
|
|
6
7
|
export interface ParsedShortcut {
|
|
7
8
|
key: string;
|
|
@@ -9,10 +10,10 @@ export interface ParsedShortcut {
|
|
|
9
10
|
alt: boolean;
|
|
10
11
|
shift: boolean;
|
|
11
12
|
meta: boolean;
|
|
12
|
-
/** Ctrl
|
|
13
|
+
/** Ctrl or Meta — for shortcuts written with `Mod`. */
|
|
13
14
|
mod: boolean;
|
|
14
15
|
}
|
|
15
|
-
/**
|
|
16
|
+
/** The subset of KeyboardEvent that matching needs. Enables DOM-free tests. */
|
|
16
17
|
export interface KeyEventLike {
|
|
17
18
|
key: string;
|
|
18
19
|
ctrlKey: boolean;
|
|
@@ -24,6 +25,18 @@ export interface KeyEventLike {
|
|
|
24
25
|
}
|
|
25
26
|
export declare function parseShortcut(shortcut: string): ParsedShortcut | null;
|
|
26
27
|
export declare function eventMatches(parsed: ParsedShortcut, event: KeyEventLike): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Comparable signatures for collision checks. `Mod` matches either Ctrl or
|
|
30
|
+
* Meta at runtime, so it expands to both — a `Mod+K` binding collides with
|
|
31
|
+
* `Ctrl+K` and with `Meta+K`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function shortcutSignatures(parsed: ParsedShortcut): string[];
|
|
34
|
+
/**
|
|
35
|
+
* Whether the shortcut is acceptable as a *saved binding*: it must carry a
|
|
36
|
+
* real modifier (Ctrl/Alt/Meta/Mod). A bare letter would fire on ordinary
|
|
37
|
+
* typing, and Shift alone is just an uppercase letter.
|
|
38
|
+
*/
|
|
39
|
+
export declare function hasBindingModifier(parsed: ParsedShortcut): boolean;
|
|
27
40
|
export interface ShortcutBinding {
|
|
28
41
|
shortcut: string;
|
|
29
42
|
run(): void | Promise<unknown>;
|
|
@@ -33,7 +46,8 @@ export interface ShortcutTarget {
|
|
|
33
46
|
removeEventListener(type: 'keydown', listener: (event: KeyboardEvent) => void, options?: boolean): void;
|
|
34
47
|
}
|
|
35
48
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
49
|
+
* Binds shortcuts to a target. `getBindings` is called on every keystroke —
|
|
50
|
+
* so the macro list can change without rebinding. Returns a dispose
|
|
51
|
+
* function.
|
|
38
52
|
*/
|
|
39
53
|
export declare function bindShortcuts(target: ShortcutTarget, getBindings: () => readonly ShortcutBinding[]): () => void;
|
package/dist/shortcuts.js
CHANGED
|
@@ -29,7 +29,7 @@ export function parseShortcut(shortcut) {
|
|
|
29
29
|
break;
|
|
30
30
|
default: {
|
|
31
31
|
if (parsed.key)
|
|
32
|
-
return null; //
|
|
32
|
+
return null; // two non-modifier keys — an invalid shortcut.
|
|
33
33
|
parsed.key = normalizeKey(part);
|
|
34
34
|
}
|
|
35
35
|
}
|
|
@@ -50,7 +50,7 @@ export function eventMatches(parsed, event) {
|
|
|
50
50
|
if (parsed.mod) {
|
|
51
51
|
if (!event.ctrlKey && !event.metaKey)
|
|
52
52
|
return false;
|
|
53
|
-
//
|
|
53
|
+
// With Mod, ctrl/meta are not checked individually — but alt/shift must match exactly.
|
|
54
54
|
return event.altKey === parsed.alt && event.shiftKey === parsed.shift;
|
|
55
55
|
}
|
|
56
56
|
return (event.ctrlKey === parsed.ctrl &&
|
|
@@ -59,8 +59,28 @@ export function eventMatches(parsed, event) {
|
|
|
59
59
|
event.metaKey === parsed.meta);
|
|
60
60
|
}
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
62
|
+
* Comparable signatures for collision checks. `Mod` matches either Ctrl or
|
|
63
|
+
* Meta at runtime, so it expands to both — a `Mod+K` binding collides with
|
|
64
|
+
* `Ctrl+K` and with `Meta+K`.
|
|
65
|
+
*/
|
|
66
|
+
export function shortcutSignatures(parsed) {
|
|
67
|
+
const suffix = `${parsed.alt ? 'alt+' : ''}${parsed.shift ? 'shift+' : ''}${parsed.key}`;
|
|
68
|
+
if (parsed.mod)
|
|
69
|
+
return [`ctrl+${suffix}`, `meta+${suffix}`];
|
|
70
|
+
return [`${parsed.ctrl ? 'ctrl+' : ''}${parsed.meta ? 'meta+' : ''}${suffix}`];
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Whether the shortcut is acceptable as a *saved binding*: it must carry a
|
|
74
|
+
* real modifier (Ctrl/Alt/Meta/Mod). A bare letter would fire on ordinary
|
|
75
|
+
* typing, and Shift alone is just an uppercase letter.
|
|
76
|
+
*/
|
|
77
|
+
export function hasBindingModifier(parsed) {
|
|
78
|
+
return parsed.ctrl || parsed.alt || parsed.meta || parsed.mod;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Binds shortcuts to a target. `getBindings` is called on every keystroke —
|
|
82
|
+
* so the macro list can change without rebinding. Returns a dispose
|
|
83
|
+
* function.
|
|
64
84
|
*/
|
|
65
85
|
export function bindShortcuts(target, getBindings) {
|
|
66
86
|
const listener = (event) => {
|
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Auto-text: typing a snippet's trigger word followed by a space replaces
|
|
3
|
+
* the word with the snippet's content.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* `TextInputEvent`
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* How it works: a small buffer keeps the characters typed in the current run
|
|
6
|
+
* (from the host's `TextInputEvent`s). When an expansion character (space by
|
|
7
|
+
* default) is typed and the word before it is some snippet's trigger, the
|
|
8
|
+
* word and the expansion character are deleted backwards and the rendered
|
|
9
|
+
* content is inserted in their place, with the expansion character restored
|
|
10
|
+
* at the end.
|
|
9
11
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
12
|
+
* The buffer resets on a new paragraph, on forward deletion and on a command
|
|
13
|
+
* running mid-typing — anything that breaks the correspondence between what
|
|
14
|
+
* was typed and what actually sits before the caret. A missed expansion is
|
|
15
|
+
* better than an expansion that deletes the wrong text.
|
|
13
16
|
*/
|
|
14
17
|
import type { MacroHost, Snippet } from '../types.js';
|
|
15
18
|
export interface AutoTextOptions {
|
|
16
|
-
/**
|
|
19
|
+
/** The expansion characters. Default: space only. */
|
|
17
20
|
expandOn?: readonly string[];
|
|
18
|
-
/**
|
|
21
|
+
/** Buffer size. A trigger word longer than this will not be recognized. */
|
|
19
22
|
bufferSize?: number;
|
|
20
|
-
/**
|
|
23
|
+
/** Called after a successful expansion. */
|
|
21
24
|
onExpand?: (snippet: Snippet) => void;
|
|
22
|
-
/**
|
|
25
|
+
/** Called when an expansion failed (e.g. a read-only document). */
|
|
23
26
|
onError?: (message: string) => void;
|
|
24
27
|
}
|
|
25
28
|
export declare class AutoText {
|
|
@@ -27,7 +27,7 @@ export class AutoText {
|
|
|
27
27
|
return () => this.detach();
|
|
28
28
|
this.buffer = '';
|
|
29
29
|
this.disposeInput = this.host.onTextInput((event) => void this.handleInput(event));
|
|
30
|
-
//
|
|
30
|
+
// A command mid-typing (formatting, paste) breaks the buffer's link to the document.
|
|
31
31
|
this.disposeCommand = this.host.onCommand(() => {
|
|
32
32
|
if (!this.busy)
|
|
33
33
|
this.buffer = '';
|
|
@@ -42,7 +42,7 @@ export class AutoText {
|
|
|
42
42
|
this.buffer = '';
|
|
43
43
|
}
|
|
44
44
|
async handleInput(event) {
|
|
45
|
-
//
|
|
45
|
+
// Input generated while the expansion itself is writing — not the user's typing.
|
|
46
46
|
if (this.busy)
|
|
47
47
|
return;
|
|
48
48
|
switch (event.kind) {
|
|
@@ -74,14 +74,16 @@ export class AutoText {
|
|
|
74
74
|
async expand(snippet, trigger, expandChar) {
|
|
75
75
|
this.busy = true;
|
|
76
76
|
try {
|
|
77
|
-
//
|
|
78
|
-
//
|
|
77
|
+
// The input event (beforeinput) fires before the character is written
|
|
78
|
+
// to the document. Deferring to the task queue guarantees the expansion
|
|
79
|
+
// character is already in before it is deleted along with the trigger.
|
|
79
80
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
80
81
|
const selectionText = usesSelection(snippet.text)
|
|
81
82
|
? (await this.host.getSelection({ includeText: true })).text
|
|
82
83
|
: undefined;
|
|
83
84
|
const rendered = renderSnippet(snippet.text, { selectionText });
|
|
84
|
-
//
|
|
85
|
+
// The expansion character is already in the document by now, so it is
|
|
86
|
+
// included in the deletion and restored at the end.
|
|
85
87
|
const deleted = await this.host.deleteBackward(trigger.length + 1);
|
|
86
88
|
if (!deleted.ok) {
|
|
87
89
|
this.onError?.(deleted.message);
|
|
@@ -99,7 +101,7 @@ export class AutoText {
|
|
|
99
101
|
}
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
|
-
/**
|
|
104
|
+
/** The word at the end of the buffer — a run of non-whitespace. */
|
|
103
105
|
function trailingWord(buffer) {
|
|
104
106
|
const match = /(\S+)$/u.exec(buffer);
|
|
105
107
|
return match?.[1] ?? null;
|
|
@@ -1,26 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Text snippets: templates inserted at the caret, with `{{...}}` variables.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Built-in variables: `{{date}}`, `{{time}}`, `{{datetime}}` (system clock,
|
|
5
|
+
* formatted with the configured locale) and `{{selection}}` (the selected
|
|
6
|
+
* text at expansion time). Any other name resolves from the `variables`
|
|
7
|
+
* passed to the call; a variable with no value stays visible in the text —
|
|
8
|
+
* so a typo shows up in the document instead of vanishing silently.
|
|
8
9
|
*/
|
|
9
10
|
import type { MacroHost, MacroOutcome, Snippet } from '../types.js';
|
|
10
11
|
export interface RenderContext {
|
|
11
|
-
/**
|
|
12
|
+
/** Values for custom variables. */
|
|
12
13
|
variables?: Readonly<Record<string, string>>;
|
|
13
|
-
/**
|
|
14
|
+
/** The text substituted for `{{selection}}`. */
|
|
14
15
|
selectionText?: string;
|
|
15
|
-
/**
|
|
16
|
+
/** The time for `{{date}}`/`{{time}}`. Default: now. Exists for tests. */
|
|
16
17
|
now?: Date;
|
|
18
|
+
/** BCP-47 locale for date/time formatting. Default: the browser's. */
|
|
19
|
+
locale?: string;
|
|
17
20
|
}
|
|
18
21
|
export declare function renderSnippet(text: string, context?: RenderContext): string;
|
|
19
|
-
/**
|
|
22
|
+
/** Whether the snippet uses `{{selection}}` — in which case expansion must read the selection. */
|
|
20
23
|
export declare function usesSelection(text: string): boolean;
|
|
21
24
|
export interface ExpandOptions {
|
|
22
25
|
variables?: Readonly<Record<string, string>>;
|
|
23
26
|
now?: Date;
|
|
27
|
+
locale?: string;
|
|
24
28
|
}
|
|
25
|
-
/**
|
|
29
|
+
/** Expands a snippet at the caret. */
|
|
26
30
|
export declare function expandSnippet(host: MacroHost, snippet: Pick<Snippet, 'text'>, options?: ExpandOptions): Promise<MacroOutcome>;
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
const VARIABLE_PATTERN = /\{\{\s*([\p{L}\p{N}_-]+)\s*\}\}/gu;
|
|
2
2
|
export function renderSnippet(text, context = {}) {
|
|
3
3
|
const now = context.now ?? new Date();
|
|
4
|
+
const locale = context.locale;
|
|
4
5
|
return text.replace(VARIABLE_PATTERN, (whole, rawName) => {
|
|
5
6
|
const name = rawName.toLowerCase();
|
|
6
7
|
switch (name) {
|
|
7
8
|
case 'date':
|
|
8
|
-
return now.toLocaleDateString(
|
|
9
|
+
return now.toLocaleDateString(locale);
|
|
9
10
|
case 'time':
|
|
10
|
-
return now.toLocaleTimeString(
|
|
11
|
+
return now.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
|
|
11
12
|
case 'datetime':
|
|
12
|
-
return `${now.toLocaleDateString(
|
|
13
|
+
return `${now.toLocaleDateString(locale)} ${now.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })}`;
|
|
13
14
|
case 'selection':
|
|
14
15
|
return context.selectionText ?? '';
|
|
15
16
|
default: {
|
|
@@ -19,13 +20,13 @@ export function renderSnippet(text, context = {}) {
|
|
|
19
20
|
}
|
|
20
21
|
});
|
|
21
22
|
}
|
|
22
|
-
/**
|
|
23
|
+
/** Whether the snippet uses `{{selection}}` — in which case expansion must read the selection. */
|
|
23
24
|
export function usesSelection(text) {
|
|
24
25
|
return /\{\{\s*selection\s*\}\}/iu.test(text);
|
|
25
26
|
}
|
|
26
|
-
/**
|
|
27
|
+
/** Expands a snippet at the caret. */
|
|
27
28
|
export async function expandSnippet(host, snippet, options = {}) {
|
|
28
|
-
//
|
|
29
|
+
// The selection is read only when needed: extracting its text has an engine cost.
|
|
29
30
|
const selectionText = usesSelection(snippet.text)
|
|
30
31
|
? (await host.getSelection({ includeText: true })).text
|
|
31
32
|
: undefined;
|
|
@@ -33,6 +34,7 @@ export async function expandSnippet(host, snippet, options = {}) {
|
|
|
33
34
|
variables: options.variables,
|
|
34
35
|
selectionText,
|
|
35
36
|
now: options.now,
|
|
37
|
+
locale: options.locale,
|
|
36
38
|
});
|
|
37
39
|
return host.insertText(rendered);
|
|
38
40
|
}
|