galath 1.0.2
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/AGENTS.md +1 -0
- package/README.md +206 -0
- package/TODO.md +140 -0
- package/index.html +188 -0
- package/logo.jpg +0 -0
- package/logo.svg +96 -0
- package/package.json +32 -0
- package/packages/galath/package.json +28 -0
- package/packages/galath/src/behavior.js +193 -0
- package/packages/galath/src/binding.js +247 -0
- package/packages/galath/src/boot.js +52 -0
- package/packages/galath/src/command.js +117 -0
- package/packages/galath/src/component.js +505 -0
- package/packages/galath/src/controller.js +181 -0
- package/packages/galath/src/core.js +190 -0
- package/packages/galath/src/imports.js +132 -0
- package/packages/galath/src/index.js +38 -0
- package/packages/galath/src/instance-model.js +343 -0
- package/packages/galath/src/morph.js +237 -0
- package/packages/galath/src/rendering.js +556 -0
- package/packages/galath/src/signals.js +215 -0
- package/packages/galath/src/templates.js +24 -0
- package/packages/galath/src/xml-events.js +53 -0
- package/packages/galath-css/css/bootstrap-icons.min.css +5 -0
- package/packages/galath-css/css/bootstrap.min.css +6 -0
- package/packages/galath-css/css/fonts/bootstrap-icons.json +2077 -0
- package/packages/galath-css/css/fonts/bootstrap-icons.woff +0 -0
- package/packages/galath-css/css/fonts/bootstrap-icons.woff2 +0 -0
- package/packages/galath-css/js/bootstrap.bundle.min.js +7 -0
- package/packages/galath-css/package.json +13 -0
- package/playground/app.xml +214 -0
- package/playground/chapters/01-welcome.xml +94 -0
- package/playground/chapters/02-signals.xml +166 -0
- package/playground/chapters/03-instance.xml +130 -0
- package/playground/chapters/04-bindings.xml +156 -0
- package/playground/chapters/05-lists.xml +138 -0
- package/playground/chapters/06-commands.xml +144 -0
- package/playground/chapters/07-controller.xml +115 -0
- package/playground/chapters/08-events.xml +126 -0
- package/playground/chapters/09-behaviors.xml +210 -0
- package/playground/chapters/10-components.xml +152 -0
- package/playground/chapters/11-imports.xml +108 -0
- package/playground/chapters/12-expressions.xml +161 -0
- package/playground/chapters/13-paths.xml +197 -0
- package/playground/components/chapter-shell.xml +29 -0
- package/playground/components/highlighter.js +111 -0
- package/playground/components/run-snippet.js +120 -0
- package/public/basic/bootstrap-icons.min.css +5 -0
- package/public/basic/bootstrap.bundle.min.js +7 -0
- package/public/basic/bootstrap.min.css +6 -0
- package/public/basic/fonts/bootstrap-icons.json +2077 -0
- package/public/basic/fonts/bootstrap-icons.woff +0 -0
- package/public/basic/fonts/bootstrap-icons.woff2 +0 -0
- package/public/basic/theme.css +209 -0
- package/seed.html +321 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// binding.js
|
|
3
|
+
//
|
|
4
|
+
// Expression evaluation, value resolution, and HTML escaping for templates.
|
|
5
|
+
//
|
|
6
|
+
// Galath expressions appear in three places:
|
|
7
|
+
//
|
|
8
|
+
// 1. Attribute interpolation: class="card {active ? 'on' : ''}"
|
|
9
|
+
// 2. Bind shorthand: bind:value="/draft/@text"
|
|
10
|
+
// 3. Inline event code: on:click="set('x', 1)"
|
|
11
|
+
//
|
|
12
|
+
// All three flow through `evaluate(expr, instance, local, fallback, event)`,
|
|
13
|
+
// which:
|
|
14
|
+
//
|
|
15
|
+
// * Tries a *path* shortcut first (`/foo/@bar`, `$todo`, `signalName`).
|
|
16
|
+
// Plain identifiers and paths skip JS entirely - that's both faster and
|
|
17
|
+
// safer (no eval needed for the 90% case).
|
|
18
|
+
//
|
|
19
|
+
// * Falls back to `Function('ctx', 'with(ctx) { return (expr); }')` so
|
|
20
|
+
// real expressions like `count > 0 && !disabled` still work. The `with`
|
|
21
|
+
// block puts every signal value, every helper (`select`, `valueOf`,
|
|
22
|
+
// `set`, `uid`, `deleteNode`...), and every loop-local (`$todo`,
|
|
23
|
+
// `index`) in scope. We *do* run user code via Function() - this is the
|
|
24
|
+
// same threat surface as putting code in `<eval>`. Galath sources are
|
|
25
|
+
// authored, not user-supplied; treat them like any other web page code.
|
|
26
|
+
//
|
|
27
|
+
// * Catches exceptions and returns a fallback so a single broken
|
|
28
|
+
// attribute doesn't blank out the whole view. The renderer logs the
|
|
29
|
+
// failure to the console.
|
|
30
|
+
//
|
|
31
|
+
// `interpolate(text)` runs `{...}` placeholders through `evaluate` and HTML-
|
|
32
|
+
// escapes the result by default - this is *crucial* for safety. A signal
|
|
33
|
+
// containing user-supplied text must never be injected as raw markup.
|
|
34
|
+
// =============================================================================
|
|
35
|
+
|
|
36
|
+
import { assert } from './core.js';
|
|
37
|
+
|
|
38
|
+
export function bindingFeature(language) {
|
|
39
|
+
/**
|
|
40
|
+
* Standard HTML escape. Used for any value that goes into the rendered
|
|
41
|
+
* HTML string before the browser parses it.
|
|
42
|
+
*/
|
|
43
|
+
function escapeHtml(value) {
|
|
44
|
+
return String(value ?? '')
|
|
45
|
+
.replaceAll('&', '&')
|
|
46
|
+
.replaceAll('<', '<')
|
|
47
|
+
.replaceAll('>', '>')
|
|
48
|
+
.replaceAll('"', '"')
|
|
49
|
+
.replaceAll("'", ''');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the context object passed to evaluated expressions. This is the
|
|
54
|
+
* runtime "global" of Galath expressions:
|
|
55
|
+
*
|
|
56
|
+
* * Every signal name is a property holding its current value.
|
|
57
|
+
* * Every loop variable from `<repeat as="x">` (and its `$x` form).
|
|
58
|
+
* * `$event` - the originating DOM event (for on:* handlers).
|
|
59
|
+
* * Helper functions: `uid`, `select`, `valueOf`, `set`, `attr`,
|
|
60
|
+
* `deleteNode`, `setNode`.
|
|
61
|
+
*
|
|
62
|
+
* Helpers are intentionally minimal. Anything more advanced should live
|
|
63
|
+
* in a `<controller>` action where it's reviewable and reusable.
|
|
64
|
+
*
|
|
65
|
+
* The context is a Proxy so that signal reads are *recorded* on
|
|
66
|
+
* `instance.readSignals` (when set). The renderer uses that record to
|
|
67
|
+
* subscribe only to signals that the last render actually consulted -
|
|
68
|
+
* unrelated signal changes no longer schedule a render. Helpers and
|
|
69
|
+
* locals fall through to the underlying object; unrecognized names
|
|
70
|
+
* fall through to the outer scope (via `with`'s standard chain) so
|
|
71
|
+
* globals like `Number`, `Math`, etc. still resolve.
|
|
72
|
+
*/
|
|
73
|
+
function buildContext(instance, local = {}, event = null) {
|
|
74
|
+
const base = {
|
|
75
|
+
...local,
|
|
76
|
+
$event: event,
|
|
77
|
+
uid: language.uid,
|
|
78
|
+
select: path => instance.tree?.select(path, local) ?? [],
|
|
79
|
+
valueOf: path => valueOf(path, instance, local),
|
|
80
|
+
set: (name, value) => {
|
|
81
|
+
const sig = instance.scope.signal(name);
|
|
82
|
+
if (sig) sig.value = value;
|
|
83
|
+
},
|
|
84
|
+
attr: (node, name) => node?.get?.(name) ?? '',
|
|
85
|
+
deleteNode: node => node?.remove(),
|
|
86
|
+
setNode: (node, name, value) => node?.set?.(name, value),
|
|
87
|
+
// Climb to the nearest enclosing component instance and read one of
|
|
88
|
+
// its signals. Returns '' when no parent is found - keeps
|
|
89
|
+
// expressions safe even if the component is mounted standalone.
|
|
90
|
+
parentSignal: name => language.parentSignal?.(instance, name) ?? '',
|
|
91
|
+
// XForms-style validation helpers. They read the `<bind>` declarations
|
|
92
|
+
// collected during setupModel and evaluate them against the live
|
|
93
|
+
// instance value. `valid('/path')` returns true when every applicable
|
|
94
|
+
// bind passes; `invalid` is its inverse. `required('/path')` reports
|
|
95
|
+
// whether the path was declared required. `validity('/path')` returns
|
|
96
|
+
// a summary object so views can show targeted error messages.
|
|
97
|
+
valid: path => language.checkValidity?.(instance, path, local).valid ?? true,
|
|
98
|
+
invalid: path => !(language.checkValidity?.(instance, path, local).valid ?? true),
|
|
99
|
+
required: path => language.checkValidity?.(instance, path, local).required ?? false,
|
|
100
|
+
validity: path => language.checkValidity?.(instance, path, local) ?? { valid: true },
|
|
101
|
+
};
|
|
102
|
+
return new Proxy(base, {
|
|
103
|
+
get(target, key) {
|
|
104
|
+
if (key in target) return target[key];
|
|
105
|
+
const sig = instance.scope?.signal?.(key);
|
|
106
|
+
if (sig) {
|
|
107
|
+
instance.readSignals?.add(key);
|
|
108
|
+
return sig.value;
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
},
|
|
112
|
+
has(target, key) {
|
|
113
|
+
if (key in target) return true;
|
|
114
|
+
return Boolean(instance.scope?.signal?.(key));
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Return the value at `path` from the instance tree, falling back to a
|
|
121
|
+
* named signal if the tree has no match.
|
|
122
|
+
*/
|
|
123
|
+
function valueOf(path, instance, local = {}) {
|
|
124
|
+
if (instance.tree) {
|
|
125
|
+
const treeValue = instance.tree.valueOf(path, local);
|
|
126
|
+
if (treeValue !== '') return treeValue;
|
|
127
|
+
}
|
|
128
|
+
const sig = instance.scope?.signal?.(path);
|
|
129
|
+
if (sig) {
|
|
130
|
+
instance.readSignals?.add(path);
|
|
131
|
+
return sig.value;
|
|
132
|
+
}
|
|
133
|
+
return '';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The hot path. Evaluate `expr` against the instance + local context.
|
|
138
|
+
*
|
|
139
|
+
* Returns `fallback` if evaluation throws (and logs to the console). We
|
|
140
|
+
* pick the cheap path - signal name or path lookup - first. Only when
|
|
141
|
+
* neither matches do we compile a Function.
|
|
142
|
+
*/
|
|
143
|
+
function evaluate(expr, instance, local = {}, fallback = '', event = null) {
|
|
144
|
+
const direct = simplePathValue(expr, instance, local);
|
|
145
|
+
if (direct.found) return direct.value;
|
|
146
|
+
try {
|
|
147
|
+
// eslint-disable-next-line no-new-func
|
|
148
|
+
return Function(
|
|
149
|
+
'ctx',
|
|
150
|
+
`with (ctx) { return (${expr}); }`,
|
|
151
|
+
)(buildContext(instance, local, event));
|
|
152
|
+
} catch (error) {
|
|
153
|
+
console.warn('[galath] expression failed:', expr, error);
|
|
154
|
+
return fallback;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Run `code` for side effects (no return value). Used by `on:click` and
|
|
160
|
+
* `<eval>` operations.
|
|
161
|
+
*/
|
|
162
|
+
function run(code, instance, local = {}, event = null) {
|
|
163
|
+
try {
|
|
164
|
+
// eslint-disable-next-line no-new-func
|
|
165
|
+
return Function(
|
|
166
|
+
'ctx',
|
|
167
|
+
`with (ctx) { ${code}; }`,
|
|
168
|
+
)(buildContext(instance, local, event));
|
|
169
|
+
} catch (error) {
|
|
170
|
+
console.error('[galath] handler failed:', code, error);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Cheap shortcut for "the expression is just a name or a path". Avoids
|
|
176
|
+
* compiling a Function for the most common case.
|
|
177
|
+
*
|
|
178
|
+
* IMPORTANT: only return a path-style match when the expression has no
|
|
179
|
+
* JS operators / whitespace. Otherwise something like
|
|
180
|
+
* $book/@done ? 'on' : 'off'
|
|
181
|
+
* would short-circuit on the leading `$`, return the boolean, and drop
|
|
182
|
+
* the ternary.
|
|
183
|
+
*/
|
|
184
|
+
function simplePathValue(expr, instance, local) {
|
|
185
|
+
expr = String(expr ?? '').trim();
|
|
186
|
+
const sig = instance.scope?.signal?.(expr);
|
|
187
|
+
if (sig) {
|
|
188
|
+
instance.readSignals?.add(expr);
|
|
189
|
+
return { found: true, value: sig.value };
|
|
190
|
+
}
|
|
191
|
+
if (
|
|
192
|
+
(expr.startsWith('/') || expr.startsWith('$')) &&
|
|
193
|
+
!JS_EXPRESSION_HINT.test(expr)
|
|
194
|
+
) {
|
|
195
|
+
return { found: true, value: instance.tree?.valueOf(expr, local) ?? '' };
|
|
196
|
+
}
|
|
197
|
+
return { found: false, value: undefined };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Any of these characters mean the expression isn't a bare path - it's
|
|
201
|
+
// JS we need to compile. We do NOT include parens because `text()`
|
|
202
|
+
// selectors use them and the JS evaluator can't parse `/foo/text()`
|
|
203
|
+
// (it parses as `/regex/ / text()`). We do NOT include `*` because it
|
|
204
|
+
// appears in wildcard path steps. If you write `$a + $b`, the `+`
|
|
205
|
+
// catches it. Dot is included so `$event.currentTarget...` remains normal
|
|
206
|
+
// JavaScript property access instead of being mistaken for an XML path.
|
|
207
|
+
const JS_EXPRESSION_HINT = /[\s.?<>&|,;!{}]/;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Replace `{expr}` placeholders inside a template string. By default the
|
|
211
|
+
* result is HTML-escaped. Pass `{ raw: true }` to skip escaping (for
|
|
212
|
+
* places like component attributes whose value is parsed again later).
|
|
213
|
+
*/
|
|
214
|
+
function interpolate(text, instance, local = {}, options = {}) {
|
|
215
|
+
const rendered = String(text ?? '').replace(/\{([^}]+)\}/g, (_, expression) =>
|
|
216
|
+
String(evaluate(expression.trim(), instance, local)),
|
|
217
|
+
);
|
|
218
|
+
return options.raw ? rendered : escapeHtml(rendered);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Publish helpers.
|
|
222
|
+
language.escapeHtml = escapeHtml;
|
|
223
|
+
language.evaluate = evaluate;
|
|
224
|
+
language.run = run;
|
|
225
|
+
language.valueOf = valueOf;
|
|
226
|
+
language.interpolate = interpolate;
|
|
227
|
+
language.buildContext = buildContext;
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Self-tests
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
language.test('binding: interpolation escapes embedded markup', () => {
|
|
233
|
+
const fake = { scope: new language.Concern('test'), tree: null };
|
|
234
|
+
fake.scope.signal('snippet', new language.Signal('<x-unsafe></x-unsafe>'));
|
|
235
|
+
const r = interpolate('{snippet}', fake);
|
|
236
|
+
assert(r.includes('<x-unsafe>'), 'snippet was not escaped');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
language.test('binding: dotted event expressions evaluate as JavaScript', () => {
|
|
240
|
+
const fake = { scope: new language.Concern('test'), tree: null };
|
|
241
|
+
const event = { currentTarget: { dataset: { chapterId: 'signals' } } };
|
|
242
|
+
assert(
|
|
243
|
+
evaluate('$event.currentTarget.dataset.chapterId', fake, {}, '', event) === 'signals',
|
|
244
|
+
'dotted $event expression was treated as a data path',
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// boot.js
|
|
3
|
+
//
|
|
4
|
+
// `boot({ source, mount })` - the one-call setup. Creates a language, plugs
|
|
5
|
+
// every standard feature in the right order, and starts it. Returns the
|
|
6
|
+
// language object so callers can poke at internals (instance trees,
|
|
7
|
+
// signals, etc.) for debugging / testing.
|
|
8
|
+
//
|
|
9
|
+
// Order of features matters: core must come first (others depend on
|
|
10
|
+
// `language.parseSource`); rendering must come last (it uses helpers added
|
|
11
|
+
// by every other feature). The order encoded here is the canonical one.
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
import { createLanguage, coreFeature } from './core.js';
|
|
15
|
+
import { signalsAndScopesFeature } from './signals.js';
|
|
16
|
+
import { instanceModelFeature } from './instance-model.js';
|
|
17
|
+
import { bindingFeature } from './binding.js';
|
|
18
|
+
import { commandFeature } from './command.js';
|
|
19
|
+
import { controllerFeature } from './controller.js';
|
|
20
|
+
import { templateItemsFeature } from './templates.js';
|
|
21
|
+
import { behaviorFeature } from './behavior.js';
|
|
22
|
+
import { xmlEventsFeature } from './xml-events.js';
|
|
23
|
+
import { importFeature } from './imports.js';
|
|
24
|
+
import { componentFeature } from './component.js';
|
|
25
|
+
import { renderingFeature } from './rendering.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build a fully-loaded Galath language and `start()` it.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} options
|
|
31
|
+
* @param {string} options.source - The Galath XML source.
|
|
32
|
+
* @param {Element} options.mount - Mount point for `<application>`.
|
|
33
|
+
* @returns {Promise<object>} the started language.
|
|
34
|
+
*/
|
|
35
|
+
export async function boot({ source, mount }) {
|
|
36
|
+
const language = createLanguage({ source, mount })
|
|
37
|
+
.use(coreFeature)
|
|
38
|
+
.use(signalsAndScopesFeature)
|
|
39
|
+
.use(instanceModelFeature)
|
|
40
|
+
.use(bindingFeature)
|
|
41
|
+
.use(commandFeature)
|
|
42
|
+
.use(controllerFeature)
|
|
43
|
+
.use(templateItemsFeature)
|
|
44
|
+
.use(behaviorFeature)
|
|
45
|
+
.use(xmlEventsFeature)
|
|
46
|
+
.use(importFeature)
|
|
47
|
+
.use(componentFeature)
|
|
48
|
+
.use(renderingFeature);
|
|
49
|
+
|
|
50
|
+
await language.start();
|
|
51
|
+
return language;
|
|
52
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// command.js
|
|
3
|
+
//
|
|
4
|
+
// XUL-style commandsets. A `<commandset>` block declares named commands
|
|
5
|
+
// with optional `enabled="..."` predicates. Buttons / menu items reference
|
|
6
|
+
// them with `command="addTodo"` and the runtime wires up:
|
|
7
|
+
//
|
|
8
|
+
// * Click -> execute the command operations.
|
|
9
|
+
// * Enabled state -> the button is `disabled` whenever the predicate is
|
|
10
|
+
// falsey, automatically re-evaluated as part of the render pass.
|
|
11
|
+
//
|
|
12
|
+
// Operations live as the children of `<command>` and are interpreted by
|
|
13
|
+
// `controller.runOperations` (see controller.js). This file only registers
|
|
14
|
+
// commands and exposes execute/enabled helpers.
|
|
15
|
+
// =============================================================================
|
|
16
|
+
|
|
17
|
+
export function commandFeature(language) {
|
|
18
|
+
/**
|
|
19
|
+
* Scan a component definition for its `<commandset>` and index commands
|
|
20
|
+
* by name. Called once during component connect.
|
|
21
|
+
*
|
|
22
|
+
* Commands declaring a `shortcut="ctrl+s"` (etc) get a global keydown
|
|
23
|
+
* listener installed against the host element. Shortcut grammar is
|
|
24
|
+
* intentionally tiny: zero or more modifiers from {ctrl, alt, shift,
|
|
25
|
+
* meta} plus a single key, joined by `+`. Matching is
|
|
26
|
+
* case-insensitive on the key.
|
|
27
|
+
*/
|
|
28
|
+
language.setupCommands = instance => {
|
|
29
|
+
instance.commands = new Map();
|
|
30
|
+
const commandset = language.firstChildElement(instance.definition, 'commandset');
|
|
31
|
+
if (!commandset) return;
|
|
32
|
+
for (const command of language.childElements(commandset, 'command')) {
|
|
33
|
+
instance.commands.set(command.getAttribute('name'), command);
|
|
34
|
+
}
|
|
35
|
+
installCommandShortcuts(instance);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Wire up `shortcut="ctrl+s"` keybindings. The listener lives on
|
|
40
|
+
* `document` so the shortcut works no matter where focus is. We only
|
|
41
|
+
* install one listener per instance; it dispatches to the correct
|
|
42
|
+
* command by walking the indexed map. Listener is collected on
|
|
43
|
+
* instance.scope so it's removed at unmount.
|
|
44
|
+
*/
|
|
45
|
+
function installCommandShortcuts(instance) {
|
|
46
|
+
const entries = [];
|
|
47
|
+
for (const [name, command] of instance.commands) {
|
|
48
|
+
const shortcut = command.getAttribute('shortcut');
|
|
49
|
+
if (shortcut) entries.push({ name, combo: parseShortcut(shortcut) });
|
|
50
|
+
}
|
|
51
|
+
if (entries.length === 0) return;
|
|
52
|
+
|
|
53
|
+
const handler = event => {
|
|
54
|
+
// Don't poach typing in editable controls unless the shortcut is
|
|
55
|
+
// explicitly modifier-bearing (Ctrl/Cmd/Alt). Plain alphanumeric
|
|
56
|
+
// shortcuts would be hostile in an <input>.
|
|
57
|
+
const target = event.target;
|
|
58
|
+
const editable =
|
|
59
|
+
target?.isContentEditable ||
|
|
60
|
+
target?.matches?.('input, textarea, select');
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (!matches(entry.combo, event)) continue;
|
|
63
|
+
if (editable && !(entry.combo.ctrl || entry.combo.meta || entry.combo.alt)) continue;
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
language.executeCommand(instance, entry.name, {}, event);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
document.addEventListener('keydown', handler);
|
|
70
|
+
instance.scope.collect(() =>
|
|
71
|
+
document.removeEventListener('keydown', handler),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseShortcut(spec) {
|
|
76
|
+
const parts = String(spec).toLowerCase().split('+').map(s => s.trim()).filter(Boolean);
|
|
77
|
+
const combo = { ctrl: false, alt: false, shift: false, meta: false, key: '' };
|
|
78
|
+
for (const part of parts) {
|
|
79
|
+
if (part === 'ctrl' || part === 'control') combo.ctrl = true;
|
|
80
|
+
else if (part === 'alt' || part === 'option') combo.alt = true;
|
|
81
|
+
else if (part === 'shift') combo.shift = true;
|
|
82
|
+
else if (part === 'meta' || part === 'cmd' || part === 'command') combo.meta = true;
|
|
83
|
+
else combo.key = part;
|
|
84
|
+
}
|
|
85
|
+
return combo;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function matches(combo, event) {
|
|
89
|
+
if (Boolean(event.ctrlKey) !== combo.ctrl) return false;
|
|
90
|
+
if (Boolean(event.altKey) !== combo.alt) return false;
|
|
91
|
+
if (Boolean(event.shiftKey) !== combo.shift) return false;
|
|
92
|
+
if (Boolean(event.metaKey) !== combo.meta) return false;
|
|
93
|
+
return String(event.key || '').toLowerCase() === combo.key;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `true` if the command has no `enabled` attribute, otherwise the value
|
|
98
|
+
* of evaluating that expression against the current scope.
|
|
99
|
+
*/
|
|
100
|
+
language.commandEnabled = (instance, name, local = {}) => {
|
|
101
|
+
const command = instance.commands?.get(name);
|
|
102
|
+
if (!command) return false;
|
|
103
|
+
const expression = command.getAttribute('enabled');
|
|
104
|
+
return expression ? Boolean(language.evaluate(expression, instance, local)) : true;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Execute the command. Silently no-ops when the command is missing OR
|
|
109
|
+
* disabled (so a user double-click on a disabled button can't sneak
|
|
110
|
+
* through a stale handler).
|
|
111
|
+
*/
|
|
112
|
+
language.executeCommand = (instance, name, local = {}, event = null) => {
|
|
113
|
+
const command = instance.commands?.get(name);
|
|
114
|
+
if (!command || !language.commandEnabled(instance, name, local)) return;
|
|
115
|
+
language.runOperations([...command.children], instance, local, event);
|
|
116
|
+
};
|
|
117
|
+
}
|