superdoc-macros 0.1.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/LICENSE +21 -0
- package/README.md +154 -0
- package/dist/host/superdoc-host.d.ts +130 -0
- package/dist/host/superdoc-host.js +257 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +10 -0
- package/dist/manager.d.ts +102 -0
- package/dist/manager.js +249 -0
- package/dist/recorder/recorder.d.ts +57 -0
- package/dist/recorder/recorder.js +141 -0
- package/dist/scripting/eval-runner.d.ts +16 -0
- package/dist/scripting/eval-runner.js +69 -0
- package/dist/scripting/iframe-runner.d.ts +41 -0
- package/dist/scripting/iframe-runner.js +150 -0
- package/dist/scripting/macro-api.d.ts +69 -0
- package/dist/scripting/macro-api.js +102 -0
- package/dist/scripting/runner.d.ts +21 -0
- package/dist/scripting/runner.js +2 -0
- package/dist/shortcuts.d.ts +39 -0
- package/dist/shortcuts.js +79 -0
- package/dist/snippets/autotext.d.ts +43 -0
- package/dist/snippets/autotext.js +106 -0
- package/dist/snippets/snippets.d.ts +26 -0
- package/dist/snippets/snippets.js +38 -0
- package/dist/storage.d.ts +24 -0
- package/dist/storage.js +65 -0
- package/dist/types.d.ts +119 -0
- package/dist/types.js +9 -0
- package/package.json +44 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { limitCalls } from './eval-runner.js';
|
|
2
|
+
import { DEFAULT_MAX_API_CALLS, DEFAULT_TIMEOUT_MS, } from './runner.js';
|
|
3
|
+
/** סימון ההודעות של הפרוטוקול, כדי לא להתנגש בהודעות אחרות בדף. */
|
|
4
|
+
export const PROTOCOL_MARK = '__otzariaMacro';
|
|
5
|
+
/** האם הודעה שייכת לפרוטוקול. חשופה לבדיקות. */
|
|
6
|
+
export function isProtocolMessage(data) {
|
|
7
|
+
return (typeof data === 'object' &&
|
|
8
|
+
data !== null &&
|
|
9
|
+
data[PROTOCOL_MARK] === true &&
|
|
10
|
+
typeof data.kind === 'string');
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* הקוד שרץ בתוך ה-iframe. מחרוזת ולא פונקציה מוסרלת — כדי שה-build לא ישנה
|
|
14
|
+
* אותו (minify של שמות היה שובר את הפרוטוקול).
|
|
15
|
+
*/
|
|
16
|
+
export const SANDBOX_BOOTSTRAP = `
|
|
17
|
+
'use strict';
|
|
18
|
+
(function () {
|
|
19
|
+
var MARK = '${PROTOCOL_MARK}';
|
|
20
|
+
var pending = new Map();
|
|
21
|
+
var seq = 0;
|
|
22
|
+
|
|
23
|
+
function post(message) {
|
|
24
|
+
message[MARK] = true;
|
|
25
|
+
parent.postMessage(message, '*');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
var api = new Proxy({}, {
|
|
29
|
+
get: function (_target, method) {
|
|
30
|
+
if (typeof method !== 'string') return undefined;
|
|
31
|
+
if (method === 'then') return undefined; // ש-await api לא יתפרש כ-thenable
|
|
32
|
+
return function () {
|
|
33
|
+
var args = Array.prototype.slice.call(arguments);
|
|
34
|
+
return new Promise(function (resolve, reject) {
|
|
35
|
+
seq += 1;
|
|
36
|
+
pending.set(seq, { resolve: resolve, reject: reject });
|
|
37
|
+
post({ kind: 'call', id: seq, method: method, args: args });
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
addEventListener('message', function (event) {
|
|
44
|
+
var m = event.data;
|
|
45
|
+
if (!m || m[MARK] !== true) return;
|
|
46
|
+
|
|
47
|
+
if (m.kind === 'run') {
|
|
48
|
+
var AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
49
|
+
Promise.resolve()
|
|
50
|
+
.then(function () {
|
|
51
|
+
var fn = new AsyncFunction('api', '"use strict";' + m.source);
|
|
52
|
+
return fn(api);
|
|
53
|
+
})
|
|
54
|
+
.then(function (value) {
|
|
55
|
+
var safe;
|
|
56
|
+
try { safe = value === undefined ? undefined : JSON.parse(JSON.stringify(value)); }
|
|
57
|
+
catch (_e) { safe = String(value); }
|
|
58
|
+
post({ kind: 'done', value: safe });
|
|
59
|
+
})
|
|
60
|
+
.catch(function (error) {
|
|
61
|
+
post({ kind: 'error', message: error && error.message ? String(error.message) : String(error) });
|
|
62
|
+
});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (m.kind === 'result') {
|
|
67
|
+
var entry = pending.get(m.id);
|
|
68
|
+
if (!entry) return;
|
|
69
|
+
pending.delete(m.id);
|
|
70
|
+
if (m.ok) entry.resolve(m.value);
|
|
71
|
+
else entry.reject(new Error(m.message || 'macro call failed'));
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
post({ kind: 'ready' });
|
|
76
|
+
})();
|
|
77
|
+
`;
|
|
78
|
+
/** ערך בטוח למסירה חזרה ל-iframe (structured clone עלול להיכשל על אובייקטי מנוע). */
|
|
79
|
+
function toCloneSafe(value) {
|
|
80
|
+
if (value === undefined || value === null)
|
|
81
|
+
return value;
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(JSON.stringify(value));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return String(value);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function createIframeRunner(doc = document) {
|
|
90
|
+
return {
|
|
91
|
+
run(source, bridge, options = {}) {
|
|
92
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
93
|
+
const limited = limitCalls(bridge, options.maxApiCalls ?? DEFAULT_MAX_API_CALLS);
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
const iframe = doc.createElement('iframe');
|
|
96
|
+
iframe.setAttribute('sandbox', 'allow-scripts');
|
|
97
|
+
iframe.style.display = 'none';
|
|
98
|
+
iframe.srcdoc = `<!doctype html><meta charset="utf-8"><script>${SANDBOX_BOOTSTRAP}</script>`;
|
|
99
|
+
let settled = false;
|
|
100
|
+
let timer;
|
|
101
|
+
const finish = (result) => {
|
|
102
|
+
if (settled)
|
|
103
|
+
return;
|
|
104
|
+
settled = true;
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
removeEventListener('message', onMessage);
|
|
107
|
+
iframe.remove();
|
|
108
|
+
resolve(result);
|
|
109
|
+
};
|
|
110
|
+
const onMessage = (event) => {
|
|
111
|
+
// רק הודעות מה-iframe הזה: דף יכול להריץ כמה מאקרו במקביל.
|
|
112
|
+
if (event.source !== iframe.contentWindow)
|
|
113
|
+
return;
|
|
114
|
+
const data = event.data;
|
|
115
|
+
if (!isProtocolMessage(data))
|
|
116
|
+
return;
|
|
117
|
+
if (data.kind === 'ready') {
|
|
118
|
+
iframe.contentWindow?.postMessage({ [PROTOCOL_MARK]: true, kind: 'run', source }, '*');
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (data.kind === 'call') {
|
|
122
|
+
const { id, method, args } = data;
|
|
123
|
+
limited
|
|
124
|
+
.call(method, args)
|
|
125
|
+
.then((value) => {
|
|
126
|
+
iframe.contentWindow?.postMessage({ [PROTOCOL_MARK]: true, kind: 'result', id, ok: true, value: toCloneSafe(value) }, '*');
|
|
127
|
+
})
|
|
128
|
+
.catch((error) => {
|
|
129
|
+
iframe.contentWindow?.postMessage({
|
|
130
|
+
[PROTOCOL_MARK]: true,
|
|
131
|
+
kind: 'result',
|
|
132
|
+
id,
|
|
133
|
+
ok: false,
|
|
134
|
+
message: error instanceof Error ? error.message : String(error),
|
|
135
|
+
}, '*');
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (data.kind === 'done')
|
|
140
|
+
finish({ ok: true, value: data.value });
|
|
141
|
+
else if (data.kind === 'error')
|
|
142
|
+
finish({ ok: false, reason: 'error', message: data.message });
|
|
143
|
+
};
|
|
144
|
+
addEventListener('message', onMessage);
|
|
145
|
+
timer = setTimeout(() => finish({ ok: false, reason: 'timeout', message: `המאקרו לא הסתיים תוך ${timeoutMs / 1000} שניות ונעצר` }), timeoutMs);
|
|
146
|
+
doc.body.appendChild(iframe);
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
import type { MacroHost, MacroOutcome } from '../types.js';
|
|
13
|
+
/** כשל של פעולת מאקרו. השם מאפשר לסקריפט להבחין בינו ובין TypeError שלו. */
|
|
14
|
+
export declare class MacroError extends Error {
|
|
15
|
+
readonly reason?: string;
|
|
16
|
+
constructor(message: string, reason?: string);
|
|
17
|
+
}
|
|
18
|
+
/** תצלום בחירה בטוח למסירה ל-iframe (בלי היעד האטום של המנוע). */
|
|
19
|
+
export interface ScriptSelection {
|
|
20
|
+
text: string;
|
|
21
|
+
hasRange: boolean;
|
|
22
|
+
blockId: string | null;
|
|
23
|
+
empty: boolean;
|
|
24
|
+
}
|
|
25
|
+
/** מה שסקריפט מקבל בתור `api`. כל המתודות א-סינכרוניות. */
|
|
26
|
+
export interface MacroApi {
|
|
27
|
+
/** מריצה פקודה מקטלוג המנוע. מחזירה תוצאה ואינה זורקת. */
|
|
28
|
+
command(id: string, payload?: unknown): Promise<MacroOutcome>;
|
|
29
|
+
/** האם המנוע מכיר את הפקודה. */
|
|
30
|
+
hasCommand(id: string): Promise<boolean>;
|
|
31
|
+
/** מזהי הפקודות המוכרות. */
|
|
32
|
+
commandIds(): Promise<readonly string[]>;
|
|
33
|
+
insertText(text: string): Promise<void>;
|
|
34
|
+
insertParagraph(): Promise<void>;
|
|
35
|
+
deleteBackward(count?: number): Promise<void>;
|
|
36
|
+
getSelection(): Promise<ScriptSelection>;
|
|
37
|
+
getSelectionText(): Promise<string>;
|
|
38
|
+
getDocumentText(): Promise<string>;
|
|
39
|
+
/** מחליפה את כל המופעים. מחזירה כמה הוחלפו. */
|
|
40
|
+
replaceAll(query: string, replacement: string): Promise<number>;
|
|
41
|
+
bold(): Promise<void>;
|
|
42
|
+
italic(): Promise<void>;
|
|
43
|
+
underline(): Promise<void>;
|
|
44
|
+
strikethrough(): Promise<void>;
|
|
45
|
+
clearFormatting(): Promise<void>;
|
|
46
|
+
bulletList(): Promise<void>;
|
|
47
|
+
numberedList(): Promise<void>;
|
|
48
|
+
indentIncrease(): Promise<void>;
|
|
49
|
+
indentDecrease(): Promise<void>;
|
|
50
|
+
directionRtl(): Promise<void>;
|
|
51
|
+
directionLtr(): Promise<void>;
|
|
52
|
+
undo(): Promise<void>;
|
|
53
|
+
redo(): Promise<void>;
|
|
54
|
+
/** כותבת שורה ליומן הריצה (מוצג למשתמש, לא ל-console). */
|
|
55
|
+
log(...parts: unknown[]): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
export interface MacroApiOptions {
|
|
58
|
+
/** מקבלת כל שורת `api.log`. ברירת המחדל: console.info. */
|
|
59
|
+
onLog?: (line: string) => void;
|
|
60
|
+
}
|
|
61
|
+
export interface MacroBridge {
|
|
62
|
+
api: MacroApi;
|
|
63
|
+
/** מסלול ה-RPC: מפעילה מתודה לפי שם. זורקת על מתודה שאינה קיימת. */
|
|
64
|
+
call(method: string, args: readonly unknown[]): Promise<unknown>;
|
|
65
|
+
/** מספר הקריאות שבוצעו עד כה. משמש לתקרת קריאות במריצים. */
|
|
66
|
+
callCount(): number;
|
|
67
|
+
}
|
|
68
|
+
/** בונה את ה-API מעל מארח. */
|
|
69
|
+
export declare function createMacroApi(host: MacroHost, options?: MacroApiOptions): MacroBridge;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** כשל של פעולת מאקרו. השם מאפשר לסקריפט להבחין בינו ובין TypeError שלו. */
|
|
2
|
+
export class MacroError extends Error {
|
|
3
|
+
reason;
|
|
4
|
+
constructor(message, reason) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'MacroError';
|
|
7
|
+
this.reason = reason;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function requireOk(outcome, action) {
|
|
11
|
+
if (!outcome.ok) {
|
|
12
|
+
throw new MacroError(`${action}: ${outcome.message}`, outcome.reason);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function asText(value, name) {
|
|
16
|
+
if (typeof value !== 'string')
|
|
17
|
+
throw new MacroError(`${name} חייב להיות מחרוזת`);
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
function formatLogPart(part) {
|
|
21
|
+
if (typeof part === 'string')
|
|
22
|
+
return part;
|
|
23
|
+
try {
|
|
24
|
+
return JSON.stringify(part);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return String(part);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** בונה את ה-API מעל מארח. */
|
|
31
|
+
export function createMacroApi(host, options = {}) {
|
|
32
|
+
const onLog = options.onLog ?? ((line) => console.info('[superdoc-macros]', line));
|
|
33
|
+
const commandSugar = async (id) => {
|
|
34
|
+
requireOk(await host.commands.execute(id), `הפקודה ${id} נכשלה`);
|
|
35
|
+
};
|
|
36
|
+
const api = {
|
|
37
|
+
command: (id, payload) => host.commands.execute(asText(id, 'id'), payload),
|
|
38
|
+
hasCommand: async (id) => host.commands.has(asText(id, 'id')),
|
|
39
|
+
commandIds: async () => host.commands.ids(),
|
|
40
|
+
async insertText(text) {
|
|
41
|
+
requireOk(await host.insertText(asText(text, 'text')), 'הכנסת הטקסט נכשלה');
|
|
42
|
+
},
|
|
43
|
+
async insertParagraph() {
|
|
44
|
+
requireOk(await host.insertText('\n'), 'הכנסת הפסקה נכשלה');
|
|
45
|
+
},
|
|
46
|
+
async deleteBackward(count = 1) {
|
|
47
|
+
const n = Math.max(0, Math.trunc(Number(count)));
|
|
48
|
+
if (n === 0)
|
|
49
|
+
return;
|
|
50
|
+
requireOk(await host.deleteBackward(n), 'המחיקה נכשלה');
|
|
51
|
+
},
|
|
52
|
+
async getSelection() {
|
|
53
|
+
const snapshot = await host.getSelection({ includeText: true });
|
|
54
|
+
return {
|
|
55
|
+
text: snapshot.text,
|
|
56
|
+
hasRange: snapshot.hasRange,
|
|
57
|
+
blockId: snapshot.blockId,
|
|
58
|
+
empty: snapshot.empty,
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
async getSelectionText() {
|
|
62
|
+
return (await host.getSelection({ includeText: true })).text;
|
|
63
|
+
},
|
|
64
|
+
getDocumentText: () => host.getDocumentText(),
|
|
65
|
+
async replaceAll(query, replacement) {
|
|
66
|
+
const result = await host.replaceAll(asText(query, 'query'), asText(replacement, 'replacement'));
|
|
67
|
+
if (!result.ok)
|
|
68
|
+
throw new MacroError(result.message ?? 'ההחלפה נכשלה');
|
|
69
|
+
return result.replaced;
|
|
70
|
+
},
|
|
71
|
+
bold: () => commandSugar('bold'),
|
|
72
|
+
italic: () => commandSugar('italic'),
|
|
73
|
+
underline: () => commandSugar('underline'),
|
|
74
|
+
strikethrough: () => commandSugar('strikethrough'),
|
|
75
|
+
clearFormatting: () => commandSugar('clear-formatting'),
|
|
76
|
+
bulletList: () => commandSugar('bullet-list'),
|
|
77
|
+
numberedList: () => commandSugar('numbered-list'),
|
|
78
|
+
indentIncrease: () => commandSugar('indent-increase'),
|
|
79
|
+
indentDecrease: () => commandSugar('indent-decrease'),
|
|
80
|
+
directionRtl: () => commandSugar('direction-rtl'),
|
|
81
|
+
directionLtr: () => commandSugar('direction-ltr'),
|
|
82
|
+
undo: () => commandSugar('undo'),
|
|
83
|
+
redo: () => commandSugar('redo'),
|
|
84
|
+
async log(...parts) {
|
|
85
|
+
onLog(parts.map(formatLogPart).join(' '));
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
let calls = 0;
|
|
89
|
+
const methods = api;
|
|
90
|
+
return {
|
|
91
|
+
api,
|
|
92
|
+
async call(method, args) {
|
|
93
|
+
const fn = methods[method];
|
|
94
|
+
if (typeof fn !== 'function' || !Object.prototype.hasOwnProperty.call(api, method)) {
|
|
95
|
+
throw new MacroError(`מתודה לא מוכרת: ${String(method)}`);
|
|
96
|
+
}
|
|
97
|
+
calls += 1;
|
|
98
|
+
return fn.apply(api, args);
|
|
99
|
+
},
|
|
100
|
+
callCount: () => calls,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** חוזה משותף לשני המריצים (eval ו-iframe). */
|
|
2
|
+
import type { MacroBridge } from './macro-api.js';
|
|
3
|
+
export type MacroRunResult = {
|
|
4
|
+
ok: true;
|
|
5
|
+
value?: unknown;
|
|
6
|
+
} | {
|
|
7
|
+
ok: false;
|
|
8
|
+
message: string;
|
|
9
|
+
reason?: 'timeout' | 'error' | 'call-limit';
|
|
10
|
+
};
|
|
11
|
+
export interface MacroRunOptions {
|
|
12
|
+
/** תקרת זמן לריצה כולה. ברירת מחדל: 30 שניות. */
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
/** תקרת קריאות API, נגד לולאה בורחת. ברירת מחדל: 10,000. */
|
|
15
|
+
maxApiCalls?: number;
|
|
16
|
+
}
|
|
17
|
+
export declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
18
|
+
export declare const DEFAULT_MAX_API_CALLS = 10000;
|
|
19
|
+
export interface MacroRunner {
|
|
20
|
+
run(source: string, bridge: MacroBridge, options?: MacroRunOptions): Promise<MacroRunResult>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* קיצורי מקלדת למאקרו ולקטעים: ניתוח מחרוזת `Ctrl+Alt+M` והתאמה לאירוע.
|
|
3
|
+
*
|
|
4
|
+
* ההתאמה לפי `event.key` באותיות קטנות. `Mod` פירושו Ctrl (או ⌘ במק).
|
|
5
|
+
*/
|
|
6
|
+
export interface ParsedShortcut {
|
|
7
|
+
key: string;
|
|
8
|
+
ctrl: boolean;
|
|
9
|
+
alt: boolean;
|
|
10
|
+
shift: boolean;
|
|
11
|
+
meta: boolean;
|
|
12
|
+
/** Ctrl או Meta — לקיצורים שנכתבו עם `Mod`. */
|
|
13
|
+
mod: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** תת-הצורה של KeyboardEvent שההתאמה צריכה. מאפשר בדיקות בלי DOM. */
|
|
16
|
+
export interface KeyEventLike {
|
|
17
|
+
key: string;
|
|
18
|
+
ctrlKey: boolean;
|
|
19
|
+
altKey: boolean;
|
|
20
|
+
shiftKey: boolean;
|
|
21
|
+
metaKey: boolean;
|
|
22
|
+
preventDefault?(): void;
|
|
23
|
+
stopPropagation?(): void;
|
|
24
|
+
}
|
|
25
|
+
export declare function parseShortcut(shortcut: string): ParsedShortcut | null;
|
|
26
|
+
export declare function eventMatches(parsed: ParsedShortcut, event: KeyEventLike): boolean;
|
|
27
|
+
export interface ShortcutBinding {
|
|
28
|
+
shortcut: string;
|
|
29
|
+
run(): void | Promise<unknown>;
|
|
30
|
+
}
|
|
31
|
+
export interface ShortcutTarget {
|
|
32
|
+
addEventListener(type: 'keydown', listener: (event: KeyboardEvent) => void, options?: boolean): void;
|
|
33
|
+
removeEventListener(type: 'keydown', listener: (event: KeyboardEvent) => void, options?: boolean): void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* קושרת קיצורים ליעד. `getBindings` נקראת בכל הקשה — כך רשימת המאקרו יכולה
|
|
37
|
+
* להשתנות בלי לקשור מחדש. מחזירה פונקציית ניתוק.
|
|
38
|
+
*/
|
|
39
|
+
export declare function bindShortcuts(target: ShortcutTarget, getBindings: () => readonly ShortcutBinding[]): () => void;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export function parseShortcut(shortcut) {
|
|
2
|
+
const parts = shortcut
|
|
3
|
+
.split('+')
|
|
4
|
+
.map((part) => part.trim())
|
|
5
|
+
.filter((part) => part.length > 0);
|
|
6
|
+
if (parts.length === 0)
|
|
7
|
+
return null;
|
|
8
|
+
const parsed = { key: '', ctrl: false, alt: false, shift: false, meta: false, mod: false };
|
|
9
|
+
for (const part of parts) {
|
|
10
|
+
switch (part.toLowerCase()) {
|
|
11
|
+
case 'ctrl':
|
|
12
|
+
case 'control':
|
|
13
|
+
parsed.ctrl = true;
|
|
14
|
+
break;
|
|
15
|
+
case 'alt':
|
|
16
|
+
case 'option':
|
|
17
|
+
parsed.alt = true;
|
|
18
|
+
break;
|
|
19
|
+
case 'shift':
|
|
20
|
+
parsed.shift = true;
|
|
21
|
+
break;
|
|
22
|
+
case 'meta':
|
|
23
|
+
case 'cmd':
|
|
24
|
+
case 'win':
|
|
25
|
+
parsed.meta = true;
|
|
26
|
+
break;
|
|
27
|
+
case 'mod':
|
|
28
|
+
parsed.mod = true;
|
|
29
|
+
break;
|
|
30
|
+
default: {
|
|
31
|
+
if (parsed.key)
|
|
32
|
+
return null; // שני מקשים שאינם modifiers — קיצור פסול.
|
|
33
|
+
parsed.key = normalizeKey(part);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return parsed.key ? parsed : null;
|
|
38
|
+
}
|
|
39
|
+
function normalizeKey(key) {
|
|
40
|
+
const lower = key.toLowerCase();
|
|
41
|
+
if (lower === 'space')
|
|
42
|
+
return ' ';
|
|
43
|
+
if (lower === 'esc')
|
|
44
|
+
return 'escape';
|
|
45
|
+
return lower;
|
|
46
|
+
}
|
|
47
|
+
export function eventMatches(parsed, event) {
|
|
48
|
+
if (normalizeKey(event.key) !== parsed.key)
|
|
49
|
+
return false;
|
|
50
|
+
if (parsed.mod) {
|
|
51
|
+
if (!event.ctrlKey && !event.metaKey)
|
|
52
|
+
return false;
|
|
53
|
+
// עם Mod לא בודקים ctrl/meta בנפרד — אבל alt/shift חייבים להתאים בדיוק.
|
|
54
|
+
return event.altKey === parsed.alt && event.shiftKey === parsed.shift;
|
|
55
|
+
}
|
|
56
|
+
return (event.ctrlKey === parsed.ctrl &&
|
|
57
|
+
event.altKey === parsed.alt &&
|
|
58
|
+
event.shiftKey === parsed.shift &&
|
|
59
|
+
event.metaKey === parsed.meta);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* קושרת קיצורים ליעד. `getBindings` נקראת בכל הקשה — כך רשימת המאקרו יכולה
|
|
63
|
+
* להשתנות בלי לקשור מחדש. מחזירה פונקציית ניתוק.
|
|
64
|
+
*/
|
|
65
|
+
export function bindShortcuts(target, getBindings) {
|
|
66
|
+
const listener = (event) => {
|
|
67
|
+
for (const binding of getBindings()) {
|
|
68
|
+
const parsed = parseShortcut(binding.shortcut);
|
|
69
|
+
if (!parsed || !eventMatches(parsed, event))
|
|
70
|
+
continue;
|
|
71
|
+
event.preventDefault?.();
|
|
72
|
+
event.stopPropagation?.();
|
|
73
|
+
void binding.run();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
target.addEventListener('keydown', listener, true);
|
|
78
|
+
return () => target.removeEventListener('keydown', listener, true);
|
|
79
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* השלמה אוטומטית (AutoText): הקלדת מילת ההפעלה של קטע ואחריה רווח מחליפה את
|
|
3
|
+
* המילה בתוכן הקטע.
|
|
4
|
+
*
|
|
5
|
+
* איך זה עובד: נשמר חוצץ קטן של התווים שהוקלדו ברצף הנוכחי (מתוך אירועי
|
|
6
|
+
* `TextInputEvent` של המארח). כשמוקלד תו הרחבה (רווח כברירת מחדל) והמילה
|
|
7
|
+
* שלפניו היא trigger של קטע — המילה ותו ההרחבה נמחקים לאחור, והתוכן המורחב
|
|
8
|
+
* מוכנס במקומם עם תו ההרחבה בסופו.
|
|
9
|
+
*
|
|
10
|
+
* החוצץ מתאפס על פסקה חדשה, על מחיקה קדימה ועל פקודה שרצה באמצע — כל דבר
|
|
11
|
+
* שמנתק את הרצף בין מה שהוקלד ובין מה שנמצא בפועל לפני הסמן. עדיף פספוס
|
|
12
|
+
* הרחבה על הרחבה שמוחקת טקסט לא נכון.
|
|
13
|
+
*/
|
|
14
|
+
import type { MacroHost, Snippet } from '../types.js';
|
|
15
|
+
export interface AutoTextOptions {
|
|
16
|
+
/** תווי ההרחבה. ברירת מחדל: רווח בלבד. */
|
|
17
|
+
expandOn?: readonly string[];
|
|
18
|
+
/** גודל החוצץ. מילת הפעלה ארוכה מזה לא תזוהה. */
|
|
19
|
+
bufferSize?: number;
|
|
20
|
+
/** נקראת אחרי הרחבה מוצלחת. */
|
|
21
|
+
onExpand?: (snippet: Snippet) => void;
|
|
22
|
+
/** נקראת כשהרחבה נכשלה (למשל מסמך לקריאה בלבד). */
|
|
23
|
+
onError?: (message: string) => void;
|
|
24
|
+
}
|
|
25
|
+
export declare class AutoText {
|
|
26
|
+
private readonly host;
|
|
27
|
+
private readonly getSnippets;
|
|
28
|
+
private readonly expandOn;
|
|
29
|
+
private readonly bufferSize;
|
|
30
|
+
private readonly onExpand?;
|
|
31
|
+
private readonly onError?;
|
|
32
|
+
private buffer;
|
|
33
|
+
private busy;
|
|
34
|
+
private disposeCommand;
|
|
35
|
+
private disposeInput;
|
|
36
|
+
constructor(host: MacroHost, getSnippets: () => readonly Snippet[], options?: AutoTextOptions);
|
|
37
|
+
get attached(): boolean;
|
|
38
|
+
attach(): () => void;
|
|
39
|
+
detach(): void;
|
|
40
|
+
private handleInput;
|
|
41
|
+
private findByTrigger;
|
|
42
|
+
private expand;
|
|
43
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { renderSnippet, usesSelection } from './snippets.js';
|
|
2
|
+
const DEFAULT_BUFFER = 64;
|
|
3
|
+
export class AutoText {
|
|
4
|
+
host;
|
|
5
|
+
getSnippets;
|
|
6
|
+
expandOn;
|
|
7
|
+
bufferSize;
|
|
8
|
+
onExpand;
|
|
9
|
+
onError;
|
|
10
|
+
buffer = '';
|
|
11
|
+
busy = false;
|
|
12
|
+
disposeCommand = null;
|
|
13
|
+
disposeInput = null;
|
|
14
|
+
constructor(host, getSnippets, options = {}) {
|
|
15
|
+
this.host = host;
|
|
16
|
+
this.getSnippets = getSnippets;
|
|
17
|
+
this.expandOn = new Set(options.expandOn ?? [' ']);
|
|
18
|
+
this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER;
|
|
19
|
+
this.onExpand = options.onExpand;
|
|
20
|
+
this.onError = options.onError;
|
|
21
|
+
}
|
|
22
|
+
get attached() {
|
|
23
|
+
return this.disposeInput !== null;
|
|
24
|
+
}
|
|
25
|
+
attach() {
|
|
26
|
+
if (this.disposeInput)
|
|
27
|
+
return () => this.detach();
|
|
28
|
+
this.buffer = '';
|
|
29
|
+
this.disposeInput = this.host.onTextInput((event) => void this.handleInput(event));
|
|
30
|
+
// פקודה באמצע הקלדה (עיצוב, הדבקה) מנתקת את הקשר בין החוצץ למסמך.
|
|
31
|
+
this.disposeCommand = this.host.onCommand(() => {
|
|
32
|
+
if (!this.busy)
|
|
33
|
+
this.buffer = '';
|
|
34
|
+
});
|
|
35
|
+
return () => this.detach();
|
|
36
|
+
}
|
|
37
|
+
detach() {
|
|
38
|
+
this.disposeInput?.();
|
|
39
|
+
this.disposeCommand?.();
|
|
40
|
+
this.disposeInput = null;
|
|
41
|
+
this.disposeCommand = null;
|
|
42
|
+
this.buffer = '';
|
|
43
|
+
}
|
|
44
|
+
async handleInput(event) {
|
|
45
|
+
// קלט שנוצר בזמן שההרחבה עצמה כותבת — לא חלק מההקלדה של המשתמש.
|
|
46
|
+
if (this.busy)
|
|
47
|
+
return;
|
|
48
|
+
switch (event.kind) {
|
|
49
|
+
case 'insert-paragraph':
|
|
50
|
+
case 'delete-forward':
|
|
51
|
+
this.buffer = '';
|
|
52
|
+
return;
|
|
53
|
+
case 'delete-backward':
|
|
54
|
+
this.buffer = this.buffer.slice(0, -1);
|
|
55
|
+
return;
|
|
56
|
+
case 'insert-text':
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
for (const char of event.text) {
|
|
60
|
+
if (this.expandOn.has(char)) {
|
|
61
|
+
const trigger = trailingWord(this.buffer);
|
|
62
|
+
const snippet = trigger ? this.findByTrigger(trigger) : undefined;
|
|
63
|
+
this.buffer = '';
|
|
64
|
+
if (snippet && trigger)
|
|
65
|
+
await this.expand(snippet, trigger, char);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
this.buffer = (this.buffer + char).slice(-this.bufferSize);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
findByTrigger(word) {
|
|
72
|
+
return this.getSnippets().find((snippet) => snippet.trigger === word);
|
|
73
|
+
}
|
|
74
|
+
async expand(snippet, trigger, expandChar) {
|
|
75
|
+
this.busy = true;
|
|
76
|
+
try {
|
|
77
|
+
// אירוע הקלט (beforeinput) נורה לפני שהתו נכתב למסמך. הדחייה לתור
|
|
78
|
+
// המשימות מבטיחה שתו ההרחבה כבר בפנים לפני שמוחקים אותו יחד עם ה-trigger.
|
|
79
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
80
|
+
const selectionText = usesSelection(snippet.text)
|
|
81
|
+
? (await this.host.getSelection({ includeText: true })).text
|
|
82
|
+
: undefined;
|
|
83
|
+
const rendered = renderSnippet(snippet.text, { selectionText });
|
|
84
|
+
// תו ההרחבה כבר נכתב למסמך כשמגיעים לכאן, ולכן הוא נכלל במחיקה ומוחזר בסוף.
|
|
85
|
+
const deleted = await this.host.deleteBackward(trigger.length + 1);
|
|
86
|
+
if (!deleted.ok) {
|
|
87
|
+
this.onError?.(deleted.message);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const inserted = await this.host.insertText(rendered + expandChar);
|
|
91
|
+
if (!inserted.ok) {
|
|
92
|
+
this.onError?.(inserted.message);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
this.onExpand?.(snippet);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
this.busy = false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** המילה שבסוף החוצץ — רצף שאינו רווח לבן. */
|
|
103
|
+
function trailingWord(buffer) {
|
|
104
|
+
const match = /(\S+)$/u.exec(buffer);
|
|
105
|
+
return match?.[1] ?? null;
|
|
106
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* קטעי טקסט (Snippets): תבניות שמוכנסות במיקום הסמן, עם משתני `{{...}}`.
|
|
3
|
+
*
|
|
4
|
+
* משתנים מובנים: `{{date}}`, `{{time}}`, `{{datetime}}` (בעברית, לפי שעון
|
|
5
|
+
* המערכת), `{{selection}}` (הטקסט המסומן ברגע ההרחבה). כל שם אחר נפתר מתוך
|
|
6
|
+
* `variables` שנמסרו בקריאה; משתנה שאין לו ערך נשאר כמו שהוא בטקסט — כדי
|
|
7
|
+
* שטעות כתיב תיראה במסמך ולא תיעלם בשקט.
|
|
8
|
+
*/
|
|
9
|
+
import type { MacroHost, MacroOutcome, Snippet } from '../types.js';
|
|
10
|
+
export interface RenderContext {
|
|
11
|
+
/** ערכים למשתנים מותאמים. */
|
|
12
|
+
variables?: Readonly<Record<string, string>>;
|
|
13
|
+
/** הטקסט שיוצב ב-`{{selection}}`. */
|
|
14
|
+
selectionText?: string;
|
|
15
|
+
/** הזמן ל-`{{date}}`/`{{time}}`. ברירת מחדל: עכשיו. קיים בשביל בדיקות. */
|
|
16
|
+
now?: Date;
|
|
17
|
+
}
|
|
18
|
+
export declare function renderSnippet(text: string, context?: RenderContext): string;
|
|
19
|
+
/** האם הקטע משתמש ב-`{{selection}}` — ואז ההרחבה צריכה לקרוא את הבחירה. */
|
|
20
|
+
export declare function usesSelection(text: string): boolean;
|
|
21
|
+
export interface ExpandOptions {
|
|
22
|
+
variables?: Readonly<Record<string, string>>;
|
|
23
|
+
now?: Date;
|
|
24
|
+
}
|
|
25
|
+
/** מרחיבה קטע במיקום הסמן. */
|
|
26
|
+
export declare function expandSnippet(host: MacroHost, snippet: Pick<Snippet, 'text'>, options?: ExpandOptions): Promise<MacroOutcome>;
|