@neuroplastio/xterm-addon-hotty 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 +73 -0
- package/README.md +358 -0
- package/dist/hotty-xterm.js +2906 -0
- package/dist/hotty-xterm.js.map +7 -0
- package/dist/types/addon.d.ts +170 -0
- package/dist/types/delta.d.ts +19 -0
- package/dist/types/hostcss.d.ts +48 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/network.d.ts +18 -0
- package/dist/types/resolver.d.ts +41 -0
- package/dist/types/resources.d.ts +32 -0
- package/dist/types/surface.d.ts +405 -0
- package/dist/types/touch.d.ts +37 -0
- package/dist/types/version.d.ts +10 -0
- package/dist/types/wire.d.ts +59 -0
- package/package.json +54 -0
|
@@ -0,0 +1,2906 @@
|
|
|
1
|
+
// src/hostcss.ts
|
|
2
|
+
var DEFAULT_FG = "#ffffff";
|
|
3
|
+
var DEFAULT_BG = "#000000";
|
|
4
|
+
var ANSI_KEYS = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite"];
|
|
5
|
+
var DEFAULT_ANSI = ["#2e3436", "#cc0000", "#4e9a06", "#c4a000", "#3465a4", "#75507b", "#06989a", "#d3d7cf", "#555753", "#ef2929", "#8ae234", "#fce94f", "#729fcf", "#ad7fa8", "#34e2e2", "#eeeeec"];
|
|
6
|
+
function palette(theme) {
|
|
7
|
+
const fg = theme?.foreground ?? DEFAULT_FG;
|
|
8
|
+
const bg = theme?.background ?? DEFAULT_BG;
|
|
9
|
+
const ansi = ANSI_KEYS.map((k, i) => theme?.[k] ?? DEFAULT_ANSI[i]);
|
|
10
|
+
return { fg, bg, ansi, dark: luminance(bg) < 0.5 };
|
|
11
|
+
}
|
|
12
|
+
function hostCss(theme, m) {
|
|
13
|
+
const p = palette(theme);
|
|
14
|
+
const lines = [
|
|
15
|
+
"@layer hotty-host {",
|
|
16
|
+
":root {",
|
|
17
|
+
` color-scheme: ${p.dark ? "dark" : "light"};`,
|
|
18
|
+
` --hotty-fg: ${p.fg};`,
|
|
19
|
+
` --hotty-bg: ${p.bg};`,
|
|
20
|
+
...p.ansi.map((c, i) => ` --hotty-ansi-${i}: ${c};`),
|
|
21
|
+
// The terminal's blue: plain blue is hard to read on a dark background.
|
|
22
|
+
` --hotty-accent: var(--hotty-ansi-${p.dark ? 12 : 4});`,
|
|
23
|
+
` --hotty-cell-w: ${m.cellW}px;`,
|
|
24
|
+
` --hotty-cell-h: ${m.cellH}px;`,
|
|
25
|
+
` --hotty-font: ${fontStack(m.fontFamily)};`,
|
|
26
|
+
" font-family: var(--hotty-font);",
|
|
27
|
+
` font-size: ${m.fontSize}px;`,
|
|
28
|
+
// An explicit line-height, so that `1rlh` is one terminal row.
|
|
29
|
+
` line-height: ${m.cellH}px;`,
|
|
30
|
+
" color: var(--hotty-fg);",
|
|
31
|
+
" background: var(--hotty-bg);",
|
|
32
|
+
" accent-color: var(--hotty-accent);",
|
|
33
|
+
"}",
|
|
34
|
+
"body { margin: 0; }",
|
|
35
|
+
// Controls in the terminal's colours (SPEC §8), not the browser's: ansi-8
|
|
36
|
+
// is the dim grey of borders, buttons, placeholders and what is disabled.
|
|
37
|
+
// :where() keeps the base rule below the state rules after it, such as
|
|
38
|
+
// :disabled.
|
|
39
|
+
":where(input:not([type=checkbox], [type=radio], [type=range]), textarea,",
|
|
40
|
+
" select, button) {",
|
|
41
|
+
" color: var(--hotty-fg);",
|
|
42
|
+
" background: var(--hotty-bg);",
|
|
43
|
+
" border: 1px solid var(--hotty-ansi-8);",
|
|
44
|
+
"}",
|
|
45
|
+
"button:enabled, input:is([type=button], [type=submit], [type=reset]):enabled {",
|
|
46
|
+
" background: var(--hotty-ansi-8);",
|
|
47
|
+
"}",
|
|
48
|
+
"::placeholder, :disabled { color: var(--hotty-ansi-8); }",
|
|
49
|
+
":focus-visible { outline: 1px solid var(--hotty-accent); }",
|
|
50
|
+
":any-link { color: var(--hotty-accent); }",
|
|
51
|
+
"::selection { color: var(--hotty-bg); background: var(--hotty-accent); }",
|
|
52
|
+
// An element that opts in to drags selects no text, whatever the
|
|
53
|
+
// document's CSS (SPEC §9.1, §11): important in the host's layer, the
|
|
54
|
+
// first, wins over every rule of the document's.
|
|
55
|
+
"[data-on~=drag], [data-on~=drag] * { -webkit-user-select: none !important; user-select: none !important; }",
|
|
56
|
+
// The rows a document needs are measured with no scrollbar of the root's
|
|
57
|
+
// in the way (`r=auto`, `fit`, SPEC §5.2).
|
|
58
|
+
`:root[${MEASURE}] { overflow: hidden !important; }`,
|
|
59
|
+
"}"
|
|
60
|
+
];
|
|
61
|
+
return lines.join("\n");
|
|
62
|
+
}
|
|
63
|
+
var MEASURE = "data-xterm-hotty-measure";
|
|
64
|
+
var CLIP = "data-xterm-hotty-clip";
|
|
65
|
+
var UNCLIPPED = "data-xterm-hotty-unclipped";
|
|
66
|
+
function scrollCss(axes, pageScrolls) {
|
|
67
|
+
if (!axes) {
|
|
68
|
+
return [
|
|
69
|
+
"@layer hotty-host {",
|
|
70
|
+
`:root { overflow: hidden; touch-action: ${pageScrolls ? "manipulation" : "none"}; }`,
|
|
71
|
+
"* { scrollbar-width: none !important; }",
|
|
72
|
+
"::-webkit-scrollbar { display: none !important; }",
|
|
73
|
+
"}"
|
|
74
|
+
].join("\n");
|
|
75
|
+
}
|
|
76
|
+
const pan = pageScrolls ? "manipulation" : axes === 3 ? "pan-x pan-y" : axes === 1 ? "pan-y" : "pan-x";
|
|
77
|
+
const lines = ["@layer hotty-host {", `:root { touch-action: ${pan}; }`];
|
|
78
|
+
if (axes !== 3) {
|
|
79
|
+
const clipped = axes === 1 ? "overflow-x" : "overflow-y";
|
|
80
|
+
lines.push(`:root, :root:not([${UNCLIPPED}]) [${CLIP}] { ${clipped}: hidden !important; }`);
|
|
81
|
+
}
|
|
82
|
+
lines.push("}");
|
|
83
|
+
return lines.join("\n");
|
|
84
|
+
}
|
|
85
|
+
function fontStack(family) {
|
|
86
|
+
const f = family.trim();
|
|
87
|
+
if (!f) return "monospace";
|
|
88
|
+
return /monospace\s*$/.test(f) ? f : `${f}, monospace`;
|
|
89
|
+
}
|
|
90
|
+
function luminance(color) {
|
|
91
|
+
let hex = color.trim().replace(/^#/, "");
|
|
92
|
+
if (hex.length === 3) hex = hex.replace(/./g, (c) => c + c);
|
|
93
|
+
if (!/^[0-9a-f]{6}/i.test(hex)) return 0;
|
|
94
|
+
const [r, g, b] = [0, 2, 4].map((i) => {
|
|
95
|
+
const c = parseInt(hex.slice(i, i + 2), 16) / 255;
|
|
96
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
97
|
+
});
|
|
98
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/delta.ts
|
|
102
|
+
var DeltaError = class extends Error {
|
|
103
|
+
code;
|
|
104
|
+
constructor(code, detail) {
|
|
105
|
+
super(detail);
|
|
106
|
+
this.code = code;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
var OPS = ["morph", "inner", "replace", "append", "prepend", "before", "after", "remove", "attr", "unattr", "text", "var"];
|
|
110
|
+
var Deltas = class {
|
|
111
|
+
doc;
|
|
112
|
+
resolver;
|
|
113
|
+
constructor(doc, resolver) {
|
|
114
|
+
this.doc = doc;
|
|
115
|
+
this.resolver = resolver;
|
|
116
|
+
}
|
|
117
|
+
/** Parses a fragment inert, cleaned and resolved. */
|
|
118
|
+
parse(html) {
|
|
119
|
+
const t = this.doc.createElement("template");
|
|
120
|
+
t.innerHTML = html;
|
|
121
|
+
this.resolver.adopt(t.content);
|
|
122
|
+
return Array.from(t.content.childNodes);
|
|
123
|
+
}
|
|
124
|
+
apply(op, target, key, payload) {
|
|
125
|
+
const find = () => {
|
|
126
|
+
if (!target) throw new DeltaError("EINVAL", `op=${op} needs t=<element id>`);
|
|
127
|
+
const el = this.doc.getElementById(target);
|
|
128
|
+
if (!el) throw new DeltaError("ENOTARGET", target);
|
|
129
|
+
return el;
|
|
130
|
+
};
|
|
131
|
+
const needKey = () => {
|
|
132
|
+
if (!key) throw new DeltaError("EINVAL", `op=${op} needs k=<name>`);
|
|
133
|
+
return key;
|
|
134
|
+
};
|
|
135
|
+
switch (op) {
|
|
136
|
+
case "":
|
|
137
|
+
case "morph": {
|
|
138
|
+
if (target === void 0) {
|
|
139
|
+
const missing = [];
|
|
140
|
+
for (const k of this.parse(payload)) {
|
|
141
|
+
if (k.nodeType !== 1) continue;
|
|
142
|
+
const id = k.getAttribute("id");
|
|
143
|
+
const old = id ? this.doc.getElementById(id) : null;
|
|
144
|
+
if (old) this.morphNode(old, k);
|
|
145
|
+
else missing.push(id ?? "(element without id)");
|
|
146
|
+
}
|
|
147
|
+
if (missing.length) throw new DeltaError("ENOTARGET", missing.join(","));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const t = find();
|
|
151
|
+
const frag = this.parse(payload);
|
|
152
|
+
const elements = frag.filter((n) => n.nodeType === 1);
|
|
153
|
+
if (elements.length === 1) {
|
|
154
|
+
this.morphNode(t, elements[0]);
|
|
155
|
+
} else {
|
|
156
|
+
t.before(...frag);
|
|
157
|
+
t.remove();
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
case "inner":
|
|
162
|
+
this.morphChildren(find(), this.parse(payload));
|
|
163
|
+
return;
|
|
164
|
+
case "replace": {
|
|
165
|
+
const t = find();
|
|
166
|
+
t.before(...this.parse(payload));
|
|
167
|
+
t.remove();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
case "append":
|
|
171
|
+
case "prepend": {
|
|
172
|
+
const t = find();
|
|
173
|
+
const fresh = [];
|
|
174
|
+
for (const k of this.parse(payload)) {
|
|
175
|
+
const id = k.nodeType === 1 ? k.getAttribute("id") : null;
|
|
176
|
+
const old = id ? this.doc.getElementById(id) : null;
|
|
177
|
+
if (old && old.parentNode === t) this.morphNode(old, k);
|
|
178
|
+
else fresh.push(k);
|
|
179
|
+
}
|
|
180
|
+
if (op === "append") t.append(...fresh);
|
|
181
|
+
else t.prepend(...fresh);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
case "before":
|
|
185
|
+
find().before(...this.parse(payload));
|
|
186
|
+
return;
|
|
187
|
+
case "after":
|
|
188
|
+
find().after(...this.parse(payload));
|
|
189
|
+
return;
|
|
190
|
+
case "remove":
|
|
191
|
+
find().remove();
|
|
192
|
+
return;
|
|
193
|
+
case "attr": {
|
|
194
|
+
const t = find();
|
|
195
|
+
const name = needKey();
|
|
196
|
+
this.resolver.set(t, name, payload);
|
|
197
|
+
syncProperty(t, name, payload, this.doc.activeElement === t);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
case "unattr": {
|
|
201
|
+
const t = find();
|
|
202
|
+
const name = needKey();
|
|
203
|
+
this.resolver.remove(t, name);
|
|
204
|
+
syncProperty(t, name, null, this.doc.activeElement === t);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
case "text": {
|
|
208
|
+
const t = find();
|
|
209
|
+
if (t.localName === "style") this.resolver.setCss(t, payload);
|
|
210
|
+
else if (t.childNodes.length === 1 && t.firstChild.nodeType === 3) t.firstChild.data = payload;
|
|
211
|
+
else t.textContent = payload;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
case "var": {
|
|
215
|
+
const t = find();
|
|
216
|
+
const k = needKey();
|
|
217
|
+
const name = k.startsWith("--") ? k : `--${k}`;
|
|
218
|
+
t.style?.setProperty(name, payload);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
default:
|
|
222
|
+
throw new DeltaError("EINVAL", `unknown op=${op}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** Makes `old` (live) look like `neu` (parsed), keeping `old` where possible. Returns the node now in place. */
|
|
226
|
+
morphNode(old, neu) {
|
|
227
|
+
if (old.nodeType === 3 && neu.nodeType === 3) {
|
|
228
|
+
if (old.data !== neu.data) old.data = neu.data;
|
|
229
|
+
return old;
|
|
230
|
+
}
|
|
231
|
+
if (old.nodeType === 8 && neu.nodeType === 8) return old;
|
|
232
|
+
if (old.nodeType === 1 && neu.nodeType === 1) {
|
|
233
|
+
const a = old;
|
|
234
|
+
const b = neu;
|
|
235
|
+
const ia = a.getAttribute("id");
|
|
236
|
+
const ib = b.getAttribute("id");
|
|
237
|
+
if (a.localName === b.localName && a.namespaceURI === b.namespaceURI && (ia === ib || ia === null || ib === null)) {
|
|
238
|
+
this.syncAttributes(a, b);
|
|
239
|
+
if (a.localName === "style") {
|
|
240
|
+
const css = this.resolver.cssOf(b);
|
|
241
|
+
if (this.resolver.cssOf(a) !== css) this.resolver.setCss(a, css);
|
|
242
|
+
} else {
|
|
243
|
+
const kids = a.localName === "template" ? b.content.childNodes : b.childNodes;
|
|
244
|
+
this.morphChildren(a.localName === "template" ? a.content : a, Array.from(kids));
|
|
245
|
+
}
|
|
246
|
+
return a;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
old.replaceWith(neu);
|
|
250
|
+
return neu;
|
|
251
|
+
}
|
|
252
|
+
syncAttributes(old, neu) {
|
|
253
|
+
const oldAttrs = this.resolver.attributes(old);
|
|
254
|
+
const newAttrs = this.resolver.attributes(neu);
|
|
255
|
+
const focused = this.doc.activeElement === old;
|
|
256
|
+
for (const [name, value] of newAttrs) {
|
|
257
|
+
if (focused && name === "value") continue;
|
|
258
|
+
const had = oldAttrs.find(([n]) => n === name);
|
|
259
|
+
if (!had || had[1] !== value) {
|
|
260
|
+
this.resolver.set(old, name, value);
|
|
261
|
+
syncProperty(old, name, value, focused);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
for (const [name] of oldAttrs) {
|
|
265
|
+
if (!newAttrs.some(([n]) => n === name)) {
|
|
266
|
+
this.resolver.remove(old, name);
|
|
267
|
+
syncProperty(old, name, null, focused);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** Morphs the children of `parent` (live) into `kids` (parsed). */
|
|
272
|
+
morphChildren(parent, kids) {
|
|
273
|
+
const oldKids = Array.from(parent.childNodes);
|
|
274
|
+
const idOf = (n) => n.nodeType === 1 ? n.getAttribute("id") : null;
|
|
275
|
+
const newIds = new Set(kids.map(idOf).filter((x) => x !== null));
|
|
276
|
+
const used = /* @__PURE__ */ new Set();
|
|
277
|
+
const plan = [];
|
|
278
|
+
let cursor = 0;
|
|
279
|
+
for (const nk of kids) {
|
|
280
|
+
const id = idOf(nk);
|
|
281
|
+
let match = null;
|
|
282
|
+
if (id !== null) {
|
|
283
|
+
match = oldKids.find((ok) => !used.has(ok) && idOf(ok) === id) ?? null;
|
|
284
|
+
} else {
|
|
285
|
+
for (let i = cursor; i < oldKids.length; i++) {
|
|
286
|
+
const ok = oldKids[i];
|
|
287
|
+
if (used.has(ok)) continue;
|
|
288
|
+
const okId = idOf(ok);
|
|
289
|
+
let same = false;
|
|
290
|
+
if (okId !== null && newIds.has(okId)) same = false;
|
|
291
|
+
else if (ok.nodeType === 1 && nk.nodeType === 1)
|
|
292
|
+
same = okId === null && ok.localName === nk.localName && ok.namespaceURI === nk.namespaceURI;
|
|
293
|
+
else same = ok.nodeType === nk.nodeType && (ok.nodeType === 3 || ok.nodeType === 8);
|
|
294
|
+
if (same) {
|
|
295
|
+
match = ok;
|
|
296
|
+
cursor = i + 1;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (match) used.add(match);
|
|
302
|
+
plan.push([match, nk]);
|
|
303
|
+
}
|
|
304
|
+
for (const ok of oldKids) if (!used.has(ok)) ok.remove();
|
|
305
|
+
plan.forEach(([keep, nk], i) => {
|
|
306
|
+
const want = keep ? this.morphNode(keep, nk) : nk;
|
|
307
|
+
const current = parent.childNodes[i] ?? null;
|
|
308
|
+
if (current !== want) move(parent, want, current);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
function move(parent, node, before) {
|
|
313
|
+
const p = parent;
|
|
314
|
+
if (node.parentNode === parent && typeof p.moveBefore === "function") {
|
|
315
|
+
try {
|
|
316
|
+
p.moveBefore(node, before);
|
|
317
|
+
return;
|
|
318
|
+
} catch {
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
parent.insertBefore(node, before);
|
|
322
|
+
}
|
|
323
|
+
function syncProperty(el, name, value, focused) {
|
|
324
|
+
if (focused) return;
|
|
325
|
+
const n = name.toLowerCase();
|
|
326
|
+
if (n === "value" && "value" in el && (el.localName === "input" || el.localName === "textarea" || el.localName === "select")) {
|
|
327
|
+
el.value = value ?? "";
|
|
328
|
+
} else if (n === "checked" && el.localName === "input") {
|
|
329
|
+
el.checked = value !== null;
|
|
330
|
+
} else if (n === "selected" && el.localName === "option") {
|
|
331
|
+
el.selected = value !== null;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// src/network.ts
|
|
336
|
+
var DIRECTIVES = ["img-src", "media-src", "font-src", "style-src"];
|
|
337
|
+
function source(s) {
|
|
338
|
+
const v = s.trim().toLowerCase();
|
|
339
|
+
if (v === "https:") return v;
|
|
340
|
+
let u;
|
|
341
|
+
try {
|
|
342
|
+
u = new URL(v);
|
|
343
|
+
} catch {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") return null;
|
|
347
|
+
if (u.pathname !== "/" && u.pathname !== "" || u.search || u.hash || u.username || u.password) return null;
|
|
348
|
+
if (!/^https?:\/\/[^/]+\/?$/.test(v)) return null;
|
|
349
|
+
return u.origin;
|
|
350
|
+
}
|
|
351
|
+
function clean(p) {
|
|
352
|
+
const out = {};
|
|
353
|
+
for (const d of DIRECTIVES) {
|
|
354
|
+
const srcs = (p?.[d] ?? []).map(source).filter((s) => s !== null);
|
|
355
|
+
if (srcs.length) out[d] = [...new Set(srcs)];
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
function parse(content) {
|
|
360
|
+
const p = {};
|
|
361
|
+
for (const part of content.split(";")) {
|
|
362
|
+
const [name, ...srcs] = part.trim().split(/\s+/);
|
|
363
|
+
const d = (name ?? "").toLowerCase();
|
|
364
|
+
if (DIRECTIVES.includes(d)) p[d] = [...p[d] ?? [], ...srcs];
|
|
365
|
+
}
|
|
366
|
+
return clean(p);
|
|
367
|
+
}
|
|
368
|
+
function matches(src, url) {
|
|
369
|
+
if (src === "https:") return url.protocol === "https:";
|
|
370
|
+
return url.origin === src;
|
|
371
|
+
}
|
|
372
|
+
function intersect(host, doc) {
|
|
373
|
+
const out = {};
|
|
374
|
+
for (const d of DIRECTIVES) {
|
|
375
|
+
const h = host[d] ?? [];
|
|
376
|
+
const res = /* @__PURE__ */ new Set();
|
|
377
|
+
for (const s of doc[d] ?? []) {
|
|
378
|
+
if (s === "https:") {
|
|
379
|
+
for (const hs of h) if (hs === "https:" || hs.startsWith("https://")) res.add(hs);
|
|
380
|
+
} else if (h.some((hs) => matches(hs, new URL(s)))) {
|
|
381
|
+
res.add(s);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (res.size) out[d] = [...res];
|
|
385
|
+
}
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
function allows(p, d, url) {
|
|
389
|
+
let u;
|
|
390
|
+
try {
|
|
391
|
+
u = new URL(url);
|
|
392
|
+
} catch {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
return (p[d] ?? []).some((s) => matches(s, u));
|
|
396
|
+
}
|
|
397
|
+
function cspSources(p, d) {
|
|
398
|
+
return (p[d] ?? []).join(" ");
|
|
399
|
+
}
|
|
400
|
+
function directiveFor(tag, attr, rel = "") {
|
|
401
|
+
if (attr === "srcset") return "img-src";
|
|
402
|
+
if (attr === "poster") return "img-src";
|
|
403
|
+
if (attr === "src") {
|
|
404
|
+
if (tag === "img" || tag === "input" || tag === "image") return "img-src";
|
|
405
|
+
if (tag === "video" || tag === "audio" || tag === "source" || tag === "track") return "media-src";
|
|
406
|
+
}
|
|
407
|
+
if ((attr === "href" || attr === "xlink:href") && (tag === "image" || tag === "use" || tag === "feimage")) return "img-src";
|
|
408
|
+
if (attr === "href" && tag === "link" && rel.split(/\s+/).includes("stylesheet")) return "style-src";
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// src/resources.ts
|
|
413
|
+
var INVALID = "about:invalid";
|
|
414
|
+
var NO_BASE = "https://hotty.invalid/";
|
|
415
|
+
function network(value, ctx, d) {
|
|
416
|
+
if (!ctx) return INVALID;
|
|
417
|
+
let abs;
|
|
418
|
+
try {
|
|
419
|
+
abs = new URL(value, ctx.base).href;
|
|
420
|
+
} catch {
|
|
421
|
+
return INVALID;
|
|
422
|
+
}
|
|
423
|
+
return !abs.startsWith(NO_BASE) && allows(ctx.policy, d, abs) ? abs : INVALID;
|
|
424
|
+
}
|
|
425
|
+
var URL_ATTRS = /* @__PURE__ */ new Set(["src", "href", "poster", "background", "data", "action", "formaction", "xlink:href", "srcset", "cite", "longdesc", "lowsrc", "manifest", "codebase", "usemap", "ping"]);
|
|
426
|
+
function isUrlAttr(name) {
|
|
427
|
+
return URL_ATTRS.has(name.toLowerCase());
|
|
428
|
+
}
|
|
429
|
+
var Store = class {
|
|
430
|
+
entries = /* @__PURE__ */ new Map();
|
|
431
|
+
used = 0;
|
|
432
|
+
quota;
|
|
433
|
+
/** Called with the names whose URL changed, so surfaces can re-resolve. */
|
|
434
|
+
onChange = () => {
|
|
435
|
+
};
|
|
436
|
+
constructor(quota = 64 << 20) {
|
|
437
|
+
this.quota = quota;
|
|
438
|
+
}
|
|
439
|
+
put(name, mime, bytes) {
|
|
440
|
+
const old = this.entries.get(name);
|
|
441
|
+
const next = this.used - (old?.bytes.length ?? 0) + bytes.length;
|
|
442
|
+
if (next > this.quota) throw new Error(`resource store over quota (${next} > ${this.quota} bytes)`);
|
|
443
|
+
this.used = next;
|
|
444
|
+
this.entries.set(name, { mime, bytes, url: null, deps: /* @__PURE__ */ new Set() });
|
|
445
|
+
this.changed(name, old);
|
|
446
|
+
}
|
|
447
|
+
remove(name) {
|
|
448
|
+
const old = this.entries.get(name);
|
|
449
|
+
if (!old) return false;
|
|
450
|
+
this.used -= old.bytes.length;
|
|
451
|
+
this.entries.delete(name);
|
|
452
|
+
this.changed(name, old);
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
/** The `blob:` URL for a resource, or `null` if there is none (yet). */
|
|
456
|
+
url(name) {
|
|
457
|
+
const e = this.entries.get(name);
|
|
458
|
+
if (!e) return null;
|
|
459
|
+
if (e.url === null) {
|
|
460
|
+
let bytes = e.bytes;
|
|
461
|
+
if (e.mime.startsWith("text/css")) {
|
|
462
|
+
e.deps.clear();
|
|
463
|
+
const css = new TextDecoder().decode(e.bytes);
|
|
464
|
+
bytes = new TextEncoder().encode(this.rewriteCss(css, e.deps));
|
|
465
|
+
}
|
|
466
|
+
e.url = URL.createObjectURL(new Blob([bytes], { type: e.mime }));
|
|
467
|
+
}
|
|
468
|
+
return e.url;
|
|
469
|
+
}
|
|
470
|
+
/** CSS text with every reference resolved; `names` collects the resources.
|
|
471
|
+
* Without a context (a stylesheet resource, shared by surfaces with
|
|
472
|
+
* different policies) nothing from the network is allowed. */
|
|
473
|
+
rewriteCss(css, names, ctx) {
|
|
474
|
+
const one = (raw, d) => {
|
|
475
|
+
const v = raw.trim();
|
|
476
|
+
if (/^cid:/i.test(v)) {
|
|
477
|
+
names.add(v.slice(4));
|
|
478
|
+
return this.url(v.slice(4)) ?? INVALID;
|
|
479
|
+
}
|
|
480
|
+
if (/^data:/i.test(v) || /^blob:/i.test(v) || v.startsWith("#")) return raw;
|
|
481
|
+
for (const dir of d) {
|
|
482
|
+
const r = network(v, ctx, dir);
|
|
483
|
+
if (r !== INVALID) return r;
|
|
484
|
+
}
|
|
485
|
+
return INVALID;
|
|
486
|
+
};
|
|
487
|
+
return css.replace(/@import\s+(?:url\(\s*)?(['"])([^'"]*)\1\s*\)?/gi, (_m, _q, u) => `@import "${one(u, ["style-src"])}"`).replace(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi, (_m, _q, u) => `url("${one(u, ["img-src", "font-src"])}")`);
|
|
488
|
+
}
|
|
489
|
+
/** Resolves one attribute value; `names` collects the resources it names.
|
|
490
|
+
* `d` is the directive that covers the attribute, if it fetches. */
|
|
491
|
+
resolveUrl(value, names, ctx, d) {
|
|
492
|
+
const v = value.trim();
|
|
493
|
+
if (/^cid:/i.test(v)) {
|
|
494
|
+
const name = v.slice(4);
|
|
495
|
+
names.add(name);
|
|
496
|
+
return this.url(name) ?? INVALID;
|
|
497
|
+
}
|
|
498
|
+
if (/^data:/i.test(v) || /^blob:/i.test(v) || v === "" || v.startsWith("#")) return value;
|
|
499
|
+
if (d) return network(v, ctx, d);
|
|
500
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(v) ? INVALID : value;
|
|
501
|
+
}
|
|
502
|
+
changed(name, old) {
|
|
503
|
+
const names = /* @__PURE__ */ new Set([name]);
|
|
504
|
+
for (const [other, e] of this.entries) {
|
|
505
|
+
if (e.deps.has(name)) {
|
|
506
|
+
if (e.url) URL.revokeObjectURL(e.url);
|
|
507
|
+
e.url = null;
|
|
508
|
+
names.add(other);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
this.onChange(names);
|
|
512
|
+
if (old?.url) URL.revokeObjectURL(old.url);
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
// src/touch.ts
|
|
517
|
+
var DRAG_SLOP = 8;
|
|
518
|
+
var FLING_DECAY = 0.996;
|
|
519
|
+
var REST_MS = 80;
|
|
520
|
+
var Touch = class {
|
|
521
|
+
/**
|
|
522
|
+
* `exclusive`: every touch here is this Touch's (the cells: xterm.js's own
|
|
523
|
+
* touch handling, which listens on the document, never sees it). Otherwise
|
|
524
|
+
* (a surface) a touch is left alone until it moves. `engage`, if given,
|
|
525
|
+
* says on a touch's start whether it is this Touch's at all: one it
|
|
526
|
+
* declines is the browser's, from start to end.
|
|
527
|
+
*/
|
|
528
|
+
constructor(target, win, host, exclusive = false, engage) {
|
|
529
|
+
this.win = win;
|
|
530
|
+
this.host = host;
|
|
531
|
+
this.exclusive = exclusive;
|
|
532
|
+
this.engage = engage;
|
|
533
|
+
const on = (type, f, passive) => {
|
|
534
|
+
const l = (e) => f(e);
|
|
535
|
+
target.addEventListener(type, l, { capture: true, passive });
|
|
536
|
+
this.off.push(() => target.removeEventListener(type, l, { capture: true }));
|
|
537
|
+
};
|
|
538
|
+
on("touchstart", (e) => this.start(e), true);
|
|
539
|
+
on("touchmove", (e) => this.move(e), false);
|
|
540
|
+
on("touchend", (e) => this.end(e), !exclusive);
|
|
541
|
+
on("touchcancel", (e) => {
|
|
542
|
+
this.keep(e);
|
|
543
|
+
this.touch = null;
|
|
544
|
+
}, true);
|
|
545
|
+
}
|
|
546
|
+
win;
|
|
547
|
+
host;
|
|
548
|
+
exclusive;
|
|
549
|
+
engage;
|
|
550
|
+
touch = null;
|
|
551
|
+
fling = 0;
|
|
552
|
+
off = [];
|
|
553
|
+
dispose() {
|
|
554
|
+
this.stopFling();
|
|
555
|
+
for (const f of this.off) f();
|
|
556
|
+
}
|
|
557
|
+
keep(e) {
|
|
558
|
+
if (this.exclusive) e.stopPropagation();
|
|
559
|
+
}
|
|
560
|
+
start(e) {
|
|
561
|
+
this.keep(e);
|
|
562
|
+
this.stopFling();
|
|
563
|
+
const t = e.touches.length === 1 ? e.touches[0] : void 0;
|
|
564
|
+
if (!t || this.engage && !this.engage(e)) {
|
|
565
|
+
this.touch = null;
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const [x, y] = this.host.toPage(t.clientX, t.clientY);
|
|
569
|
+
this.touch = { id: t.identifier, x0: x, y0: y, x, y, t: e.timeStamp, t0: e.timeStamp, vx: 0, vy: 0, dragging: false };
|
|
570
|
+
}
|
|
571
|
+
move(e) {
|
|
572
|
+
this.keep(e);
|
|
573
|
+
const s = this.touch;
|
|
574
|
+
if (!s) return;
|
|
575
|
+
if (e.touches.length !== 1) {
|
|
576
|
+
this.touch = null;
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
const t = [...e.changedTouches].find((c) => c.identifier === s.id);
|
|
580
|
+
if (!t) return;
|
|
581
|
+
const [x, y] = this.host.toPage(t.clientX, t.clientY);
|
|
582
|
+
if (!s.dragging && Math.hypot(x - s.x0, y - s.y0) < DRAG_SLOP) return;
|
|
583
|
+
if (!s.dragging && this.host.claim && !this.host.claim(s.x0 - x, s.y0 - y, e)) {
|
|
584
|
+
this.touch = null;
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
s.dragging = true;
|
|
588
|
+
e.preventDefault();
|
|
589
|
+
const dx = x - s.x;
|
|
590
|
+
const dy = y - s.y;
|
|
591
|
+
const dt = Math.max(1, e.timeStamp - s.t);
|
|
592
|
+
s.vx = 0.7 * (dx / dt) + 0.3 * s.vx;
|
|
593
|
+
s.vy = 0.7 * (dy / dt) + 0.3 * s.vy;
|
|
594
|
+
s.x = x;
|
|
595
|
+
s.y = y;
|
|
596
|
+
s.t = e.timeStamp;
|
|
597
|
+
this.host.scroll(-dx, -dy, x, y);
|
|
598
|
+
}
|
|
599
|
+
end(e) {
|
|
600
|
+
this.keep(e);
|
|
601
|
+
const s = this.touch;
|
|
602
|
+
if (!s || ![...e.changedTouches].some((c) => c.identifier === s.id)) return;
|
|
603
|
+
this.touch = null;
|
|
604
|
+
if (s.dragging) {
|
|
605
|
+
if (e.timeStamp - s.t < REST_MS) this.startFling(-s.vx, -s.vy, s.x, s.y);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (this.host.tap && e.timeStamp - s.t0 < 500) {
|
|
609
|
+
e.preventDefault();
|
|
610
|
+
this.host.tap(s.x, s.y);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
startFling(vx, vy, x, y) {
|
|
614
|
+
let last = this.win.performance.now();
|
|
615
|
+
const step = (now) => {
|
|
616
|
+
const dt = now - last;
|
|
617
|
+
last = now;
|
|
618
|
+
const decay = Math.pow(FLING_DECAY, dt);
|
|
619
|
+
vx *= decay;
|
|
620
|
+
vy *= decay;
|
|
621
|
+
if (Math.abs(vx) < 0.02 && Math.abs(vy) < 0.02) {
|
|
622
|
+
this.fling = 0;
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
this.host.scroll(vx * dt, vy * dt, x, y);
|
|
626
|
+
this.fling = this.win.requestAnimationFrame(step);
|
|
627
|
+
};
|
|
628
|
+
this.fling = this.win.requestAnimationFrame(step);
|
|
629
|
+
}
|
|
630
|
+
stopFling() {
|
|
631
|
+
if (this.fling) this.win.cancelAnimationFrame(this.fling);
|
|
632
|
+
this.fling = 0;
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
// src/resolver.ts
|
|
637
|
+
var DROP = /* @__PURE__ */ new Set(["script", "iframe", "frame", "frameset", "object", "embed", "applet", "base", "meta", "portal", "fencedframe"]);
|
|
638
|
+
var DROP_ATTRS = /* @__PURE__ */ new Set(["srcdoc", "autofocus", "http-equiv", "ping", "nonce", "integrity"]);
|
|
639
|
+
var Resolver = class {
|
|
640
|
+
/** The program's value of each attribute the resolver rewrote. */
|
|
641
|
+
orig = /* @__PURE__ */ new WeakMap();
|
|
642
|
+
/** The program's text of each `<style>` whose CSS names a resource. */
|
|
643
|
+
css = /* @__PURE__ */ new WeakMap();
|
|
644
|
+
/** Elements that name resources, and which. */
|
|
645
|
+
refs = /* @__PURE__ */ new Map();
|
|
646
|
+
/** Attributes the host set and the program did not write (a detached
|
|
647
|
+
* surface's `disabled`, SPEC §5.5): live, never reported. */
|
|
648
|
+
own = /* @__PURE__ */ new WeakMap();
|
|
649
|
+
store;
|
|
650
|
+
/** The document's base URL and effective network policy (set per a=doc). */
|
|
651
|
+
ctx;
|
|
652
|
+
constructor(store) {
|
|
653
|
+
this.store = store;
|
|
654
|
+
}
|
|
655
|
+
/** Cleans and resolves an inert subtree, in place. */
|
|
656
|
+
adopt(root) {
|
|
657
|
+
const walk = (node) => {
|
|
658
|
+
if (node.nodeType === 1) {
|
|
659
|
+
const el = node;
|
|
660
|
+
if (!this.allowed(el)) {
|
|
661
|
+
el.remove();
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
this.cleanAttributes(el);
|
|
665
|
+
if (el.localName === "style") this.setCss(el, el.textContent ?? "");
|
|
666
|
+
}
|
|
667
|
+
for (const child of Array.from(node.childNodes)) walk(child);
|
|
668
|
+
if (node.nodeType === 1 && node.localName === "template") {
|
|
669
|
+
walk(node.content);
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
walk(root);
|
|
673
|
+
}
|
|
674
|
+
allowed(el) {
|
|
675
|
+
const name = el.localName;
|
|
676
|
+
if (DROP.has(name)) return false;
|
|
677
|
+
if (name === "link") {
|
|
678
|
+
const rel = (el.getAttribute("rel") ?? "").toLowerCase().split(/\s+/);
|
|
679
|
+
return rel.includes("stylesheet");
|
|
680
|
+
}
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
cleanAttributes(el) {
|
|
684
|
+
for (const a of Array.from(el.attributes)) {
|
|
685
|
+
const n = a.name.toLowerCase();
|
|
686
|
+
if (n.startsWith("on") || DROP_ATTRS.has(n)) {
|
|
687
|
+
el.removeAttribute(a.name);
|
|
688
|
+
} else if (isUrlAttr(n) || n === "style") {
|
|
689
|
+
this.set(el, a.name, a.value);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/** An attribute as the program wrote it. */
|
|
694
|
+
get(el, name) {
|
|
695
|
+
if (this.own.get(el)?.has(name)) return null;
|
|
696
|
+
return this.orig.get(el)?.get(name) ?? el.getAttribute(name);
|
|
697
|
+
}
|
|
698
|
+
/** Every attribute as the program wrote it, in order. */
|
|
699
|
+
attributes(el) {
|
|
700
|
+
const o = this.orig.get(el);
|
|
701
|
+
const own = this.own.get(el);
|
|
702
|
+
const out = [];
|
|
703
|
+
for (const a of Array.from(el.attributes)) if (!own?.has(a.name)) out.push([a.name, o?.get(a.name) ?? a.value]);
|
|
704
|
+
return out;
|
|
705
|
+
}
|
|
706
|
+
/** Sets an attribute of the host's own, which the program does not see. */
|
|
707
|
+
setOwn(el, name, value) {
|
|
708
|
+
el.setAttribute(name, value);
|
|
709
|
+
let own = this.own.get(el);
|
|
710
|
+
if (!own) this.own.set(el, own = /* @__PURE__ */ new Set());
|
|
711
|
+
own.add(name);
|
|
712
|
+
}
|
|
713
|
+
/** Removes an attribute, if it is the host's own. */
|
|
714
|
+
removeOwn(el, name) {
|
|
715
|
+
if (!this.own.get(el)?.delete(name)) return;
|
|
716
|
+
el.removeAttribute(name);
|
|
717
|
+
}
|
|
718
|
+
/** Sets an attribute to the program's value, resolving URLs and CSS. */
|
|
719
|
+
set(el, name, value) {
|
|
720
|
+
const n = name.toLowerCase();
|
|
721
|
+
if (n.startsWith("on") || DROP_ATTRS.has(n)) return;
|
|
722
|
+
const names = /* @__PURE__ */ new Set();
|
|
723
|
+
let live = value;
|
|
724
|
+
if (n === "style") live = this.store.rewriteCss(value, names, this.ctx);
|
|
725
|
+
else if (isUrlAttr(n)) {
|
|
726
|
+
const d = directiveFor(el.localName, n, el.getAttribute("rel") ?? "");
|
|
727
|
+
live = n === "srcset" ? this.srcset(value, names) : this.store.resolveUrl(value, names, this.ctx, d);
|
|
728
|
+
}
|
|
729
|
+
if ((el.localName === "a" || el.localName === "area") && n === "href") live = value;
|
|
730
|
+
try {
|
|
731
|
+
el.setAttribute(name, live);
|
|
732
|
+
} catch {
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
this.own.get(el)?.delete(n);
|
|
736
|
+
this.remember(el, name, value, live, names);
|
|
737
|
+
}
|
|
738
|
+
remove(el, name) {
|
|
739
|
+
el.removeAttribute(name);
|
|
740
|
+
this.orig.get(el)?.delete(name);
|
|
741
|
+
this.own.get(el)?.delete(name.toLowerCase());
|
|
742
|
+
}
|
|
743
|
+
/** A `<style>`'s CSS as the program wrote it. */
|
|
744
|
+
cssOf(el) {
|
|
745
|
+
return this.css.get(el) ?? el.textContent ?? "";
|
|
746
|
+
}
|
|
747
|
+
setCss(el, text2) {
|
|
748
|
+
const names = /* @__PURE__ */ new Set();
|
|
749
|
+
const live = this.store.rewriteCss(text2, names, this.ctx);
|
|
750
|
+
if (el.textContent !== live) el.textContent = live;
|
|
751
|
+
if (live !== text2) this.css.set(el, text2);
|
|
752
|
+
else this.css.delete(el);
|
|
753
|
+
if (names.size > 0) this.track(el, names);
|
|
754
|
+
}
|
|
755
|
+
/** Re-resolves everything that names one of `names` (a resource changed);
|
|
756
|
+
* whether anything did. */
|
|
757
|
+
refresh(names) {
|
|
758
|
+
let any = false;
|
|
759
|
+
for (const [el, uses] of Array.from(this.refs)) {
|
|
760
|
+
if (!el.isConnected) {
|
|
761
|
+
this.refs.delete(el);
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
if (![...uses].some((u) => names.has(u))) continue;
|
|
765
|
+
any = true;
|
|
766
|
+
this.refs.delete(el);
|
|
767
|
+
const o = this.orig.get(el);
|
|
768
|
+
if (o) for (const [attr, value] of Array.from(o)) this.set(el, attr, value);
|
|
769
|
+
const css = this.css.get(el);
|
|
770
|
+
if (css !== void 0) this.setCss(el, css);
|
|
771
|
+
}
|
|
772
|
+
return any;
|
|
773
|
+
}
|
|
774
|
+
srcset(value, names) {
|
|
775
|
+
return value.split(",").map((part) => {
|
|
776
|
+
const [url, ...rest] = part.trim().split(/\s+/);
|
|
777
|
+
return [this.store.resolveUrl(url ?? "", names, this.ctx, "img-src"), ...rest].join(" ");
|
|
778
|
+
}).join(", ");
|
|
779
|
+
}
|
|
780
|
+
remember(el, name, value, live, names) {
|
|
781
|
+
let o = this.orig.get(el);
|
|
782
|
+
if (value !== live) {
|
|
783
|
+
if (!o) this.orig.set(el, o = /* @__PURE__ */ new Map());
|
|
784
|
+
o.set(name, value);
|
|
785
|
+
} else {
|
|
786
|
+
o?.delete(name);
|
|
787
|
+
}
|
|
788
|
+
if (names.size > 0) this.track(el, names);
|
|
789
|
+
}
|
|
790
|
+
track(el, names) {
|
|
791
|
+
const uses = this.refs.get(el) ?? /* @__PURE__ */ new Set();
|
|
792
|
+
for (const n of names) uses.add(n);
|
|
793
|
+
this.refs.set(el, uses);
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
// src/surface.ts
|
|
798
|
+
function csp(host) {
|
|
799
|
+
const with_ = (fixed, d) => [fixed, cspSources(host, d)].filter(Boolean).join(" ");
|
|
800
|
+
return [
|
|
801
|
+
"default-src 'none'",
|
|
802
|
+
with_("img-src data: blob:", "img-src"),
|
|
803
|
+
with_("media-src data: blob:", "media-src"),
|
|
804
|
+
with_("font-src data: blob:", "font-src"),
|
|
805
|
+
with_("style-src 'unsafe-inline' blob:", "style-src"),
|
|
806
|
+
"form-action 'none'",
|
|
807
|
+
"base-uri http: https:"
|
|
808
|
+
].join("; ");
|
|
809
|
+
}
|
|
810
|
+
function baseOf(doc) {
|
|
811
|
+
const href = doc.querySelector("base[href]")?.getAttribute("href") ?? "";
|
|
812
|
+
try {
|
|
813
|
+
const u = new URL(href);
|
|
814
|
+
if (u.protocol === "http:" || u.protocol === "https:") return u.href;
|
|
815
|
+
} catch {
|
|
816
|
+
}
|
|
817
|
+
return NO_BASE;
|
|
818
|
+
}
|
|
819
|
+
var LINE_PX = 40;
|
|
820
|
+
var PAGE_FRACTION = 0.875;
|
|
821
|
+
var TEXT_TYPES = /* @__PURE__ */ new Set(["text", "email", "password", "search", "tel", "url", "number", "date", "datetime-local", "month", "time", "week"]);
|
|
822
|
+
var CONTROLS = "input, select, textarea, button";
|
|
823
|
+
var HYPERLINK = "data-xterm-hotty-hyperlink";
|
|
824
|
+
var DETACHED_CSS = `@layer hotty-host {
|
|
825
|
+
* { cursor: auto !important; }
|
|
826
|
+
a[href], a[href] * { cursor: text !important; }
|
|
827
|
+
a[href][${HYPERLINK}], a[href][${HYPERLINK}] * { cursor: pointer !important; }
|
|
828
|
+
}`;
|
|
829
|
+
var Surface = class {
|
|
830
|
+
name;
|
|
831
|
+
box;
|
|
832
|
+
frame;
|
|
833
|
+
doc;
|
|
834
|
+
resolver;
|
|
835
|
+
deltas;
|
|
836
|
+
cols = 80;
|
|
837
|
+
rows = 24;
|
|
838
|
+
/** The part of the surface on screen, in cells (SPEC §5.2). */
|
|
839
|
+
win = { x: 0, y: 0, w: 80, h: 24 };
|
|
840
|
+
autoRows = false;
|
|
841
|
+
host;
|
|
842
|
+
hostStyle;
|
|
843
|
+
/** Set while the program moves focus, so no `focus` event echoes back. */
|
|
844
|
+
programFocus = false;
|
|
845
|
+
/** The document's base URL (SPEC §7.3). */
|
|
846
|
+
base = NO_BASE;
|
|
847
|
+
keyboard = false;
|
|
848
|
+
/** Detached (SPEC §5.5): nothing in the surface reaches the program. */
|
|
849
|
+
detachedState = false;
|
|
850
|
+
/** A press is on something that takes no focus (SPEC §10.1): the focus
|
|
851
|
+
* the browser gives the frame for it is not the surface's. */
|
|
852
|
+
pressNothing = false;
|
|
853
|
+
/** The element a press focuses, until the browser is done with it. */
|
|
854
|
+
pressed = null;
|
|
855
|
+
/** A press of another button than the primary one, until the browser is
|
|
856
|
+
* done with it: it neither takes nor gives back the keyboard. */
|
|
857
|
+
auxPress = false;
|
|
858
|
+
settling = null;
|
|
859
|
+
/** A drag under way (SPEC §9.1). */
|
|
860
|
+
drag = null;
|
|
861
|
+
/** A drag just ended at a release: it reported its own click, so the
|
|
862
|
+
* browser's, which follows in the same task, is not reported. */
|
|
863
|
+
dragReleased = false;
|
|
864
|
+
/** The placement asked for presses (`p=1`, SPEC §5.2). */
|
|
865
|
+
presses = false;
|
|
866
|
+
/** A mouse's or a pen's pointerdown came first: the mousedown that
|
|
867
|
+
* follows is the same press, not a tap's. */
|
|
868
|
+
pointerPress = false;
|
|
869
|
+
/** The pointer of a press with Alt held (SPEC §9.2), until its release:
|
|
870
|
+
* the gesture is the program's. */
|
|
871
|
+
altPointer = null;
|
|
872
|
+
/** Such a gesture just ended at a release: the browser's click that
|
|
873
|
+
* follows in the same task is not the surface's. */
|
|
874
|
+
altReleased = false;
|
|
875
|
+
/** One cell, in CSS pixels: a drag's detail counts cells (SPEC §9.1). */
|
|
876
|
+
cellW = 9;
|
|
877
|
+
cellH = 17;
|
|
878
|
+
css = "";
|
|
879
|
+
/** Keys whose keydown went to the program, so their keyup follows. */
|
|
880
|
+
forwarded = /* @__PURE__ */ new Set();
|
|
881
|
+
/** The rows the program heard last, while the placement asked for `fit`
|
|
882
|
+
* (`f=1`, SPEC §5.2); null when it did not. */
|
|
883
|
+
fitRows = null;
|
|
884
|
+
/** The height may have changed since the last check. */
|
|
885
|
+
fitDirty = false;
|
|
886
|
+
/** The frame a check waits for. */
|
|
887
|
+
fitFrame = 0;
|
|
888
|
+
fitObserver = null;
|
|
889
|
+
/** The axes the document asked to scroll along (`scroll`, SPEC §5.1): 1
|
|
890
|
+
* vertically, 2 horizontally, 3 both; 0, nothing scrolls. */
|
|
891
|
+
axes = 0;
|
|
892
|
+
/** Where the touch drag under way goes, once it has a direction. */
|
|
893
|
+
touchRoute = "terminal";
|
|
894
|
+
/** The box the wheel gesture under way scrolls, if it went to the
|
|
895
|
+
* document: the gesture moves no other (`scrollWheel`). */
|
|
896
|
+
wheelBox = null;
|
|
897
|
+
/** Some element may carry `CLIP`. */
|
|
898
|
+
clipping = false;
|
|
899
|
+
constructor(name, host, hostCss2, scheme) {
|
|
900
|
+
this.name = name;
|
|
901
|
+
this.host = host;
|
|
902
|
+
this.box = document.createElement("div");
|
|
903
|
+
this.box.className = "hotty-surface";
|
|
904
|
+
this.box.dataset.surface = name;
|
|
905
|
+
Object.assign(this.box.style, {
|
|
906
|
+
position: "absolute",
|
|
907
|
+
left: "0px",
|
|
908
|
+
top: "0px",
|
|
909
|
+
overflow: "hidden",
|
|
910
|
+
pointerEvents: "auto",
|
|
911
|
+
visibility: "hidden"
|
|
912
|
+
});
|
|
913
|
+
this.frame = document.createElement("iframe");
|
|
914
|
+
this.frame.setAttribute("sandbox", "allow-same-origin allow-forms");
|
|
915
|
+
this.frame.setAttribute("title", `HOTTY surface ${name}`);
|
|
916
|
+
Object.assign(this.frame.style, {
|
|
917
|
+
border: "0",
|
|
918
|
+
position: "absolute",
|
|
919
|
+
left: "0px",
|
|
920
|
+
top: "0px",
|
|
921
|
+
width: "100%",
|
|
922
|
+
height: "100%",
|
|
923
|
+
display: "block",
|
|
924
|
+
colorScheme: scheme
|
|
925
|
+
});
|
|
926
|
+
this.box.append(this.frame);
|
|
927
|
+
host.layer.append(this.box);
|
|
928
|
+
const doc = this.frame.contentDocument;
|
|
929
|
+
if (!doc) throw new Error("surface iframe has no document");
|
|
930
|
+
doc.open();
|
|
931
|
+
doc.write(
|
|
932
|
+
`<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${csp(host.policy)}"><base href="${NO_BASE}"><style id="hotty-host"></style></head><body></body></html>`
|
|
933
|
+
);
|
|
934
|
+
doc.close();
|
|
935
|
+
this.doc = doc;
|
|
936
|
+
this.hostStyle = doc.getElementById("hotty-host");
|
|
937
|
+
this.css = hostCss2;
|
|
938
|
+
this.restyle();
|
|
939
|
+
this.resolver = new Resolver(host.store);
|
|
940
|
+
this.deltas = new Deltas(doc, this.resolver);
|
|
941
|
+
this.listen();
|
|
942
|
+
}
|
|
943
|
+
setHostCss(css, scheme) {
|
|
944
|
+
this.css = css;
|
|
945
|
+
this.restyle();
|
|
946
|
+
if (this.frame.style.colorScheme !== scheme) this.frame.style.colorScheme = scheme;
|
|
947
|
+
this.refit();
|
|
948
|
+
}
|
|
949
|
+
restyle() {
|
|
950
|
+
const css = [this.css, scrollCss(this.axes, this.host.pageScrolls), ...this.detachedState ? [DETACHED_CSS] : []].join("\n");
|
|
951
|
+
if (this.hostStyle.textContent !== css) this.hostStyle.textContent = css;
|
|
952
|
+
this.clip();
|
|
953
|
+
}
|
|
954
|
+
/** Replaces the whole document (`a=doc`): its head's styles and its body.
|
|
955
|
+
* `detached` (`d=1`) detaches the surface first; without it, the surface
|
|
956
|
+
* is the program's again (SPEC §5.1, §5.5). `axes` (`scroll`) are the
|
|
957
|
+
* axes it scrolls along, 0 for none (§5.3). */
|
|
958
|
+
setDocument(html, detached = false, axes = 0) {
|
|
959
|
+
if (detached) this.detach();
|
|
960
|
+
const scrolled = this.axes !== 0;
|
|
961
|
+
this.axes = axes;
|
|
962
|
+
const parsed = new DOMParser().parseFromString(html, "text/html");
|
|
963
|
+
this.base = baseOf(parsed);
|
|
964
|
+
const request = parse(parsed.querySelector('meta[name="hotty-network" i]')?.getAttribute("content") ?? "");
|
|
965
|
+
this.resolver.ctx = { base: this.base, policy: intersect(this.host.policy, request) };
|
|
966
|
+
const ours = this.doc.head.querySelector("base");
|
|
967
|
+
if (ours && ours.getAttribute("href") !== this.base) ours.setAttribute("href", this.base);
|
|
968
|
+
this.resolver.adopt(parsed.documentElement);
|
|
969
|
+
const head = this.doc.head;
|
|
970
|
+
for (const n of Array.from(head.childNodes)) {
|
|
971
|
+
const el = n;
|
|
972
|
+
const ours2 = el.nodeType === 1 && (el.id === "hotty-host" || el.localName === "meta" || el.localName === "base");
|
|
973
|
+
if (!ours2) n.remove();
|
|
974
|
+
}
|
|
975
|
+
head.append(...Array.from(parsed.head.childNodes));
|
|
976
|
+
this.copyAttributes(parsed.documentElement, this.doc.documentElement);
|
|
977
|
+
this.copyAttributes(parsed.body, this.doc.body);
|
|
978
|
+
this.doc.body.replaceChildren(...Array.from(parsed.body.childNodes));
|
|
979
|
+
this.detachedState = detached;
|
|
980
|
+
this.sync();
|
|
981
|
+
if (scrolled) {
|
|
982
|
+
this.doc.documentElement.scrollTo(0, 0);
|
|
983
|
+
this.doc.body.scrollTo(0, 0);
|
|
984
|
+
}
|
|
985
|
+
this.refit();
|
|
986
|
+
}
|
|
987
|
+
/** A delta (SPEC §6). On a detached surface, the controls it adds are
|
|
988
|
+
* disabled too, and so is one whose `disabled` it removes. */
|
|
989
|
+
delta(op, target, key, payload) {
|
|
990
|
+
try {
|
|
991
|
+
this.deltas.apply(op, target, key, payload);
|
|
992
|
+
} finally {
|
|
993
|
+
if (this.detachedState) this.sync();
|
|
994
|
+
else this.clip();
|
|
995
|
+
this.refit();
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
// --- Ownership (SPEC §5.5) -----------------------------------------------
|
|
999
|
+
get detached() {
|
|
1000
|
+
return this.detachedState;
|
|
1001
|
+
}
|
|
1002
|
+
/** `a=detach`: nothing in the surface reaches the program any more. A
|
|
1003
|
+
* surface that has the keyboard gives it back silently, with no `change`
|
|
1004
|
+
* and no `blur`, and its form controls act disabled. */
|
|
1005
|
+
detach() {
|
|
1006
|
+
if (!this.detachedState) {
|
|
1007
|
+
this.detachedState = true;
|
|
1008
|
+
this.dropDrag();
|
|
1009
|
+
const had = this.keyboard || this.frameFocused();
|
|
1010
|
+
this.keyboard = false;
|
|
1011
|
+
this.focusedControl()?.blur();
|
|
1012
|
+
if (had) this.giveBack();
|
|
1013
|
+
}
|
|
1014
|
+
this.sync();
|
|
1015
|
+
}
|
|
1016
|
+
/** Disables the form controls of a detached surface, as if each had the
|
|
1017
|
+
* `disabled` attribute, or enables those it disabled, and marks its
|
|
1018
|
+
* hyperlinks for the host's stylesheet. The attributes are the host's
|
|
1019
|
+
* own: the document reports the program's (SPEC §16). */
|
|
1020
|
+
sync() {
|
|
1021
|
+
const detached = this.detachedState;
|
|
1022
|
+
for (const el of Array.from(this.doc.querySelectorAll(CONTROLS))) {
|
|
1023
|
+
if (!detached) this.resolver.removeOwn(el, "disabled");
|
|
1024
|
+
else if (!el.hasAttribute("disabled")) this.resolver.setOwn(el, "disabled", "");
|
|
1025
|
+
}
|
|
1026
|
+
for (const a of Array.from(this.doc.querySelectorAll("a"))) {
|
|
1027
|
+
if (detached && this.isHyperlink(a)) {
|
|
1028
|
+
if (!a.hasAttribute(HYPERLINK)) this.resolver.setOwn(a, HYPERLINK, "");
|
|
1029
|
+
} else this.resolver.removeOwn(a, HYPERLINK);
|
|
1030
|
+
}
|
|
1031
|
+
this.restyle();
|
|
1032
|
+
}
|
|
1033
|
+
copyAttributes(from, to) {
|
|
1034
|
+
for (const [name] of this.resolver.attributes(to)) {
|
|
1035
|
+
if (!from.hasAttribute(name)) this.resolver.remove(to, name);
|
|
1036
|
+
}
|
|
1037
|
+
for (const [name, value] of this.resolver.attributes(from)) this.resolver.set(to, name, value);
|
|
1038
|
+
}
|
|
1039
|
+
/** Rows the content needs at `cols` columns (`r=auto`). */
|
|
1040
|
+
contentRows(cols, cellW, cellH) {
|
|
1041
|
+
Object.assign(this.box.style, { display: "block", visibility: "hidden" });
|
|
1042
|
+
return this.neededRows(cols, cellW, cellH);
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Rows the document needs at `cols` columns: `r=auto`'s, and `fit`'s
|
|
1046
|
+
* (SPEC §5.2). The frame is laid out at the width a placement gives it
|
|
1047
|
+
* and a height of 1px, so the viewport adds nothing, then restored in the
|
|
1048
|
+
* same task: nothing is drawn in between.
|
|
1049
|
+
*/
|
|
1050
|
+
neededRows(cols, cellW, cellH) {
|
|
1051
|
+
const f = this.frame.style;
|
|
1052
|
+
const [width, height] = [f.width, f.height];
|
|
1053
|
+
const root = this.doc.documentElement;
|
|
1054
|
+
if (this.axes) this.resolver.setOwn(root, MEASURE, "");
|
|
1055
|
+
Object.assign(f, { width: `${Math.round(cols * cellW)}px`, height: "1px" });
|
|
1056
|
+
const h = root.scrollHeight;
|
|
1057
|
+
Object.assign(f, { width, height });
|
|
1058
|
+
if (this.axes) this.resolver.removeOwn(root, MEASURE);
|
|
1059
|
+
return Math.max(1, Math.min(1e3, Math.ceil(h / cellH)));
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* In a document that scrolls along one axis only, the elements whose
|
|
1063
|
+
* `overflow` along the other is `auto` or `scroll` are marked (`CLIP`), so
|
|
1064
|
+
* that the host stylesheet clips that axis as `overflow: hidden` does
|
|
1065
|
+
* (SPEC §5.3): no scrollbar, and nothing the user does scrolls it. CSS
|
|
1066
|
+
* cannot select by a computed value, so the addon reads it, with its own
|
|
1067
|
+
* rule off (`UNCLIPPED`), each time the document or its style may have
|
|
1068
|
+
* changed: a document, a delta, the host stylesheet. Style only, no
|
|
1069
|
+
* layout, and only for a document with `scroll` of 1 or 2.
|
|
1070
|
+
*/
|
|
1071
|
+
clip() {
|
|
1072
|
+
const prop = this.axes === 1 ? "overflowX" : this.axes === 2 ? "overflowY" : null;
|
|
1073
|
+
if (!prop && !this.clipping) return;
|
|
1074
|
+
const marked = new Set(this.doc.querySelectorAll(`[${CLIP}]`));
|
|
1075
|
+
const want = /* @__PURE__ */ new Set();
|
|
1076
|
+
if (prop) {
|
|
1077
|
+
const root = this.doc.documentElement;
|
|
1078
|
+
const win = this.frame.contentWindow;
|
|
1079
|
+
this.resolver.setOwn(root, UNCLIPPED, "");
|
|
1080
|
+
for (const el of [this.doc.body, ...Array.from(this.doc.body.querySelectorAll("*"))]) {
|
|
1081
|
+
if (/auto|scroll/.test(win.getComputedStyle(el)[prop])) want.add(el);
|
|
1082
|
+
}
|
|
1083
|
+
this.resolver.removeOwn(root, UNCLIPPED);
|
|
1084
|
+
}
|
|
1085
|
+
for (const el of marked) if (!want.has(el)) this.resolver.removeOwn(el, CLIP);
|
|
1086
|
+
for (const el of want) if (!marked.has(el)) this.resolver.setOwn(el, CLIP, "");
|
|
1087
|
+
this.clipping = want.size > 0;
|
|
1088
|
+
}
|
|
1089
|
+
// --- Fit (SPEC §5.2: f=1 on a=place) --------------------------------------
|
|
1090
|
+
/**
|
|
1091
|
+
* `f=1` on the placement: from `rows`, the placement's own, the program
|
|
1092
|
+
* hears `fit` whenever the rows the document needs differ from those it
|
|
1093
|
+
* heard last. `null`: the placement did not ask (or is gone).
|
|
1094
|
+
*
|
|
1095
|
+
* What can change the height asks for a check in the next frame drawn:
|
|
1096
|
+
* a document, a delta, a resource (`refresh`), the host stylesheet (cell
|
|
1097
|
+
* size, font), an image, stylesheet or font loading, and, for whatever
|
|
1098
|
+
* else does (the user opening a `<details>`, say), the root's and the
|
|
1099
|
+
* body's boxes changing size. One check per frame, so one `fit` at most.
|
|
1100
|
+
*/
|
|
1101
|
+
setFit(rows) {
|
|
1102
|
+
this.fitRows = rows;
|
|
1103
|
+
if (rows === null) {
|
|
1104
|
+
this.fitObserver?.disconnect();
|
|
1105
|
+
this.fitObserver = null;
|
|
1106
|
+
if (this.fitFrame) this.frame.ownerDocument.defaultView?.cancelAnimationFrame(this.fitFrame);
|
|
1107
|
+
this.fitFrame = 0;
|
|
1108
|
+
this.fitDirty = false;
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
if (!this.fitObserver) {
|
|
1112
|
+
this.fitObserver = new ResizeObserver(() => this.refit());
|
|
1113
|
+
this.fitObserver.observe(this.doc.documentElement);
|
|
1114
|
+
this.fitObserver.observe(this.doc.body);
|
|
1115
|
+
}
|
|
1116
|
+
this.refit();
|
|
1117
|
+
}
|
|
1118
|
+
/** The document's height may have changed: a check in the next frame
|
|
1119
|
+
* drawn. A surface out of view draws none; it checks once shown. */
|
|
1120
|
+
refit() {
|
|
1121
|
+
if (this.fitRows === null) return;
|
|
1122
|
+
this.fitDirty = true;
|
|
1123
|
+
if (this.fitFrame || this.box.style.display === "none") return;
|
|
1124
|
+
this.fitFrame = this.frame.ownerDocument.defaultView.requestAnimationFrame(() => {
|
|
1125
|
+
this.fitFrame = 0;
|
|
1126
|
+
this.checkFit();
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
checkFit() {
|
|
1130
|
+
if (this.fitRows === null || !this.fitDirty || this.box.style.display === "none") return;
|
|
1131
|
+
this.fitDirty = false;
|
|
1132
|
+
if (this.detachedState) return;
|
|
1133
|
+
const rows = this.neededRows(this.cols, this.cellW, this.cellH);
|
|
1134
|
+
if (rows === this.fitRows) return;
|
|
1135
|
+
this.fitRows = rows;
|
|
1136
|
+
this.emit("fit", "", { r: rows });
|
|
1137
|
+
}
|
|
1138
|
+
/** A resource changed (`a=res`, `a=del`): what names it resolves again. */
|
|
1139
|
+
refresh(names) {
|
|
1140
|
+
if (this.resolver.refresh(names)) this.refit();
|
|
1141
|
+
}
|
|
1142
|
+
/** The surface is `cols`×`rows` cells, and the box shows `win` of it:
|
|
1143
|
+
* the document keeps the whole size, offset by the window's corner. */
|
|
1144
|
+
setSize(cols, rows, cellW, cellH, win = { x: 0, y: 0, w: cols, h: rows }) {
|
|
1145
|
+
this.cols = cols;
|
|
1146
|
+
this.rows = rows;
|
|
1147
|
+
this.win = win;
|
|
1148
|
+
this.cellW = cellW;
|
|
1149
|
+
this.cellH = cellH;
|
|
1150
|
+
Object.assign(this.frame.style, {
|
|
1151
|
+
left: `${-Math.round(win.x * cellW)}px`,
|
|
1152
|
+
top: `${-win.y * cellH}px`,
|
|
1153
|
+
width: `${Math.round(cols * cellW)}px`,
|
|
1154
|
+
height: `${rows * cellH}px`
|
|
1155
|
+
});
|
|
1156
|
+
this.box.style.width = `${Math.round(win.w * cellW)}px`;
|
|
1157
|
+
this.box.style.height = `${win.h * cellH}px`;
|
|
1158
|
+
}
|
|
1159
|
+
/** The placement's z (SPEC §5.2): CSS z-index on the box, so overlapping
|
|
1160
|
+
* boxes stack by it, and by their order in the layer at the same z. */
|
|
1161
|
+
setZ(z) {
|
|
1162
|
+
this.box.style.zIndex = String(z);
|
|
1163
|
+
}
|
|
1164
|
+
/** Shows the surface with its top-left corner at (x, y) in the screen's pixels. */
|
|
1165
|
+
show(x, y) {
|
|
1166
|
+
this.box.style.left = `${x}px`;
|
|
1167
|
+
this.box.style.top = `${y}px`;
|
|
1168
|
+
this.box.style.visibility = "visible";
|
|
1169
|
+
this.box.style.display = "block";
|
|
1170
|
+
if (this.fitDirty) this.refit();
|
|
1171
|
+
}
|
|
1172
|
+
/** Out of view (its line left the screen): a drag under way ends
|
|
1173
|
+
* (SPEC §9.1). */
|
|
1174
|
+
hide() {
|
|
1175
|
+
this.cancelDrag();
|
|
1176
|
+
this.dropAlt();
|
|
1177
|
+
this.box.style.display = "none";
|
|
1178
|
+
}
|
|
1179
|
+
/** No placement (hidden, or not placed yet): the document stays, and the
|
|
1180
|
+
* browser skips its style, layout and paint until it is placed again. */
|
|
1181
|
+
park() {
|
|
1182
|
+
this.box.style.display = "none";
|
|
1183
|
+
}
|
|
1184
|
+
destroy() {
|
|
1185
|
+
this.setFit(null);
|
|
1186
|
+
this.dropDrag();
|
|
1187
|
+
this.dropAlt();
|
|
1188
|
+
if (this.settling) clearTimeout(this.settling);
|
|
1189
|
+
if (this.keyboard || this.frameFocused()) this.host.focusTerminal();
|
|
1190
|
+
this.box.remove();
|
|
1191
|
+
}
|
|
1192
|
+
// --- Keyboard (PROTOCOL §8, Q-0005) -------------------------------------
|
|
1193
|
+
hasKeyboard() {
|
|
1194
|
+
return this.keyboard;
|
|
1195
|
+
}
|
|
1196
|
+
/** `a=focus`: the surface takes the keyboard, at `target` if given. */
|
|
1197
|
+
focus(target) {
|
|
1198
|
+
const el = target ? this.doc.getElementById(target) : null;
|
|
1199
|
+
if (target && !el) throw new Error(target);
|
|
1200
|
+
this.programFocus = true;
|
|
1201
|
+
try {
|
|
1202
|
+
this.frame.focus();
|
|
1203
|
+
this.frame.contentWindow?.focus();
|
|
1204
|
+
if (el) el.focus();
|
|
1205
|
+
else if (!this.focusedControl()) this.tabbable()[0]?.focus();
|
|
1206
|
+
this.keyboard = true;
|
|
1207
|
+
} finally {
|
|
1208
|
+
setTimeout(() => this.programFocus = false, 0);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
/** `a=blur`, or the keyboard leaving: the edited control commits first. */
|
|
1212
|
+
blur() {
|
|
1213
|
+
this.commit();
|
|
1214
|
+
if (this.keyboard) this.giveBack();
|
|
1215
|
+
}
|
|
1216
|
+
/** The browser's focus goes from the frame to the terminal. The frame lets
|
|
1217
|
+
* go of it first: Firefox can leave the frame focused otherwise. */
|
|
1218
|
+
giveBack() {
|
|
1219
|
+
this.frame.blur();
|
|
1220
|
+
this.host.focusTerminal();
|
|
1221
|
+
}
|
|
1222
|
+
commit() {
|
|
1223
|
+
const active = this.focusedControl();
|
|
1224
|
+
if (active) active.blur();
|
|
1225
|
+
}
|
|
1226
|
+
focusedControl() {
|
|
1227
|
+
const a = this.doc.activeElement;
|
|
1228
|
+
return a && a !== this.doc.body && a !== this.doc.documentElement ? a : null;
|
|
1229
|
+
}
|
|
1230
|
+
/** Whether the browser's focus is in this surface's frame. */
|
|
1231
|
+
frameFocused() {
|
|
1232
|
+
return this.frame.ownerDocument.activeElement === this.frame;
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* A press (SPEC §10.1). On an element that takes focus, the focus that
|
|
1236
|
+
* follows gives the surface the keyboard (`onFocusIn`). On anything else,
|
|
1237
|
+
* or anywhere in a detached surface, it takes nothing: a surface that had
|
|
1238
|
+
* the keyboard gives it back (its control commits, then `blur`), and the
|
|
1239
|
+
* focus the browser gives the frame anyway goes back to the terminal.
|
|
1240
|
+
*/
|
|
1241
|
+
onPress(e) {
|
|
1242
|
+
if (e.type === "pointerdown" && e.pointerType === "touch") return;
|
|
1243
|
+
if (e.button !== 0) {
|
|
1244
|
+
this.auxPress = true;
|
|
1245
|
+
setTimeout(() => this.auxPress = false, 0);
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
const el = this.detachedState ? null : focusTargetAt(e, this.hyper);
|
|
1249
|
+
if (el) {
|
|
1250
|
+
this.pressed = el;
|
|
1251
|
+
this.settle();
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
this.pressNothing = true;
|
|
1255
|
+
if (this.keyboard) {
|
|
1256
|
+
this.commit();
|
|
1257
|
+
this.keyboard = false;
|
|
1258
|
+
this.emit("blur", "");
|
|
1259
|
+
}
|
|
1260
|
+
this.settle();
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Once the browser is done with a press (its default action focuses the
|
|
1264
|
+
* frame): an element that takes focus has it, even where the browser does
|
|
1265
|
+
* not focus one on a click (a button on a Mac, say); and focus the
|
|
1266
|
+
* surface does not hold goes back to the terminal. The frame keeps it
|
|
1267
|
+
* until then, so text selection starts as usual.
|
|
1268
|
+
*/
|
|
1269
|
+
settle() {
|
|
1270
|
+
if (this.settling) return;
|
|
1271
|
+
this.settling = setTimeout(() => {
|
|
1272
|
+
this.settling = null;
|
|
1273
|
+
const el = this.pressed;
|
|
1274
|
+
this.pressed = null;
|
|
1275
|
+
this.pressNothing = false;
|
|
1276
|
+
if (el && !this.keyboard && !this.detachedState && el.isConnected && this.doc.activeElement !== el) {
|
|
1277
|
+
this.frame.focus();
|
|
1278
|
+
el.focus({ preventScroll: true });
|
|
1279
|
+
}
|
|
1280
|
+
if (!this.keyboard && this.frameFocused()) this.giveBack();
|
|
1281
|
+
}, 0);
|
|
1282
|
+
}
|
|
1283
|
+
/** An element got focus: the surface takes the keyboard if the element is
|
|
1284
|
+
* one that takes focus, and the focus is neither the program's (no echo)
|
|
1285
|
+
* nor a detached surface's. */
|
|
1286
|
+
onFocusIn(e) {
|
|
1287
|
+
if (this.programFocus || this.auxPress) return;
|
|
1288
|
+
if (this.detachedState || this.pressNothing || !isElement(e.target) || !takesFocus(e.target, this.hyper)) {
|
|
1289
|
+
this.settle();
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
if (this.keyboard) return;
|
|
1293
|
+
this.keyboard = true;
|
|
1294
|
+
this.emit("focus", "");
|
|
1295
|
+
}
|
|
1296
|
+
tabbable() {
|
|
1297
|
+
const all = this.doc.querySelectorAll(
|
|
1298
|
+
"a[href], button, input:not([type=hidden]), select, textarea, summary, [tabindex], [contenteditable=''], [contenteditable=true]"
|
|
1299
|
+
);
|
|
1300
|
+
return Array.from(all).filter((el) => el.tabIndex >= 0 && !el.disabled && el.getClientRects().length > 0);
|
|
1301
|
+
}
|
|
1302
|
+
controlKind(el) {
|
|
1303
|
+
if (!el) return "none";
|
|
1304
|
+
const tag = el.localName;
|
|
1305
|
+
if (tag === "textarea" || el.isContentEditable) return "textarea";
|
|
1306
|
+
if (tag === "select") return "select";
|
|
1307
|
+
if (tag === "input") {
|
|
1308
|
+
const type = (el.type || "text").toLowerCase();
|
|
1309
|
+
if (TEXT_TYPES.has(type)) return "text";
|
|
1310
|
+
return "activatable";
|
|
1311
|
+
}
|
|
1312
|
+
if (tag === "button" || tag === "a" || tag === "summary") return "activatable";
|
|
1313
|
+
return "none";
|
|
1314
|
+
}
|
|
1315
|
+
/** Whether the focused element uses the key; if not, the program gets it. */
|
|
1316
|
+
consumes(e, kind) {
|
|
1317
|
+
const plain = !e.ctrlKey && !e.altKey && !e.metaKey;
|
|
1318
|
+
const k = e.key;
|
|
1319
|
+
const printable = [...k].length === 1;
|
|
1320
|
+
if (k === "Escape") return false;
|
|
1321
|
+
switch (kind) {
|
|
1322
|
+
case "text":
|
|
1323
|
+
return (plain || e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) && (printable || ["Backspace", "Delete", "ArrowLeft", "ArrowRight", "Home", "End", "Enter"].includes(k));
|
|
1324
|
+
case "textarea":
|
|
1325
|
+
return (plain || e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) && (printable || ["Backspace", "Delete", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown", "Enter"].includes(k));
|
|
1326
|
+
case "select":
|
|
1327
|
+
return plain && (printable || ["ArrowUp", "ArrowDown", "Home", "End", "PageUp", "PageDown", "Enter", " "].includes(k));
|
|
1328
|
+
case "activatable":
|
|
1329
|
+
return plain && (k === " " || k === "Enter");
|
|
1330
|
+
default:
|
|
1331
|
+
return false;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
onKey(e) {
|
|
1335
|
+
if (e.isComposing || e.defaultPrevented) return;
|
|
1336
|
+
if (!this.keyboard) {
|
|
1337
|
+
this.settle();
|
|
1338
|
+
if (this.host.browserKey(e)) return;
|
|
1339
|
+
this.forward(e);
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
if (e.key === "Tab" && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
1343
|
+
const order = this.tabbable();
|
|
1344
|
+
const at = order.indexOf(this.doc.activeElement);
|
|
1345
|
+
const leaving = order.length === 0 || (e.shiftKey ? at <= 0 : at === order.length - 1 || at < 0);
|
|
1346
|
+
if (leaving) {
|
|
1347
|
+
e.preventDefault();
|
|
1348
|
+
this.blur();
|
|
1349
|
+
}
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
if (this.consumes(e, this.controlKind(this.doc.activeElement))) return;
|
|
1353
|
+
if (this.host.browserKey(e)) return;
|
|
1354
|
+
if (this.axes && this.scrollKey(e)) return;
|
|
1355
|
+
this.forward(e);
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* A key a browser scrolls with, in a document that scrolls (SPEC §5.3):
|
|
1359
|
+
* it scrolls the innermost box, from the focused element outward (from
|
|
1360
|
+
* the root when none is), that can still move that way. Where none can,
|
|
1361
|
+
* the key goes on to the program, as every key the surface does not use
|
|
1362
|
+
* does (§10.2), unless `overscroll-behavior` stops it. The addon scrolls
|
|
1363
|
+
* the box itself: the browser's own action could be the focused
|
|
1364
|
+
* element's instead (a radio button's arrows, a number field's).
|
|
1365
|
+
*/
|
|
1366
|
+
scrollKey(e) {
|
|
1367
|
+
const k = keyScroll(e);
|
|
1368
|
+
if (!k) return false;
|
|
1369
|
+
const to = this.scroller(this.focusedControl(), k.axis, k.sign);
|
|
1370
|
+
if (to === "terminal") return false;
|
|
1371
|
+
e.preventDefault();
|
|
1372
|
+
if (to === "stop") return true;
|
|
1373
|
+
const y = k.axis === "y";
|
|
1374
|
+
const by = k.by === "line" ? LINE_PX : k.by === "page" ? Math.max(1, (y ? to.clientHeight : to.clientWidth) * PAGE_FRACTION) : y ? to.scrollHeight : to.scrollWidth;
|
|
1375
|
+
to.scrollBy(y ? { top: k.sign * by } : { left: k.sign * by });
|
|
1376
|
+
return true;
|
|
1377
|
+
}
|
|
1378
|
+
forward(e) {
|
|
1379
|
+
e.preventDefault();
|
|
1380
|
+
e.stopPropagation();
|
|
1381
|
+
this.forwarded.add(e.code);
|
|
1382
|
+
this.host.key(e);
|
|
1383
|
+
}
|
|
1384
|
+
/** The release of a key the program had: the program hears it too, if it
|
|
1385
|
+
* asked the terminal for releases (SPEC §10.3). */
|
|
1386
|
+
onKeyUp(e) {
|
|
1387
|
+
if (!this.forwarded.delete(e.code)) return;
|
|
1388
|
+
e.preventDefault();
|
|
1389
|
+
e.stopPropagation();
|
|
1390
|
+
this.host.key(e);
|
|
1391
|
+
}
|
|
1392
|
+
// --- Drags (SPEC §9.1) ----------------------------------------------------
|
|
1393
|
+
/**
|
|
1394
|
+
* A press of a mouse's or a pen's primary button on an element with
|
|
1395
|
+
* `drag` in its `data-on` (the nearest, from the pressed one outward, and
|
|
1396
|
+
* only if it has an id) starts a drag. The surface then holds the pointer
|
|
1397
|
+
* until the release: captured by its root element, which no delta
|
|
1398
|
+
* replaces, so every move comes here wherever it is (over the cells,
|
|
1399
|
+
* another surface, or outside the page), and nothing else hears it.
|
|
1400
|
+
*/
|
|
1401
|
+
onDragDown(e) {
|
|
1402
|
+
this.cancelDrag();
|
|
1403
|
+
if (this.detachedState || e.pointerType === "touch" || e.button !== 0 || !e.isPrimary) return;
|
|
1404
|
+
let start = null;
|
|
1405
|
+
for (const n of e.composedPath()) {
|
|
1406
|
+
if (isElement(n) && listens(n, "drag")) {
|
|
1407
|
+
start = n;
|
|
1408
|
+
break;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
const id = start?.getAttribute("id");
|
|
1412
|
+
if (!id) return;
|
|
1413
|
+
const [c, r] = this.cellOf(e);
|
|
1414
|
+
const keys = keysOf(e);
|
|
1415
|
+
this.drag = { pointer: e.pointerId, start: id, target: id, c, r, keys };
|
|
1416
|
+
this.emit("dragstart", id, { c, r, keys });
|
|
1417
|
+
try {
|
|
1418
|
+
this.doc.documentElement.setPointerCapture(e.pointerId);
|
|
1419
|
+
} catch {
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
/** A move during a drag: an event each time its target changes, and
|
|
1423
|
+
* while it has none, each time its cell does. */
|
|
1424
|
+
onDragMove(e) {
|
|
1425
|
+
const d = this.drag;
|
|
1426
|
+
if (!d || e.pointerId !== d.pointer) return;
|
|
1427
|
+
const [c, r] = this.cellOf(e);
|
|
1428
|
+
const target = this.dragTarget(this.elementAt(e, c, r));
|
|
1429
|
+
const keys = keysOf(e);
|
|
1430
|
+
if (target !== d.target || target === "" && (c !== d.c || r !== d.r)) {
|
|
1431
|
+
this.emit("drag", target, { c, r, keys });
|
|
1432
|
+
}
|
|
1433
|
+
Object.assign(d, { target, c, r, keys });
|
|
1434
|
+
}
|
|
1435
|
+
/** The release ends the drag, wherever it is. Where it began, it is the
|
|
1436
|
+
* click it would have been without the drag; anywhere else, no click. */
|
|
1437
|
+
onDragUp(e) {
|
|
1438
|
+
const d = this.drag;
|
|
1439
|
+
if (!d || e.pointerId !== d.pointer || e.button !== 0) return;
|
|
1440
|
+
this.drag = null;
|
|
1441
|
+
const [c, r] = this.cellOf(e);
|
|
1442
|
+
const at = this.elementAt(e, c, r);
|
|
1443
|
+
const target = this.dragTarget(at);
|
|
1444
|
+
this.emit("dragend", target, { c, r, keys: keysOf(e) });
|
|
1445
|
+
this.dragReleased = true;
|
|
1446
|
+
setTimeout(() => this.dragReleased = false, 0);
|
|
1447
|
+
if (target === d.start && at) this.reportClick(ancestry(at));
|
|
1448
|
+
}
|
|
1449
|
+
/** The browser took the pointer away before the release. */
|
|
1450
|
+
lostPointer(e) {
|
|
1451
|
+
if (this.drag && e.pointerId === this.drag.pointer) this.cancelDrag();
|
|
1452
|
+
if (e.pointerId === this.altPointer) this.altPointer = null;
|
|
1453
|
+
}
|
|
1454
|
+
/**
|
|
1455
|
+
* Ends a drag under way without a release (SPEC §9.1: its placement went
|
|
1456
|
+
* away, a new document came, or the pointer was lost): `dragend` with no
|
|
1457
|
+
* target, at the last cell the program heard of.
|
|
1458
|
+
*/
|
|
1459
|
+
cancelDrag() {
|
|
1460
|
+
const d = this.drag;
|
|
1461
|
+
if (!d) return;
|
|
1462
|
+
this.dropDrag();
|
|
1463
|
+
this.emit("dragend", "", { c: d.c, r: d.r, keys: d.keys });
|
|
1464
|
+
}
|
|
1465
|
+
/** Ends a drag under way, silently (a detached or deleted surface). */
|
|
1466
|
+
dropDrag() {
|
|
1467
|
+
const d = this.drag;
|
|
1468
|
+
if (!d) return;
|
|
1469
|
+
this.drag = null;
|
|
1470
|
+
try {
|
|
1471
|
+
if (this.doc.documentElement.hasPointerCapture(d.pointer)) this.doc.documentElement.releasePointerCapture(d.pointer);
|
|
1472
|
+
} catch {
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
/** The surface's cell under the pointer, from its top left (the frame's
|
|
1476
|
+
* origin), counting on past its edges. */
|
|
1477
|
+
cellOf(e) {
|
|
1478
|
+
return [Math.floor(e.clientX / this.cellW), Math.floor(e.clientY / this.cellH)];
|
|
1479
|
+
}
|
|
1480
|
+
/** The element under the pointer, if it is in the window (SPEC §5.2). */
|
|
1481
|
+
elementAt(e, c, r) {
|
|
1482
|
+
const w = this.win;
|
|
1483
|
+
if (c < w.x || c >= w.x + w.w || r < w.y || r >= w.y + w.h) return null;
|
|
1484
|
+
return this.doc.elementFromPoint(e.clientX, e.clientY);
|
|
1485
|
+
}
|
|
1486
|
+
/** A drag's target: the nearest element with an id and `drag` in its
|
|
1487
|
+
* `data-on`, from `el` outward; "" where there is none. */
|
|
1488
|
+
dragTarget(el) {
|
|
1489
|
+
for (let n = el; n; n = n.parentElement) {
|
|
1490
|
+
const id = n.getAttribute("id");
|
|
1491
|
+
if (id && listens(n, "drag")) return id;
|
|
1492
|
+
}
|
|
1493
|
+
return "";
|
|
1494
|
+
}
|
|
1495
|
+
// --- Presses with Alt (SPEC §9.2) ---------------------------------------
|
|
1496
|
+
/**
|
|
1497
|
+
* A press of a mouse's or a pen's primary button with Alt held is the
|
|
1498
|
+
* program's: the addon replays it on the cells beneath, and the surface
|
|
1499
|
+
* hears nothing of it (no press, drag, click, focus or selection; its
|
|
1500
|
+
* mousedown is cancelled). It holds the pointer until the release, as a
|
|
1501
|
+
* drag does, so every move and the release go to the cells, wherever the
|
|
1502
|
+
* pointer is. Whether it is the program's is decided here, at the press:
|
|
1503
|
+
* Alt let go later changes nothing.
|
|
1504
|
+
*/
|
|
1505
|
+
onAltDown(e) {
|
|
1506
|
+
if (e.pointerType === "touch" || e.button !== 0 || !e.altKey || !e.isPrimary) return false;
|
|
1507
|
+
this.cancelDrag();
|
|
1508
|
+
this.dropAlt();
|
|
1509
|
+
this.pointerPress = true;
|
|
1510
|
+
this.altPointer = e.pointerId;
|
|
1511
|
+
this.onLeave(e);
|
|
1512
|
+
try {
|
|
1513
|
+
this.doc.documentElement.setPointerCapture(e.pointerId);
|
|
1514
|
+
} catch {
|
|
1515
|
+
}
|
|
1516
|
+
const [x, y] = this.toPage(e);
|
|
1517
|
+
this.host.cells("down", e, x, y);
|
|
1518
|
+
return true;
|
|
1519
|
+
}
|
|
1520
|
+
onAltMove(e) {
|
|
1521
|
+
if (e.pointerId !== this.altPointer) return false;
|
|
1522
|
+
const [x, y] = this.toPage(e);
|
|
1523
|
+
this.host.cells("move", e, x, y);
|
|
1524
|
+
return true;
|
|
1525
|
+
}
|
|
1526
|
+
onAltUp(e) {
|
|
1527
|
+
if (e.pointerId !== this.altPointer || e.button !== 0) return false;
|
|
1528
|
+
this.dropAlt();
|
|
1529
|
+
this.altReleased = true;
|
|
1530
|
+
setTimeout(() => this.altReleased = false, 0);
|
|
1531
|
+
const [x, y] = this.toPage(e);
|
|
1532
|
+
this.host.cells("up", e, x, y);
|
|
1533
|
+
return true;
|
|
1534
|
+
}
|
|
1535
|
+
/** Ends such a gesture without a release: the frame goes away. */
|
|
1536
|
+
dropAlt() {
|
|
1537
|
+
const p = this.altPointer;
|
|
1538
|
+
if (p === null) return;
|
|
1539
|
+
this.altPointer = null;
|
|
1540
|
+
try {
|
|
1541
|
+
if (this.doc.documentElement.hasPointerCapture(p)) this.doc.documentElement.releasePointerCapture(p);
|
|
1542
|
+
} catch {
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
/** A point of the frame's document in the page. */
|
|
1546
|
+
toPage(e) {
|
|
1547
|
+
const r = this.frame.getBoundingClientRect();
|
|
1548
|
+
return [r.left + e.clientX, r.top + e.clientY];
|
|
1549
|
+
}
|
|
1550
|
+
/** `press` (SPEC §9), if the placement asked for it: `t` is the nearest
|
|
1551
|
+
* element with an id. A press on a hyperlink is the terminal's. */
|
|
1552
|
+
reportPress(e) {
|
|
1553
|
+
if (!this.presses) return;
|
|
1554
|
+
const link = this.linkIn(e);
|
|
1555
|
+
if (link && this.isHyperlink(link)) return;
|
|
1556
|
+
for (const n of e.composedPath()) {
|
|
1557
|
+
const id = isElement(n) ? n.getAttribute("id") : null;
|
|
1558
|
+
if (id) {
|
|
1559
|
+
this.emit("press", id, { area: this.areaOf(n) });
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
this.emit("press", "");
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* The cells an element's border box covers as the user sees it, scrolled
|
|
1567
|
+
* included (SPEC §9: `area`), counted from the surface's top left cell:
|
|
1568
|
+
* whole, where it is clipped or scrolled away. An edge within half a
|
|
1569
|
+
* device pixel of a cell's is on it: layout rounds positions to fractions
|
|
1570
|
+
* of a pixel, and the user sees no less than a device pixel.
|
|
1571
|
+
*/
|
|
1572
|
+
areaOf(el) {
|
|
1573
|
+
const b = el.getBoundingClientRect();
|
|
1574
|
+
const tol = 0.5 / (this.frame.ownerDocument.defaultView?.devicePixelRatio || 1);
|
|
1575
|
+
const c = Math.floor((b.left + tol) / this.cellW);
|
|
1576
|
+
const r = Math.floor((b.top + tol) / this.cellH);
|
|
1577
|
+
const right = Math.ceil((b.right - tol) / this.cellW);
|
|
1578
|
+
const bottom = Math.ceil((b.bottom - tol) / this.cellH);
|
|
1579
|
+
return { c, r, w: Math.max(0, right - c), h: Math.max(0, bottom - r) };
|
|
1580
|
+
}
|
|
1581
|
+
// --- Events for the program (PROTOCOL §8) --------------------------------
|
|
1582
|
+
listen() {
|
|
1583
|
+
const d = this.doc;
|
|
1584
|
+
const win = this.frame.contentWindow;
|
|
1585
|
+
d.addEventListener(
|
|
1586
|
+
"pointerdown",
|
|
1587
|
+
(e) => {
|
|
1588
|
+
if (this.onAltDown(e)) return;
|
|
1589
|
+
this.pointerPress = e.pointerType !== "touch";
|
|
1590
|
+
if (this.pointerPress && e.button === 0 && e.isPrimary) this.reportPress(e);
|
|
1591
|
+
this.onDragDown(e);
|
|
1592
|
+
this.onPress(e);
|
|
1593
|
+
},
|
|
1594
|
+
true
|
|
1595
|
+
);
|
|
1596
|
+
d.addEventListener("pointermove", (e) => this.onAltMove(e) || this.onDragMove(e), true);
|
|
1597
|
+
d.addEventListener("pointerup", (e) => this.onAltUp(e) || this.onDragUp(e), true);
|
|
1598
|
+
d.addEventListener("pointercancel", (e) => this.lostPointer(e), true);
|
|
1599
|
+
d.addEventListener("lostpointercapture", (e) => this.lostPointer(e), true);
|
|
1600
|
+
d.addEventListener(
|
|
1601
|
+
"mousedown",
|
|
1602
|
+
(e) => {
|
|
1603
|
+
if (this.altPointer !== null) {
|
|
1604
|
+
e.preventDefault();
|
|
1605
|
+
e.stopImmediatePropagation();
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
if (!this.pointerPress && e.button === 0) this.reportPress(e);
|
|
1609
|
+
this.pointerPress = false;
|
|
1610
|
+
this.onPress(e);
|
|
1611
|
+
},
|
|
1612
|
+
true
|
|
1613
|
+
);
|
|
1614
|
+
d.addEventListener("focusin", (e) => this.onFocusIn(e), true);
|
|
1615
|
+
d.addEventListener("click", (e) => this.onClick(e), true);
|
|
1616
|
+
d.addEventListener("auxclick", (e) => this.onAuxClick(e), true);
|
|
1617
|
+
d.addEventListener("mouseover", (e) => this.onOver(e), true);
|
|
1618
|
+
d.addEventListener("mouseout", (e) => this.onOut(e), true);
|
|
1619
|
+
d.addEventListener("submit", (e) => this.onSubmit(e), true);
|
|
1620
|
+
d.addEventListener("change", (e) => this.onChange(e), true);
|
|
1621
|
+
d.addEventListener("input", (e) => this.onInput(e), true);
|
|
1622
|
+
d.addEventListener("keydown", (e) => this.onKey(e), true);
|
|
1623
|
+
d.addEventListener("keyup", (e) => this.onKeyUp(e), true);
|
|
1624
|
+
d.addEventListener("dragstart", (e) => e.preventDefault(), true);
|
|
1625
|
+
d.addEventListener("wheel", (e) => this.onWheel(e), { capture: true, passive: false });
|
|
1626
|
+
d.addEventListener("scroll", (e) => this.onScroll(e), true);
|
|
1627
|
+
d.addEventListener("load", () => this.refit(), true);
|
|
1628
|
+
d.addEventListener("error", () => this.refit(), true);
|
|
1629
|
+
d.fonts.addEventListener("loadingdone", () => this.refit());
|
|
1630
|
+
const toPage = (x, y) => {
|
|
1631
|
+
const r = this.frame.getBoundingClientRect();
|
|
1632
|
+
return [r.left + x, r.top + y];
|
|
1633
|
+
};
|
|
1634
|
+
if (this.host.pageScrolls) {
|
|
1635
|
+
new Touch(
|
|
1636
|
+
d,
|
|
1637
|
+
this.frame.ownerDocument.defaultView,
|
|
1638
|
+
{ scroll: (dx, dy) => this.host.scrollPage(dx, dy), toPage },
|
|
1639
|
+
false,
|
|
1640
|
+
(e) => !this.axes && this.inScroller(e.target)
|
|
1641
|
+
);
|
|
1642
|
+
} else {
|
|
1643
|
+
new Touch(d, this.frame.ownerDocument.defaultView, {
|
|
1644
|
+
scroll: (dx, dy, x, y) => {
|
|
1645
|
+
if (this.touchRoute === "terminal") this.host.wheel(new WheelEvent("wheel", { deltaX: dx, deltaY: dy, deltaMode: 0, clientX: x, clientY: y }), x, y);
|
|
1646
|
+
},
|
|
1647
|
+
toPage,
|
|
1648
|
+
claim: (dx, dy, e) => {
|
|
1649
|
+
this.touchRoute = this.axes ? this.gestureRoute(e.target, dx, dy) : "terminal";
|
|
1650
|
+
return this.touchRoute !== "doc";
|
|
1651
|
+
}
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
win.addEventListener("focus", () => {
|
|
1655
|
+
if (!this.programFocus && !this.auxPress && !this.keyboard) this.settle();
|
|
1656
|
+
});
|
|
1657
|
+
win.addEventListener("blur", () => {
|
|
1658
|
+
this.commit();
|
|
1659
|
+
if (this.keyboard) {
|
|
1660
|
+
this.keyboard = false;
|
|
1661
|
+
this.emit("blur", "");
|
|
1662
|
+
}
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
1666
|
+
* Nothing in a surface scrolls, and a wheel over it is the terminal's
|
|
1667
|
+
* (SPEC §5.3, §9). Wheel events never leave an iframe, so without this
|
|
1668
|
+
* the terminal's scrollback (or, on the alternate screen, the program's
|
|
1669
|
+
* wheel input) would stall under the pointer. Ctrl and the wheel stay the
|
|
1670
|
+
* browser's: its zoom. Where the page scrolls (the `scroll` option), the
|
|
1671
|
+
* wheel is the browser's too: nothing in the frame scrolls, so it scrolls
|
|
1672
|
+
* the page, as over the cells; but over an element it would scroll
|
|
1673
|
+
* instead, the surface scrolls the page itself.
|
|
1674
|
+
*/
|
|
1675
|
+
onWheel(e) {
|
|
1676
|
+
if (e.ctrlKey) return;
|
|
1677
|
+
if (this.axes) {
|
|
1678
|
+
this.scrollWheel(e);
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
if (this.host.pageScrolls) {
|
|
1682
|
+
if (!this.inScroller(e.target)) return;
|
|
1683
|
+
e.preventDefault();
|
|
1684
|
+
const px = e.deltaMode === WheelEvent.DOM_DELTA_PIXEL ? 1 : e.deltaMode === WheelEvent.DOM_DELTA_LINE ? 20 : 400;
|
|
1685
|
+
this.host.scrollPage(e.deltaX * px, e.deltaY * px);
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
e.preventDefault();
|
|
1689
|
+
const r = this.frame.getBoundingClientRect();
|
|
1690
|
+
this.host.wheel(e, r.left + e.clientX, r.top + e.clientY);
|
|
1691
|
+
}
|
|
1692
|
+
/** Whether a wheel or a touch at target would scroll an element of the
|
|
1693
|
+
* document, overflowing with `overflow: auto` or `scroll`: the browser
|
|
1694
|
+
* gives the gesture to it before the page, and nothing in a surface
|
|
1695
|
+
* scrolls (SPEC §5.3), so the gesture would go nowhere. */
|
|
1696
|
+
inScroller(target) {
|
|
1697
|
+
for (let n = target && isElement(target) ? target : null; n; n = n.parentElement) {
|
|
1698
|
+
const s = n.ownerDocument.defaultView.getComputedStyle(n);
|
|
1699
|
+
const y = /auto|scroll/.test(s.overflowY) && n.scrollHeight > n.clientHeight;
|
|
1700
|
+
const x = /auto|scroll/.test(s.overflowX) && n.scrollWidth > n.clientWidth;
|
|
1701
|
+
if (x || y) return true;
|
|
1702
|
+
}
|
|
1703
|
+
return false;
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* A wheel over a document that scrolls (SPEC §5.3). A gesture's first
|
|
1707
|
+
* wheel decides where all of it goes, as a browser latches scrolling to
|
|
1708
|
+
* what it began on: while an element under the pointer can still move
|
|
1709
|
+
* that way, the browser scrolls it; where none can, the gesture goes on
|
|
1710
|
+
* to the terminal, as over the cells beneath (§9), unless
|
|
1711
|
+
* `overscroll-behavior` stops it. A gesture the document took goes no
|
|
1712
|
+
* further when it reaches the end there, as in a browser. Where the page
|
|
1713
|
+
* scrolls (the `scroll` option), the page is the terminal, and the
|
|
1714
|
+
* browser chains to it from the document natively. A wheel the browser
|
|
1715
|
+
* does not let the page cancel is the browser's already.
|
|
1716
|
+
*/
|
|
1717
|
+
scrollWheel(e) {
|
|
1718
|
+
if (this.host.pageScrolls || !e.cancelable) return;
|
|
1719
|
+
const route = this.host.wheelGesture(this.name, () => {
|
|
1720
|
+
const to = this.gestureTarget(e.target, e.deltaX, e.deltaY);
|
|
1721
|
+
this.wheelBox = typeof to === "string" ? null : to;
|
|
1722
|
+
return typeof to === "string" ? to : "doc";
|
|
1723
|
+
});
|
|
1724
|
+
const [axis, d] = mainAxis(e.deltaX, e.deltaY);
|
|
1725
|
+
if (route === "doc" && this.wheelBox && this.canMove(this.wheelBox, axis, d)) return;
|
|
1726
|
+
e.preventDefault();
|
|
1727
|
+
if (route !== "terminal") return;
|
|
1728
|
+
const r = this.frame.getBoundingClientRect();
|
|
1729
|
+
this.host.wheel(e, r.left + e.clientX, r.top + e.clientY);
|
|
1730
|
+
}
|
|
1731
|
+
/** Where a gesture that scrolls by dx, dy pixels at `target` goes, by its
|
|
1732
|
+
* main axis (SPEC §5.3). */
|
|
1733
|
+
gestureRoute(target, dx, dy) {
|
|
1734
|
+
const to = this.gestureTarget(target, dx, dy);
|
|
1735
|
+
return typeof to === "string" ? to : "doc";
|
|
1736
|
+
}
|
|
1737
|
+
gestureTarget(target, dx, dy) {
|
|
1738
|
+
const [axis, d] = mainAxis(dx, dy);
|
|
1739
|
+
return this.scroller(target && isElement(target) ? target : null, axis, d);
|
|
1740
|
+
}
|
|
1741
|
+
/** Whether a box that scrolls can still move along `axis`, `d`'s way. */
|
|
1742
|
+
canMove(el, axis, d) {
|
|
1743
|
+
if (!d || !(this.axes & (axis === "y" ? 1 : 2))) return false;
|
|
1744
|
+
const [at, span] = axis === "y" ? [el.scrollTop, el.scrollHeight - el.clientHeight] : [el.scrollLeft, el.scrollWidth - el.clientWidth];
|
|
1745
|
+
const rtl = axis === "x" && this.frame.contentWindow.getComputedStyle(el).direction === "rtl";
|
|
1746
|
+
const [min, max] = rtl ? [-span, 0] : [0, span];
|
|
1747
|
+
return d > 0 ? at < max - 0.5 : at > min + 0.5;
|
|
1748
|
+
}
|
|
1749
|
+
/**
|
|
1750
|
+
* What a scroll along `axis`, `d`'s way, from `from` (an element, or the
|
|
1751
|
+
* root for none) moves in a document that scrolls (SPEC §5.3): the
|
|
1752
|
+
* innermost box, from `from` outward, that the user scrolls along it
|
|
1753
|
+
* (`overflow` `auto` or `scroll`; the root's, unless `hidden` or `clip`),
|
|
1754
|
+
* that overflows there, and that can still move that way. At a box that
|
|
1755
|
+
* cannot, the scroll stops if its `overscroll-behavior` is `contain` or
|
|
1756
|
+
* `none`, and goes on outward otherwise. Past the root, and along an axis
|
|
1757
|
+
* the document did not ask for, it is the terminal's.
|
|
1758
|
+
*/
|
|
1759
|
+
scroller(from, axis, d) {
|
|
1760
|
+
if (!d || !(this.axes & (axis === "y" ? 1 : 2))) return "terminal";
|
|
1761
|
+
const win = this.frame.contentWindow;
|
|
1762
|
+
const root = this.doc.documentElement;
|
|
1763
|
+
const body = this.doc.body;
|
|
1764
|
+
const rootStyle = win.getComputedStyle(root);
|
|
1765
|
+
const fromBody = rootStyle.overflowX === "visible" && rootStyle.overflowY === "visible";
|
|
1766
|
+
const prop = axis === "y" ? "overflowY" : "overflowX";
|
|
1767
|
+
for (let el = from ?? root; el; el = el.parentElement) {
|
|
1768
|
+
if (el === body && fromBody) continue;
|
|
1769
|
+
const s = el === root ? rootStyle : win.getComputedStyle(el);
|
|
1770
|
+
const scrolls = el === root ? !/hidden|clip/.test((fromBody ? win.getComputedStyle(body) : s)[prop]) : /auto|scroll/.test(s[prop]);
|
|
1771
|
+
const span = axis === "y" ? el.scrollHeight - el.clientHeight : el.scrollWidth - el.clientWidth;
|
|
1772
|
+
if (!scrolls || span < 1) continue;
|
|
1773
|
+
if (this.canMove(el, axis, d)) return el;
|
|
1774
|
+
const stops = axis === "y" ? s.overscrollBehaviorY : s.overscrollBehaviorX;
|
|
1775
|
+
if (stops === "contain" || stops === "none") return "stop";
|
|
1776
|
+
}
|
|
1777
|
+
return "terminal";
|
|
1778
|
+
}
|
|
1779
|
+
/** Whatever the browser scrolled along an axis the document did not ask
|
|
1780
|
+
* for (a focused element into view, say) goes back to zero. A text
|
|
1781
|
+
* field's own text follows its caret (SPEC §5.3). */
|
|
1782
|
+
onScroll(e) {
|
|
1783
|
+
if (this.axes === 3) return;
|
|
1784
|
+
const t = e.target;
|
|
1785
|
+
const el = t === this.doc ? this.doc.scrollingElement : t && isElement(t) ? t : null;
|
|
1786
|
+
if (!el || t !== this.doc && (el.localName === "input" || el.localName === "textarea")) return;
|
|
1787
|
+
const x = !(this.axes & 2) && el.scrollLeft !== 0;
|
|
1788
|
+
const y = !(this.axes & 1) && el.scrollTop !== 0;
|
|
1789
|
+
if (x || y) el.scrollTo(x ? 0 : el.scrollLeft, y ? 0 : el.scrollTop);
|
|
1790
|
+
}
|
|
1791
|
+
/** An event for the program (SPEC §9); a detached surface sends none (§5.5). */
|
|
1792
|
+
emit(kind, target, detail) {
|
|
1793
|
+
if (this.detachedState) return;
|
|
1794
|
+
this.host.event(this.name, kind, target, detail);
|
|
1795
|
+
}
|
|
1796
|
+
linkIn(e) {
|
|
1797
|
+
for (const n of e.composedPath()) {
|
|
1798
|
+
if (isElement(n) && n.localName === "a" && n.hasAttribute("href")) return n;
|
|
1799
|
+
}
|
|
1800
|
+
return null;
|
|
1801
|
+
}
|
|
1802
|
+
/** A link's `url` (SPEC §9): its href resolved against the document's
|
|
1803
|
+
* base, or "" under hotty.invalid or when it is no URL. */
|
|
1804
|
+
urlOf(link) {
|
|
1805
|
+
const href = this.resolver.get(link, "href") ?? "";
|
|
1806
|
+
let url = "";
|
|
1807
|
+
try {
|
|
1808
|
+
url = new URL(href, this.base).href;
|
|
1809
|
+
} catch {
|
|
1810
|
+
}
|
|
1811
|
+
return url.startsWith(NO_BASE) ? "" : url;
|
|
1812
|
+
}
|
|
1813
|
+
/** The link's box in the page (the iframe may be offset for a window). */
|
|
1814
|
+
pageBox(el) {
|
|
1815
|
+
const f = this.frame.getBoundingClientRect();
|
|
1816
|
+
const r = el.getBoundingClientRect();
|
|
1817
|
+
return new DOMRect(f.left + r.left, f.top + r.top, r.width, r.height);
|
|
1818
|
+
}
|
|
1819
|
+
/** A hyperlink (SPEC §9): a link with target="_blank" and a `url`. One
|
|
1820
|
+
* without a url is the program's, as any other link. */
|
|
1821
|
+
isHyperlink(link) {
|
|
1822
|
+
return link.getAttribute("target") === "_blank" && this.urlOf(link) !== "";
|
|
1823
|
+
}
|
|
1824
|
+
hyper = (link) => this.isHyperlink(link);
|
|
1825
|
+
/** A link's click (SPEC §9). A hyperlink is the terminal's, as an OSC 8
|
|
1826
|
+
* one is, and not reported; any other link is the program's: an event,
|
|
1827
|
+
* with or without an id. The host opens nothing itself. */
|
|
1828
|
+
onLink(link, e) {
|
|
1829
|
+
e.preventDefault();
|
|
1830
|
+
const url = this.urlOf(link);
|
|
1831
|
+
if (this.isHyperlink(link)) {
|
|
1832
|
+
if (e.button === 0) this.host.hyperlink("activate", e, url, this.pageBox(link));
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
const detail = { href: this.resolver.get(link, "href") ?? "" };
|
|
1836
|
+
if (url) detail.url = url;
|
|
1837
|
+
detail.area = this.areaOf(link);
|
|
1838
|
+
this.emit("click", link.getAttribute("id") ?? "", detail);
|
|
1839
|
+
}
|
|
1840
|
+
/** Other buttons on a link do nothing: no navigation, no new tab. */
|
|
1841
|
+
onAuxClick(e) {
|
|
1842
|
+
if (this.linkIn(e)) e.preventDefault();
|
|
1843
|
+
}
|
|
1844
|
+
hovered = null;
|
|
1845
|
+
/** The pointer onto a hyperlink: the terminal hears it, as it does over
|
|
1846
|
+
* an OSC 8 one. */
|
|
1847
|
+
onOver(e) {
|
|
1848
|
+
if (this.altPointer !== null) return;
|
|
1849
|
+
const link = this.linkIn(e);
|
|
1850
|
+
const hyper = link && this.isHyperlink(link) ? link : null;
|
|
1851
|
+
if (hyper === this.hovered?.link) return;
|
|
1852
|
+
this.onLeave(e);
|
|
1853
|
+
const url = hyper ? this.urlOf(hyper) : "";
|
|
1854
|
+
if (hyper) {
|
|
1855
|
+
this.hovered = { link: hyper, url };
|
|
1856
|
+
this.host.hyperlink("hover", e, url, this.pageBox(hyper));
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
onOut(e) {
|
|
1860
|
+
if (this.altPointer !== null) return;
|
|
1861
|
+
const to = e.relatedTarget;
|
|
1862
|
+
if (this.hovered && to && this.hovered.link.contains(to)) return;
|
|
1863
|
+
this.onLeave(e);
|
|
1864
|
+
}
|
|
1865
|
+
onLeave(e) {
|
|
1866
|
+
const h = this.hovered;
|
|
1867
|
+
if (!h) return;
|
|
1868
|
+
this.hovered = null;
|
|
1869
|
+
this.host.hyperlink("leave", e, h.url, this.pageBox(h.link));
|
|
1870
|
+
}
|
|
1871
|
+
onClick(e) {
|
|
1872
|
+
const link = this.linkIn(e);
|
|
1873
|
+
if (this.altReleased) {
|
|
1874
|
+
this.altReleased = false;
|
|
1875
|
+
e.preventDefault();
|
|
1876
|
+
e.stopImmediatePropagation();
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
if (this.dragReleased) {
|
|
1880
|
+
this.dragReleased = false;
|
|
1881
|
+
if (link) e.preventDefault();
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
if (link) {
|
|
1885
|
+
this.onLink(link, e);
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
this.reportClick(e.composedPath());
|
|
1889
|
+
}
|
|
1890
|
+
/** The click of the nearest element on `path` that reports one (SPEC §9). */
|
|
1891
|
+
reportClick(path) {
|
|
1892
|
+
for (const n of path) {
|
|
1893
|
+
if (!isElement(n)) continue;
|
|
1894
|
+
if (n.localName === "a" && n.hasAttribute("href")) {
|
|
1895
|
+
if (this.isHyperlink(n)) return;
|
|
1896
|
+
const url = this.urlOf(n);
|
|
1897
|
+
const detail = { href: this.resolver.get(n, "href") ?? "" };
|
|
1898
|
+
if (url) detail.url = url;
|
|
1899
|
+
detail.area = this.areaOf(n);
|
|
1900
|
+
this.emit("click", n.getAttribute("id") ?? "", detail);
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
const el = n;
|
|
1904
|
+
const tag = el.localName;
|
|
1905
|
+
const type = (el.getAttribute("type") ?? "").toLowerCase();
|
|
1906
|
+
const wants = (el.getAttribute("data-on") ?? "").split(/\s+/).includes("click");
|
|
1907
|
+
const reportable = tag === "button" || tag === "a" || tag === "summary" || tag === "input" && ["button", "submit", "reset"].includes(type) || wants;
|
|
1908
|
+
if (!reportable) continue;
|
|
1909
|
+
const id = el.getAttribute("id");
|
|
1910
|
+
if (id) {
|
|
1911
|
+
const detail = {};
|
|
1912
|
+
const href = this.resolver.get(el, "href");
|
|
1913
|
+
if (href !== null) detail.href = href;
|
|
1914
|
+
const value = this.resolver.get(el, "value");
|
|
1915
|
+
if (value !== null) detail.value = value;
|
|
1916
|
+
detail.area = this.areaOf(el);
|
|
1917
|
+
this.emit("click", id, detail);
|
|
1918
|
+
}
|
|
1919
|
+
break;
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
onSubmit(e) {
|
|
1923
|
+
e.preventDefault();
|
|
1924
|
+
const form = e.target;
|
|
1925
|
+
const id = form.getAttribute("id");
|
|
1926
|
+
if (!id) return;
|
|
1927
|
+
const fields = {};
|
|
1928
|
+
const data = new this.frame.contentWindow.FormData(form, e.submitter ?? void 0);
|
|
1929
|
+
for (const [k, v] of data) if (typeof v === "string") fields[k] = v;
|
|
1930
|
+
this.emit("submit", id, fields);
|
|
1931
|
+
}
|
|
1932
|
+
onChange(e) {
|
|
1933
|
+
const el = e.target;
|
|
1934
|
+
const id = el.getAttribute?.("id");
|
|
1935
|
+
if (!id) return;
|
|
1936
|
+
const type = (el.getAttribute("type") ?? "").toLowerCase();
|
|
1937
|
+
if (el.localName === "input" && (type === "checkbox" || type === "radio")) {
|
|
1938
|
+
this.emit("change", id, { checked: el.checked, value: this.resolver.get(el, "value") });
|
|
1939
|
+
} else if ("value" in el) {
|
|
1940
|
+
this.emit("change", id, { value: el.value });
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
onInput(e) {
|
|
1944
|
+
const el = e.target;
|
|
1945
|
+
const id = el.getAttribute?.("id");
|
|
1946
|
+
if (!id) return;
|
|
1947
|
+
const type = (el.getAttribute("type") ?? "").toLowerCase();
|
|
1948
|
+
if (type === "checkbox" || type === "radio") return;
|
|
1949
|
+
if ((el.getAttribute("data-on") ?? "").split(/\s+/).includes("input") && "value" in el) {
|
|
1950
|
+
this.emit("input", id, { value: el.value });
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
};
|
|
1954
|
+
function isElement(n) {
|
|
1955
|
+
return n.nodeType === 1;
|
|
1956
|
+
}
|
|
1957
|
+
function listens(el, what) {
|
|
1958
|
+
return (el.getAttribute("data-on") ?? "").split(/\s+/).includes(what);
|
|
1959
|
+
}
|
|
1960
|
+
function ancestry(el) {
|
|
1961
|
+
const out = [];
|
|
1962
|
+
for (let n = el; n; n = n.parentElement) out.push(n);
|
|
1963
|
+
return out;
|
|
1964
|
+
}
|
|
1965
|
+
function keysOf(e) {
|
|
1966
|
+
const keys = [];
|
|
1967
|
+
if (e.shiftKey) keys.push("shift");
|
|
1968
|
+
if (e.ctrlKey) keys.push("ctrl");
|
|
1969
|
+
if (e.altKey) keys.push("alt");
|
|
1970
|
+
if (e.metaKey) keys.push("meta");
|
|
1971
|
+
return keys;
|
|
1972
|
+
}
|
|
1973
|
+
function takesFocus(el, hyper) {
|
|
1974
|
+
if (el.matches(":disabled")) return false;
|
|
1975
|
+
switch (el.localName) {
|
|
1976
|
+
case "button":
|
|
1977
|
+
case "select":
|
|
1978
|
+
case "textarea":
|
|
1979
|
+
return true;
|
|
1980
|
+
case "input":
|
|
1981
|
+
return el.type !== "hidden";
|
|
1982
|
+
case "a":
|
|
1983
|
+
case "area":
|
|
1984
|
+
if (el.hasAttribute("href")) return !hyper(el);
|
|
1985
|
+
break;
|
|
1986
|
+
case "summary": {
|
|
1987
|
+
const d = el.parentElement;
|
|
1988
|
+
if (d?.localName === "details" && d.querySelector(":scope > summary") === el) return true;
|
|
1989
|
+
break;
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
const ce = el.getAttribute("contenteditable");
|
|
1993
|
+
if (ce !== null && ce.toLowerCase() !== "false" && el.isContentEditable) return true;
|
|
1994
|
+
return el.hasAttribute("tabindex") && el.tabIndex >= 0;
|
|
1995
|
+
}
|
|
1996
|
+
function focusTargetAt(e, hyper) {
|
|
1997
|
+
for (const n of e.composedPath()) {
|
|
1998
|
+
if (!isElement(n)) continue;
|
|
1999
|
+
if (n.localName === "a" && n.hasAttribute("href") && hyper(n)) return null;
|
|
2000
|
+
if (takesFocus(n, hyper)) return n;
|
|
2001
|
+
const control = n.localName === "label" ? n.control : null;
|
|
2002
|
+
if (control) return takesFocus(control, hyper) ? control : null;
|
|
2003
|
+
}
|
|
2004
|
+
return null;
|
|
2005
|
+
}
|
|
2006
|
+
function mainAxis(dx, dy) {
|
|
2007
|
+
return Math.abs(dy) >= Math.abs(dx) ? ["y", dy] : ["x", dx];
|
|
2008
|
+
}
|
|
2009
|
+
function keyScroll(e) {
|
|
2010
|
+
if (e.ctrlKey || e.altKey || e.metaKey) return null;
|
|
2011
|
+
if (e.shiftKey) return e.key === " " ? { axis: "y", sign: -1, by: "page" } : null;
|
|
2012
|
+
switch (e.key) {
|
|
2013
|
+
case "ArrowDown":
|
|
2014
|
+
return { axis: "y", sign: 1, by: "line" };
|
|
2015
|
+
case "ArrowUp":
|
|
2016
|
+
return { axis: "y", sign: -1, by: "line" };
|
|
2017
|
+
case "ArrowRight":
|
|
2018
|
+
return { axis: "x", sign: 1, by: "line" };
|
|
2019
|
+
case "ArrowLeft":
|
|
2020
|
+
return { axis: "x", sign: -1, by: "line" };
|
|
2021
|
+
case "PageDown":
|
|
2022
|
+
case " ":
|
|
2023
|
+
return { axis: "y", sign: 1, by: "page" };
|
|
2024
|
+
case "PageUp":
|
|
2025
|
+
return { axis: "y", sign: -1, by: "page" };
|
|
2026
|
+
case "End":
|
|
2027
|
+
return { axis: "y", sign: 1, by: "end" };
|
|
2028
|
+
case "Home":
|
|
2029
|
+
return { axis: "y", sign: -1, by: "end" };
|
|
2030
|
+
}
|
|
2031
|
+
return null;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// src/version.ts
|
|
2035
|
+
var VERSION = "0.1.0";
|
|
2036
|
+
var HOST = "@neuroplastio/xterm-addon-hotty";
|
|
2037
|
+
|
|
2038
|
+
// src/wire.ts
|
|
2039
|
+
var OSC = 7279;
|
|
2040
|
+
var CHUNK = 4096;
|
|
2041
|
+
var MAX_COMMAND = 16 << 20;
|
|
2042
|
+
var Control = class _Control {
|
|
2043
|
+
pairs;
|
|
2044
|
+
constructor(pairs = []) {
|
|
2045
|
+
this.pairs = pairs;
|
|
2046
|
+
}
|
|
2047
|
+
static parse(text2) {
|
|
2048
|
+
const pairs = [];
|
|
2049
|
+
if (text2 === "") return new _Control(pairs);
|
|
2050
|
+
for (const part of text2.split(":")) {
|
|
2051
|
+
const eq = part.indexOf("=");
|
|
2052
|
+
if (eq <= 0) throw new Error(`bad control pair ${JSON.stringify(part)}`);
|
|
2053
|
+
pairs.push([part.slice(0, eq), part.slice(eq + 1)]);
|
|
2054
|
+
}
|
|
2055
|
+
return new _Control(pairs);
|
|
2056
|
+
}
|
|
2057
|
+
get(key) {
|
|
2058
|
+
for (const [k, v] of this.pairs) if (k === key) return v;
|
|
2059
|
+
return void 0;
|
|
2060
|
+
}
|
|
2061
|
+
set(key, value) {
|
|
2062
|
+
const i = this.pairs.findIndex(([k]) => k === key);
|
|
2063
|
+
if (i >= 0) this.pairs[i] = [key, value];
|
|
2064
|
+
else this.pairs.push([key, value]);
|
|
2065
|
+
return this;
|
|
2066
|
+
}
|
|
2067
|
+
delete(key) {
|
|
2068
|
+
const i = this.pairs.findIndex(([k]) => k === key);
|
|
2069
|
+
if (i >= 0) this.pairs.splice(i, 1);
|
|
2070
|
+
return this;
|
|
2071
|
+
}
|
|
2072
|
+
/** A continuation chunk carries nothing but `m` and `q`. */
|
|
2073
|
+
onlyChunkKeys() {
|
|
2074
|
+
return this.pairs.every(([k]) => k === "m" || k === "q");
|
|
2075
|
+
}
|
|
2076
|
+
encode() {
|
|
2077
|
+
return this.pairs.map(([k, v]) => `${k}=${clean2(v)}`).join(":");
|
|
2078
|
+
}
|
|
2079
|
+
};
|
|
2080
|
+
function clean2(value) {
|
|
2081
|
+
return value.replace(/[^\x20-\x7e]|[:;=]/gu, "_");
|
|
2082
|
+
}
|
|
2083
|
+
var Assembler = class {
|
|
2084
|
+
partial = null;
|
|
2085
|
+
/** Feeds the data of one OSC 7279 sequence. */
|
|
2086
|
+
feed(data) {
|
|
2087
|
+
const semi = data.indexOf(";");
|
|
2088
|
+
const ctlText = semi < 0 ? data : data.slice(0, semi);
|
|
2089
|
+
const b64 = semi < 0 ? "" : data.slice(semi + 1);
|
|
2090
|
+
let control;
|
|
2091
|
+
try {
|
|
2092
|
+
control = Control.parse(ctlText);
|
|
2093
|
+
} catch (e) {
|
|
2094
|
+
this.partial = null;
|
|
2095
|
+
return [{ kind: "invalid", reason: String(e.message) }];
|
|
2096
|
+
}
|
|
2097
|
+
const more = control.get("m") === "1";
|
|
2098
|
+
const partial = this.partial;
|
|
2099
|
+
this.partial = null;
|
|
2100
|
+
if (partial && control.onlyChunkKeys()) {
|
|
2101
|
+
partial.b64.push(b64);
|
|
2102
|
+
partial.size += b64.length;
|
|
2103
|
+
if (partial.size > MAX_COMMAND) return [{ kind: "invalid", reason: "command too large" }];
|
|
2104
|
+
if (more) {
|
|
2105
|
+
this.partial = partial;
|
|
2106
|
+
return [{ kind: "partial" }];
|
|
2107
|
+
}
|
|
2108
|
+
return settle([], emit(partial.control, partial.b64.join("")));
|
|
2109
|
+
}
|
|
2110
|
+
const out = [];
|
|
2111
|
+
if (partial) out.push({ kind: "invalid", reason: "chunked command interrupted" });
|
|
2112
|
+
if (more) {
|
|
2113
|
+
this.partial = { control, b64: [b64], size: b64.length };
|
|
2114
|
+
out.push({ kind: "partial" });
|
|
2115
|
+
return out;
|
|
2116
|
+
}
|
|
2117
|
+
return settle(out, emit(control, b64));
|
|
2118
|
+
}
|
|
2119
|
+
};
|
|
2120
|
+
function settle(out, d) {
|
|
2121
|
+
if (d instanceof Promise) return d.then((x) => [...out, x]);
|
|
2122
|
+
out.push(d);
|
|
2123
|
+
return out;
|
|
2124
|
+
}
|
|
2125
|
+
function emit(control, b64) {
|
|
2126
|
+
const fail = (e) => ({ kind: "invalid", reason: `bad payload: ${e.message}` });
|
|
2127
|
+
let bytes;
|
|
2128
|
+
try {
|
|
2129
|
+
bytes = fromBase64(b64);
|
|
2130
|
+
} catch (e) {
|
|
2131
|
+
return fail(e);
|
|
2132
|
+
}
|
|
2133
|
+
const done = (payload) => {
|
|
2134
|
+
control.delete("m").delete("o");
|
|
2135
|
+
return { kind: "command", command: { control, payload } };
|
|
2136
|
+
};
|
|
2137
|
+
if (control.get("o") === "z") return inflate(bytes).then(done, fail);
|
|
2138
|
+
return done(bytes);
|
|
2139
|
+
}
|
|
2140
|
+
function fromBase64(b64) {
|
|
2141
|
+
const s = b64.replace(/\s+/g, "");
|
|
2142
|
+
if (s === "") return new Uint8Array(0);
|
|
2143
|
+
const bin = atob(s);
|
|
2144
|
+
const out = new Uint8Array(bin.length);
|
|
2145
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
2146
|
+
return out;
|
|
2147
|
+
}
|
|
2148
|
+
function toBase64(bytes) {
|
|
2149
|
+
let bin = "";
|
|
2150
|
+
for (let i = 0; i < bytes.length; i += 32768) {
|
|
2151
|
+
bin += String.fromCharCode(...bytes.subarray(i, i + 32768));
|
|
2152
|
+
}
|
|
2153
|
+
return btoa(bin);
|
|
2154
|
+
}
|
|
2155
|
+
async function inflate(bytes) {
|
|
2156
|
+
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate"));
|
|
2157
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
2158
|
+
}
|
|
2159
|
+
var utf8 = new TextDecoder("utf-8", { fatal: false });
|
|
2160
|
+
var utf8Encoder = new TextEncoder();
|
|
2161
|
+
function text(bytes) {
|
|
2162
|
+
return utf8.decode(bytes);
|
|
2163
|
+
}
|
|
2164
|
+
function encode(control, payload = new Uint8Array(0)) {
|
|
2165
|
+
const bytes = typeof payload === "string" ? utf8Encoder.encode(payload) : payload;
|
|
2166
|
+
const b64 = toBase64(bytes);
|
|
2167
|
+
const osc = (ctl, chunk) => `\x1B]${OSC};${ctl}${chunk === null ? "" : `;${chunk}`}\x1B\\`;
|
|
2168
|
+
if (b64.length <= CHUNK) return osc(control.encode(), b64 === "" ? null : b64);
|
|
2169
|
+
const quiet = control.get("q");
|
|
2170
|
+
let out = "";
|
|
2171
|
+
for (let i = 0; i < b64.length; i += CHUNK) {
|
|
2172
|
+
const last = i + CHUNK >= b64.length;
|
|
2173
|
+
let ctl;
|
|
2174
|
+
if (i === 0) {
|
|
2175
|
+
ctl = new Control([...control.pairs]).set("m", "1");
|
|
2176
|
+
} else {
|
|
2177
|
+
ctl = new Control([["m", last ? "0" : "1"]]);
|
|
2178
|
+
if (quiet !== void 0) ctl.set("q", quiet);
|
|
2179
|
+
}
|
|
2180
|
+
out += osc(ctl.encode(), b64.slice(i, i + CHUNK));
|
|
2181
|
+
}
|
|
2182
|
+
return out;
|
|
2183
|
+
}
|
|
2184
|
+
async function decodeAll(stream) {
|
|
2185
|
+
const a = new Assembler();
|
|
2186
|
+
const out = [];
|
|
2187
|
+
const re = /\x1b\]7279;([^\x07\x1b]*)(?:\x07|\x1b\\)/g;
|
|
2188
|
+
for (const m of stream.matchAll(re)) {
|
|
2189
|
+
for (const d of await a.feed(m[1])) if (d.kind === "command") out.push(d.command);
|
|
2190
|
+
}
|
|
2191
|
+
return out;
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
// src/addon.ts
|
|
2195
|
+
var mac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
2196
|
+
function browserKeys(e) {
|
|
2197
|
+
if (e.key === "F5" || e.key === "F11" || e.key === "F12") return true;
|
|
2198
|
+
if (mac && e.metaKey) return true;
|
|
2199
|
+
const mod = e.ctrlKey || e.metaKey;
|
|
2200
|
+
const k = e.key.toLowerCase();
|
|
2201
|
+
if (mod && !e.altKey && k === "r") return true;
|
|
2202
|
+
if (mod && ["=", "+", "-", "0"].includes(e.key)) return true;
|
|
2203
|
+
if (e.ctrlKey && e.shiftKey && ["i", "j", "c"].includes(k)) return true;
|
|
2204
|
+
return false;
|
|
2205
|
+
}
|
|
2206
|
+
var GESTURE_MS = 150;
|
|
2207
|
+
var EVENTS = ["click", "change", "input", "submit", "press", "drag", "focus", "blur", "resize", "fit"];
|
|
2208
|
+
var Failure = class extends Error {
|
|
2209
|
+
code;
|
|
2210
|
+
constructor(code, detail) {
|
|
2211
|
+
super(detail);
|
|
2212
|
+
this.code = code;
|
|
2213
|
+
}
|
|
2214
|
+
};
|
|
2215
|
+
var HottyAddon = class {
|
|
2216
|
+
term;
|
|
2217
|
+
opts;
|
|
2218
|
+
store;
|
|
2219
|
+
surfaces = /* @__PURE__ */ new Map();
|
|
2220
|
+
placements = /* @__PURE__ */ new Map();
|
|
2221
|
+
assembler = new Assembler();
|
|
2222
|
+
disposables = [];
|
|
2223
|
+
layer = null;
|
|
2224
|
+
/** Commands held while synchronized output is on (PROTOCOL §5). */
|
|
2225
|
+
held = [];
|
|
2226
|
+
heldTimer = null;
|
|
2227
|
+
css = "";
|
|
2228
|
+
policy;
|
|
2229
|
+
metrics = { cellW: 0, cellH: 0, key: "" };
|
|
2230
|
+
resizeTimer = null;
|
|
2231
|
+
/** A surface's key is being forwarded to the terminal (`key`), and the
|
|
2232
|
+
* terminal's own focus is held off meanwhile. A program in the page may
|
|
2233
|
+
* answer the key inside its dispatch, since xterm.js parses what follows
|
|
2234
|
+
* user input at once: a surface that gives the keyboard back then has
|
|
2235
|
+
* its focus carried out once the key is through. */
|
|
2236
|
+
forwarding = false;
|
|
2237
|
+
focusAfter = false;
|
|
2238
|
+
/** The wheel gesture under way: the surface its last wheel was over ("" for
|
|
2239
|
+
* the cells), where it goes, and when that wheel came. */
|
|
2240
|
+
gesture = null;
|
|
2241
|
+
constructor(options = {}) {
|
|
2242
|
+
this.opts = { maxSurfaces: 64, resourceQuota: 64 << 20, ...options };
|
|
2243
|
+
this.policy = clean(options.network);
|
|
2244
|
+
this.store = new Store(this.opts.resourceQuota);
|
|
2245
|
+
this.store.onChange = (names) => {
|
|
2246
|
+
for (const s of this.surfaces.values()) s.refresh(names);
|
|
2247
|
+
};
|
|
2248
|
+
}
|
|
2249
|
+
activate(term) {
|
|
2250
|
+
this.term = term;
|
|
2251
|
+
term.attachCustomKeyEventHandler((e) => !this.browserKey(e));
|
|
2252
|
+
const p = term.parser;
|
|
2253
|
+
this.disposables.push(
|
|
2254
|
+
p.registerOscHandler(OSC, (data) => this.onOsc(data)),
|
|
2255
|
+
// `CSI ? 2026 l` ends a synchronized batch: apply what was held, then
|
|
2256
|
+
// let xterm handle the mode as usual.
|
|
2257
|
+
p.registerCsiHandler({ prefix: "?", final: "l" }, (params) => {
|
|
2258
|
+
if (params.some((x) => x === 2026)) this.release();
|
|
2259
|
+
return false;
|
|
2260
|
+
}),
|
|
2261
|
+
// RIS: every surface goes (PROTOCOL §4).
|
|
2262
|
+
p.registerEscHandler({ final: "c" }, () => {
|
|
2263
|
+
this.reset();
|
|
2264
|
+
return false;
|
|
2265
|
+
}),
|
|
2266
|
+
term.buffer.onBufferChange((b) => this.onBufferChange(b.type)),
|
|
2267
|
+
term.onRender(() => {
|
|
2268
|
+
this.bindTouch();
|
|
2269
|
+
this.checkMetrics();
|
|
2270
|
+
}),
|
|
2271
|
+
term.onResize(() => this.checkMetrics()),
|
|
2272
|
+
term.onScroll(() => this.reposition()),
|
|
2273
|
+
term.onWriteParsed(() => this.reposition())
|
|
2274
|
+
);
|
|
2275
|
+
}
|
|
2276
|
+
dispose() {
|
|
2277
|
+
this.cellTouch?.dispose();
|
|
2278
|
+
this.cellTouch = null;
|
|
2279
|
+
this.reset();
|
|
2280
|
+
for (const d of this.disposables) d.dispose();
|
|
2281
|
+
this.disposables.length = 0;
|
|
2282
|
+
this.layer?.remove();
|
|
2283
|
+
this.layer = null;
|
|
2284
|
+
}
|
|
2285
|
+
/**
|
|
2286
|
+
* An element of a surface as the program wrote it (tag, attributes, text,
|
|
2287
|
+
* child elements): the shape the shared conformance vectors check
|
|
2288
|
+
* (conformance/README.md). `null` if there is no such surface or element.
|
|
2289
|
+
*/
|
|
2290
|
+
inspect(surface, id) {
|
|
2291
|
+
const s = this.surfaces.get(surface);
|
|
2292
|
+
const el = s?.doc.getElementById(id);
|
|
2293
|
+
if (!s || !el) return null;
|
|
2294
|
+
return {
|
|
2295
|
+
tag: el.localName,
|
|
2296
|
+
attrs: Object.fromEntries(s.resolver.attributes(el)),
|
|
2297
|
+
text: el.textContent ?? "",
|
|
2298
|
+
children: Array.from(el.children, (c) => [c.localName, c.getAttribute("id"), c.textContent ?? ""])
|
|
2299
|
+
};
|
|
2300
|
+
}
|
|
2301
|
+
// --- Wire -----------------------------------------------------------------
|
|
2302
|
+
onOsc(data) {
|
|
2303
|
+
const decoded = this.assembler.feed(data);
|
|
2304
|
+
if (decoded instanceof Promise) return decoded.then((d) => this.run(d)).then(() => true);
|
|
2305
|
+
const r = this.run(decoded);
|
|
2306
|
+
return r instanceof Promise ? r.then(() => true) : true;
|
|
2307
|
+
}
|
|
2308
|
+
run(decoded) {
|
|
2309
|
+
let chain;
|
|
2310
|
+
for (const d of decoded) {
|
|
2311
|
+
if (d.kind === "invalid") this.opts.onInvalid?.(d.reason);
|
|
2312
|
+
if (d.kind !== "command") continue;
|
|
2313
|
+
const cmd = d.command;
|
|
2314
|
+
chain = sequence(chain, () => this.submit(cmd));
|
|
2315
|
+
}
|
|
2316
|
+
return chain;
|
|
2317
|
+
}
|
|
2318
|
+
submit(cmd) {
|
|
2319
|
+
if (this.term.modes.synchronizedOutputMode && cmd.control.get("a") !== "q") {
|
|
2320
|
+
this.held.push(cmd);
|
|
2321
|
+
this.heldTimer ??= setTimeout(() => this.release(), 1e3);
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
return this.apply([cmd]);
|
|
2325
|
+
}
|
|
2326
|
+
release() {
|
|
2327
|
+
if (this.heldTimer) clearTimeout(this.heldTimer);
|
|
2328
|
+
this.heldTimer = null;
|
|
2329
|
+
const batch = this.held;
|
|
2330
|
+
this.held = [];
|
|
2331
|
+
if (batch.length) void this.apply(batch);
|
|
2332
|
+
}
|
|
2333
|
+
apply(batch) {
|
|
2334
|
+
const t0 = performance.now();
|
|
2335
|
+
let chain;
|
|
2336
|
+
for (const cmd of batch) chain = sequence(chain, () => this.handle(cmd));
|
|
2337
|
+
const done = () => this.opts.onFrame?.({ commands: batch.length, applyMs: performance.now() - t0 });
|
|
2338
|
+
if (chain) return chain.then(done);
|
|
2339
|
+
done();
|
|
2340
|
+
}
|
|
2341
|
+
handle(cmd) {
|
|
2342
|
+
const action = cmd.control.get("a") ?? "";
|
|
2343
|
+
const quiet = Number(cmd.control.get("q") ?? "0");
|
|
2344
|
+
const ok = (r) => {
|
|
2345
|
+
if (quiet === 0) this.reply("ok", cmd, r ?? {});
|
|
2346
|
+
};
|
|
2347
|
+
const fail = (e) => {
|
|
2348
|
+
const code = e instanceof Failure || e instanceof DeltaError ? e.code : "EINVAL";
|
|
2349
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
2350
|
+
if (quiet < 2) this.reply("err", cmd, { body: JSON.stringify({ code, detail }) });
|
|
2351
|
+
};
|
|
2352
|
+
try {
|
|
2353
|
+
const r = this.dispatch(action, cmd);
|
|
2354
|
+
if (r instanceof Promise) return r.then(ok, fail);
|
|
2355
|
+
ok(r);
|
|
2356
|
+
} catch (e) {
|
|
2357
|
+
fail(e);
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
reply(kind, cmd, r) {
|
|
2361
|
+
const c = new Control().set("a", kind);
|
|
2362
|
+
for (const k of ["n", "s"]) {
|
|
2363
|
+
const v = cmd.control.get(k);
|
|
2364
|
+
if (v !== void 0) c.set(k, v);
|
|
2365
|
+
}
|
|
2366
|
+
c.set("re", cmd.control.get("a") ?? "");
|
|
2367
|
+
for (const [k, v] of r.extra ?? []) c.set(k, v);
|
|
2368
|
+
this.send(encode(c, r.body ?? ""));
|
|
2369
|
+
}
|
|
2370
|
+
send(message) {
|
|
2371
|
+
this.term.input(message, false);
|
|
2372
|
+
}
|
|
2373
|
+
// --- Commands (PROTOCOL §3-§6) -------------------------------------------
|
|
2374
|
+
dispatch(action, cmd) {
|
|
2375
|
+
const c = cmd.control;
|
|
2376
|
+
switch (action) {
|
|
2377
|
+
case "q":
|
|
2378
|
+
return { body: JSON.stringify(this.capabilities()) };
|
|
2379
|
+
case "doc": {
|
|
2380
|
+
const name = this.surfaceName(c);
|
|
2381
|
+
let s = this.surfaces.get(name);
|
|
2382
|
+
if (!s) {
|
|
2383
|
+
if (this.surfaces.size >= this.opts.maxSurfaces) throw new Failure("EQUOTA", `at most ${this.opts.maxSurfaces} surfaces`);
|
|
2384
|
+
s = new Surface(name, this.host(), this.hostCss(), this.scheme());
|
|
2385
|
+
this.surfaces.set(name, s);
|
|
2386
|
+
}
|
|
2387
|
+
if (c.get("d") !== "1") s.cancelDrag();
|
|
2388
|
+
const scroll = c.get("scroll");
|
|
2389
|
+
s.setDocument(text(cmd.payload), c.get("d") === "1", scroll === "1" || scroll === "2" || scroll === "3" ? Number(scroll) : 0);
|
|
2390
|
+
return;
|
|
2391
|
+
}
|
|
2392
|
+
case "place":
|
|
2393
|
+
return this.place(this.existing(c), c);
|
|
2394
|
+
case "hide": {
|
|
2395
|
+
const s = this.existing(c);
|
|
2396
|
+
s.cancelDrag();
|
|
2397
|
+
if (s.hasKeyboard()) s.blur();
|
|
2398
|
+
this.unplace(s.name);
|
|
2399
|
+
return;
|
|
2400
|
+
}
|
|
2401
|
+
case "delta": {
|
|
2402
|
+
const s = this.existing(c);
|
|
2403
|
+
s.delta(c.get("op") ?? "morph", c.get("t"), c.get("k"), text(cmd.payload));
|
|
2404
|
+
return;
|
|
2405
|
+
}
|
|
2406
|
+
case "res": {
|
|
2407
|
+
const id = c.get("id");
|
|
2408
|
+
if (!id) throw new Failure("EINVAL", "res needs id=<name>");
|
|
2409
|
+
try {
|
|
2410
|
+
this.store.put(id, c.get("type") ?? "application/octet-stream", cmd.payload);
|
|
2411
|
+
} catch (e) {
|
|
2412
|
+
throw new Failure("EQUOTA", e.message);
|
|
2413
|
+
}
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
case "del": {
|
|
2417
|
+
const id = c.get("id");
|
|
2418
|
+
if (id !== void 0) {
|
|
2419
|
+
this.store.remove(id);
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
const name = c.get("s");
|
|
2423
|
+
if (name === void 0) {
|
|
2424
|
+
this.reset();
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
if (!this.surfaces.has(name)) throw new Failure("ENOENT", `no surface ${name}`);
|
|
2428
|
+
this.remove(name);
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
case "detach":
|
|
2432
|
+
this.existing(c).detach();
|
|
2433
|
+
return;
|
|
2434
|
+
case "focus": {
|
|
2435
|
+
const s = this.existing(c);
|
|
2436
|
+
if (s.detached) throw new Failure("EDETACHED", `surface ${s.name} is detached`);
|
|
2437
|
+
try {
|
|
2438
|
+
s.focus(c.get("t"));
|
|
2439
|
+
} catch (e) {
|
|
2440
|
+
throw new Failure("ENOTARGET", e.message);
|
|
2441
|
+
}
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
case "blur":
|
|
2445
|
+
this.existing(c).blur();
|
|
2446
|
+
return;
|
|
2447
|
+
case "":
|
|
2448
|
+
throw new Failure("EINVAL", "missing a=<action>");
|
|
2449
|
+
default:
|
|
2450
|
+
throw new Failure("EINVAL", `unknown action a=${action}`);
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
capabilities() {
|
|
2454
|
+
const { cellW, cellH } = this.cell();
|
|
2455
|
+
const scale = window.devicePixelRatio || 1;
|
|
2456
|
+
return {
|
|
2457
|
+
v: "0.1",
|
|
2458
|
+
ops: OPS,
|
|
2459
|
+
events: EVENTS,
|
|
2460
|
+
cell: { w: Math.round(cellW * scale), h: Math.round(cellH * scale) },
|
|
2461
|
+
scale,
|
|
2462
|
+
scheme: this.scheme(),
|
|
2463
|
+
limits: { resources: this.store.quota, surfaces: this.opts.maxSurfaces },
|
|
2464
|
+
net: this.policy,
|
|
2465
|
+
scroll: true,
|
|
2466
|
+
host: HOST,
|
|
2467
|
+
version: VERSION
|
|
2468
|
+
};
|
|
2469
|
+
}
|
|
2470
|
+
surfaceName(c) {
|
|
2471
|
+
const s = c.get("s");
|
|
2472
|
+
if (s === void 0) throw new Failure("EINVAL", "missing s=<surface>");
|
|
2473
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(s)) throw new Failure("EINVAL", `bad surface name ${JSON.stringify(s)}`);
|
|
2474
|
+
return s;
|
|
2475
|
+
}
|
|
2476
|
+
existing(c) {
|
|
2477
|
+
const name = this.surfaceName(c);
|
|
2478
|
+
const s = this.surfaces.get(name);
|
|
2479
|
+
if (!s) throw new Failure("ENOENT", `no surface ${name}`);
|
|
2480
|
+
return s;
|
|
2481
|
+
}
|
|
2482
|
+
place(s, c) {
|
|
2483
|
+
const cols = Number(c.get("c"));
|
|
2484
|
+
if (c.get("c") === void 0 || !Number.isInteger(cols)) throw new Failure("EINVAL", "place needs c=<cols>");
|
|
2485
|
+
if (cols < 1 || cols > 1e3) throw new Failure("EINVAL", "c out of range");
|
|
2486
|
+
const { cellW, cellH } = this.cell();
|
|
2487
|
+
const r = c.get("r");
|
|
2488
|
+
const auto = r === void 0 || r === "auto";
|
|
2489
|
+
const rows = auto ? s.contentRows(cols, cellW, cellH) : Number(r);
|
|
2490
|
+
if (!Number.isInteger(rows) || rows < 1 || rows > 1e3) throw new Failure("EINVAL", "r out of range");
|
|
2491
|
+
const cell = (k, d) => {
|
|
2492
|
+
const v = c.get(k);
|
|
2493
|
+
if (v === void 0) return d;
|
|
2494
|
+
const n = Number(v);
|
|
2495
|
+
if (!Number.isInteger(n) || n < 0) throw new Failure("EINVAL", `bad ${k}`);
|
|
2496
|
+
return n;
|
|
2497
|
+
};
|
|
2498
|
+
const x = cell("x", 0);
|
|
2499
|
+
const y = cell("y", 0);
|
|
2500
|
+
const win = { x, y, w: cell("w", cols - x), h: cell("h", rows - y) };
|
|
2501
|
+
if (win.w < 1 || win.h < 1 || x + win.w > cols || y + win.h > rows) {
|
|
2502
|
+
throw new Failure("EINVAL", "the window is not inside the surface");
|
|
2503
|
+
}
|
|
2504
|
+
const z = c.get("z") === void 0 ? 0 : Number(c.get("z"));
|
|
2505
|
+
if (!Number.isInteger(z) || z < -1e3 || z > 1e3) throw new Failure("EINVAL", "z is an integer from -1000 to 1000");
|
|
2506
|
+
s.autoRows = auto;
|
|
2507
|
+
s.setSize(cols, rows, cellW, cellH, win);
|
|
2508
|
+
s.setZ(z);
|
|
2509
|
+
this.unplace(s.name);
|
|
2510
|
+
s.presses = c.get("p") === "1";
|
|
2511
|
+
s.setFit(c.get("f") === "1" ? rows : null);
|
|
2512
|
+
const buf = this.term.buffer.active;
|
|
2513
|
+
const col = buf.cursorX;
|
|
2514
|
+
const row = buf.cursorY;
|
|
2515
|
+
const p = { surface: s, kind: buf.type === "normal" ? "normal" : "alt", col, row, disposables: [] };
|
|
2516
|
+
if (p.kind === "normal") {
|
|
2517
|
+
const marker = this.term.registerMarker(0);
|
|
2518
|
+
if (marker) {
|
|
2519
|
+
p.marker = marker;
|
|
2520
|
+
p.disposables.push(marker.onDispose(() => this.unplace(s.name)));
|
|
2521
|
+
} else {
|
|
2522
|
+
p.kind = "alt";
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
this.placements.set(s.name, p);
|
|
2526
|
+
this.position(p);
|
|
2527
|
+
if (c.get("C") !== "1") this.moveCursorBelow(win.h);
|
|
2528
|
+
return { extra: [["c", String(cols)], ["r", String(rows)]] };
|
|
2529
|
+
}
|
|
2530
|
+
/** As the native host: index `rows` times, then carriage return. */
|
|
2531
|
+
moveCursorBelow(rows) {
|
|
2532
|
+
const core = this.term._core;
|
|
2533
|
+
const ih = core?._inputHandler;
|
|
2534
|
+
if (ih && core?.buffer) {
|
|
2535
|
+
for (let i = 0; i < rows; i++) ih.lineFeed();
|
|
2536
|
+
core.buffer.x = 0;
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
/** Puts every placed surface where its cells are now. */
|
|
2540
|
+
reposition() {
|
|
2541
|
+
for (const p of this.placements.values()) this.position(p);
|
|
2542
|
+
}
|
|
2543
|
+
position(p) {
|
|
2544
|
+
const { cellW, cellH } = this.cell();
|
|
2545
|
+
const s = p.surface;
|
|
2546
|
+
let row = p.row;
|
|
2547
|
+
if (p.kind === "normal") {
|
|
2548
|
+
const buf = this.term.buffer.active;
|
|
2549
|
+
if (buf.type !== "normal" || !p.marker || p.marker.line < 0) {
|
|
2550
|
+
s.hide();
|
|
2551
|
+
return;
|
|
2552
|
+
}
|
|
2553
|
+
row = p.marker.line - buf.viewportY;
|
|
2554
|
+
}
|
|
2555
|
+
if (row + s.win.h <= 0 || row >= this.term.rows) {
|
|
2556
|
+
s.hide();
|
|
2557
|
+
return;
|
|
2558
|
+
}
|
|
2559
|
+
s.setSize(s.cols, s.rows, cellW, cellH, s.win);
|
|
2560
|
+
s.show(Math.round(p.col * cellW), row * cellH);
|
|
2561
|
+
}
|
|
2562
|
+
unplace(name) {
|
|
2563
|
+
const p = this.placements.get(name);
|
|
2564
|
+
if (!p) return;
|
|
2565
|
+
this.placements.delete(name);
|
|
2566
|
+
for (const d of p.disposables) d.dispose();
|
|
2567
|
+
p.marker?.dispose();
|
|
2568
|
+
p.surface.presses = false;
|
|
2569
|
+
p.surface.setFit(null);
|
|
2570
|
+
p.surface.park();
|
|
2571
|
+
}
|
|
2572
|
+
remove(name) {
|
|
2573
|
+
this.unplace(name);
|
|
2574
|
+
this.surfaces.get(name)?.destroy();
|
|
2575
|
+
this.surfaces.delete(name);
|
|
2576
|
+
}
|
|
2577
|
+
reset() {
|
|
2578
|
+
for (const name of Array.from(this.surfaces.keys())) this.remove(name);
|
|
2579
|
+
this.held = [];
|
|
2580
|
+
}
|
|
2581
|
+
onBufferChange(type) {
|
|
2582
|
+
for (const p of Array.from(this.placements.values())) {
|
|
2583
|
+
if (p.kind === "alt" && type === "normal") this.remove(p.surface.name);
|
|
2584
|
+
}
|
|
2585
|
+
this.reposition();
|
|
2586
|
+
}
|
|
2587
|
+
// --- Geometry and look ---------------------------------------------------
|
|
2588
|
+
host() {
|
|
2589
|
+
return {
|
|
2590
|
+
layer: this.ensureLayer(),
|
|
2591
|
+
store: this.store,
|
|
2592
|
+
event: (surface, kind, target, detail) => {
|
|
2593
|
+
const c = new Control().set("a", "ev").set("s", surface).set("e", kind).set("t", target);
|
|
2594
|
+
this.send(encode(c, detail === void 0 ? "" : JSON.stringify(detail)));
|
|
2595
|
+
},
|
|
2596
|
+
browserKey: (e) => this.browserKey(e),
|
|
2597
|
+
key: (e) => {
|
|
2598
|
+
const ta = this.term.textarea;
|
|
2599
|
+
if (!ta) return;
|
|
2600
|
+
const init = {
|
|
2601
|
+
key: e.key,
|
|
2602
|
+
code: e.code,
|
|
2603
|
+
location: e.location,
|
|
2604
|
+
repeat: e.repeat,
|
|
2605
|
+
ctrlKey: e.ctrlKey,
|
|
2606
|
+
altKey: e.altKey,
|
|
2607
|
+
shiftKey: e.shiftKey,
|
|
2608
|
+
metaKey: e.metaKey,
|
|
2609
|
+
keyCode: e.keyCode,
|
|
2610
|
+
which: e.which,
|
|
2611
|
+
bubbles: true,
|
|
2612
|
+
cancelable: true
|
|
2613
|
+
};
|
|
2614
|
+
Object.defineProperty(ta, "focus", { value: () => {
|
|
2615
|
+
}, configurable: true });
|
|
2616
|
+
this.forwarding = true;
|
|
2617
|
+
try {
|
|
2618
|
+
const ev = new KeyboardEvent(e.type, init);
|
|
2619
|
+
ta.dispatchEvent(ev);
|
|
2620
|
+
if (e.type === "keydown" && !ev.defaultPrevented && [...e.key].length === 1) {
|
|
2621
|
+
const c = e.key.codePointAt(0);
|
|
2622
|
+
ta.dispatchEvent(new KeyboardEvent("keypress", { ...init, charCode: c, keyCode: c, which: c }));
|
|
2623
|
+
}
|
|
2624
|
+
} finally {
|
|
2625
|
+
delete ta.focus;
|
|
2626
|
+
this.forwarding = false;
|
|
2627
|
+
if (this.focusAfter) {
|
|
2628
|
+
this.focusAfter = false;
|
|
2629
|
+
this.term.focus();
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
},
|
|
2633
|
+
focusTerminal: () => {
|
|
2634
|
+
if (this.forwarding) this.focusAfter = true;
|
|
2635
|
+
else this.term.focus();
|
|
2636
|
+
},
|
|
2637
|
+
policy: this.policy,
|
|
2638
|
+
pageScrolls: this.pageScrolls,
|
|
2639
|
+
scrollPage: (dx, dy) => this.scrollPage(dx, dy),
|
|
2640
|
+
wheelGesture: (surface, decide) => {
|
|
2641
|
+
const now = performance.now();
|
|
2642
|
+
const g = this.gesture;
|
|
2643
|
+
const same = g !== null && now - g.at < GESTURE_MS && (g.surface === surface || g.route === "terminal");
|
|
2644
|
+
const route = same ? g.route : decide();
|
|
2645
|
+
this.gesture = { surface, route, at: now };
|
|
2646
|
+
return route;
|
|
2647
|
+
},
|
|
2648
|
+
// A hyperlink (SPEC §9) goes where xterm.js sends an OSC 8 one: the
|
|
2649
|
+
// terminal's linkHandler, or its confirm-then-open default, and only
|
|
2650
|
+
// for http and https unless the handler allows other schemes.
|
|
2651
|
+
hyperlink: (kind, e, url, box) => {
|
|
2652
|
+
const handler = this.term.options.linkHandler;
|
|
2653
|
+
if (!handler?.allowNonHttpProtocols) {
|
|
2654
|
+
try {
|
|
2655
|
+
if (!["http:", "https:"].includes(new URL(url).protocol)) return;
|
|
2656
|
+
} catch {
|
|
2657
|
+
return;
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
const range = this.cellRange(box);
|
|
2661
|
+
if (kind === "activate") {
|
|
2662
|
+
if (handler) handler.activate(e, url, range);
|
|
2663
|
+
else openAfterConfirm(url);
|
|
2664
|
+
} else if (kind === "hover") handler?.hover?.(e, url, range);
|
|
2665
|
+
else handler?.leave?.(e, url, range);
|
|
2666
|
+
},
|
|
2667
|
+
// Replayed on the terminal's screen, where xterm handles it as its own:
|
|
2668
|
+
// scrollback, or mouse reports and arrow keys on the alternate screen.
|
|
2669
|
+
wheel: (e, x, y) => this.forwardWheel(e, x, y),
|
|
2670
|
+
cells: (kind, e, x, y) => this.pressCells(kind, e, x, y)
|
|
2671
|
+
};
|
|
2672
|
+
}
|
|
2673
|
+
cellTouch = null;
|
|
2674
|
+
pageScrollBound = false;
|
|
2675
|
+
/** Whether the page scrolls, not the terminal (the `scroll` option). */
|
|
2676
|
+
get pageScrolls() {
|
|
2677
|
+
return this.opts.scroll === "page";
|
|
2678
|
+
}
|
|
2679
|
+
/**
|
|
2680
|
+
* Wheels and touches on the cells left to the browser, which scrolls the
|
|
2681
|
+
* page (`scroll: "page"`): they stop at the terminal's element, before
|
|
2682
|
+
* xterm.js, which would take them for its scrollback, or turn a drag into
|
|
2683
|
+
* arrow keys, and cancel the browser's scroll. Nothing here cancels it.
|
|
2684
|
+
*/
|
|
2685
|
+
bindPageScroll(el) {
|
|
2686
|
+
if (this.pageScrollBound) return;
|
|
2687
|
+
this.pageScrollBound = true;
|
|
2688
|
+
const stop = (e) => e.stopPropagation();
|
|
2689
|
+
for (const type of ["wheel", "touchstart", "touchmove", "touchend", "touchcancel"]) {
|
|
2690
|
+
el.addEventListener(type, stop, { capture: true, passive: true });
|
|
2691
|
+
this.disposables.push({ dispose: () => el.removeEventListener(type, stop, { capture: true }) });
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
/** Scrolls what scrolls the terminal's page: its nearest ancestor that
|
|
2695
|
+
* scrolls, or the document. */
|
|
2696
|
+
scrollPage(dx, dy) {
|
|
2697
|
+
let el = this.term.element?.parentElement ?? null;
|
|
2698
|
+
for (; el; el = el.parentElement) {
|
|
2699
|
+
const o = getComputedStyle(el).overflowY;
|
|
2700
|
+
if (/auto|scroll/.test(o) && el.scrollHeight > el.clientHeight) break;
|
|
2701
|
+
}
|
|
2702
|
+
(el ?? document.scrollingElement ?? document.documentElement).scrollBy(dx, dy);
|
|
2703
|
+
}
|
|
2704
|
+
cellWheelBound = false;
|
|
2705
|
+
/** A wheel over the cells begins or goes on with a gesture of the
|
|
2706
|
+
* terminal's: a document that scrolls under the pointer as the terminal
|
|
2707
|
+
* scrolls takes none of it (`wheelGesture`). */
|
|
2708
|
+
bindCellWheel(el) {
|
|
2709
|
+
if (this.cellWheelBound) return;
|
|
2710
|
+
this.cellWheelBound = true;
|
|
2711
|
+
const wheel = (e) => {
|
|
2712
|
+
if (e.isTrusted) this.gesture = { surface: "", route: "terminal", at: performance.now() };
|
|
2713
|
+
};
|
|
2714
|
+
el.addEventListener("wheel", wheel, { capture: true, passive: true });
|
|
2715
|
+
this.disposables.push({ dispose: () => el.removeEventListener("wheel", wheel, { capture: true }) });
|
|
2716
|
+
}
|
|
2717
|
+
/** Touch on the cells (the `touch` option), once the terminal is open. */
|
|
2718
|
+
bindTouch() {
|
|
2719
|
+
const el = this.term.element;
|
|
2720
|
+
if (el && this.pageScrolls) return this.bindPageScroll(el);
|
|
2721
|
+
if (el) this.bindCellWheel(el);
|
|
2722
|
+
if (this.cellTouch || !el || this.opts.touch === false) return;
|
|
2723
|
+
const screen = () => el.querySelector(".xterm-screen");
|
|
2724
|
+
this.cellTouch = new Touch(
|
|
2725
|
+
el,
|
|
2726
|
+
el.ownerDocument.defaultView,
|
|
2727
|
+
{
|
|
2728
|
+
scroll: (dx, dy, x, y) => this.forwardWheel(new WheelEvent("wheel", { deltaX: dx, deltaY: dy, deltaMode: 0 }), x, y),
|
|
2729
|
+
// A tap is the press and release a click would make: xterm.js
|
|
2730
|
+
// reports them to the program as it reports a mouse.
|
|
2731
|
+
tap: (x, y) => {
|
|
2732
|
+
const at = { clientX: x, clientY: y, button: 0, bubbles: true, cancelable: true, view: el.ownerDocument.defaultView };
|
|
2733
|
+
screen()?.dispatchEvent(new MouseEvent("mousedown", { ...at, buttons: 1 }));
|
|
2734
|
+
el.ownerDocument.dispatchEvent(new MouseEvent("mouseup", { ...at, buttons: 0 }));
|
|
2735
|
+
},
|
|
2736
|
+
toPage: (x, y) => [x, y]
|
|
2737
|
+
},
|
|
2738
|
+
true
|
|
2739
|
+
);
|
|
2740
|
+
}
|
|
2741
|
+
/** A wheel, at a point in the page, replayed on the terminal's screen,
|
|
2742
|
+
* where xterm.js handles it as its own: scrollback, or mouse reports and
|
|
2743
|
+
* arrow keys on the alternate screen. */
|
|
2744
|
+
forwardWheel(e, x, y) {
|
|
2745
|
+
const ev = new WheelEvent("wheel", {
|
|
2746
|
+
deltaX: e.deltaX,
|
|
2747
|
+
deltaY: e.deltaY,
|
|
2748
|
+
deltaMode: e.deltaMode,
|
|
2749
|
+
clientX: x,
|
|
2750
|
+
clientY: y,
|
|
2751
|
+
shiftKey: e.shiftKey,
|
|
2752
|
+
altKey: e.altKey,
|
|
2753
|
+
metaKey: e.metaKey,
|
|
2754
|
+
bubbles: true,
|
|
2755
|
+
cancelable: true
|
|
2756
|
+
});
|
|
2757
|
+
legacyWheelDelta(ev, e);
|
|
2758
|
+
this.term.element?.querySelector(".xterm-screen")?.dispatchEvent(ev);
|
|
2759
|
+
}
|
|
2760
|
+
/**
|
|
2761
|
+
* A press with Alt held on a surface, a move of its gesture, or its
|
|
2762
|
+
* release (SPEC §9.2), replayed on the cells beneath, where xterm.js
|
|
2763
|
+
* handles them as its own mouse: a report to the program, with Alt, or
|
|
2764
|
+
* its selection (rectangular, with Alt). The press goes to the screen,
|
|
2765
|
+
* where xterm.js listens for presses; the moves and the release to its
|
|
2766
|
+
* document, where it listens while a button is held. The keyboard goes
|
|
2767
|
+
* back to the terminal first, as on a click on the cells (§10.1): a
|
|
2768
|
+
* surface that had it sends `blur`.
|
|
2769
|
+
*/
|
|
2770
|
+
pressCells(kind, e, x, y) {
|
|
2771
|
+
const el = this.term.element;
|
|
2772
|
+
if (!el) return;
|
|
2773
|
+
const init = {
|
|
2774
|
+
clientX: x,
|
|
2775
|
+
clientY: y,
|
|
2776
|
+
screenX: e.screenX,
|
|
2777
|
+
screenY: e.screenY,
|
|
2778
|
+
button: 0,
|
|
2779
|
+
buttons: e.buttons,
|
|
2780
|
+
// xterm.js selects on a press whose detail is the click count.
|
|
2781
|
+
detail: kind === "down" ? e.detail || 1 : e.detail,
|
|
2782
|
+
altKey: e.altKey,
|
|
2783
|
+
ctrlKey: e.ctrlKey,
|
|
2784
|
+
shiftKey: e.shiftKey,
|
|
2785
|
+
metaKey: e.metaKey,
|
|
2786
|
+
bubbles: true,
|
|
2787
|
+
cancelable: true,
|
|
2788
|
+
view: el.ownerDocument.defaultView
|
|
2789
|
+
};
|
|
2790
|
+
if (kind === "down") {
|
|
2791
|
+
for (const s of this.surfaces.values()) if (s.hasKeyboard()) s.blur();
|
|
2792
|
+
el.querySelector(".xterm-screen")?.dispatchEvent(new MouseEvent("mousedown", init));
|
|
2793
|
+
return;
|
|
2794
|
+
}
|
|
2795
|
+
el.ownerDocument.dispatchEvent(new MouseEvent(kind === "move" ? "mousemove" : "mouseup", init));
|
|
2796
|
+
}
|
|
2797
|
+
ensureLayer() {
|
|
2798
|
+
if (this.layer?.isConnected) return this.layer;
|
|
2799
|
+
const screen = this.term.element?.querySelector(".xterm-screen");
|
|
2800
|
+
if (!screen) throw new Failure("EINVAL", "terminal is not open");
|
|
2801
|
+
const layer = document.createElement("div");
|
|
2802
|
+
layer.className = "hotty-layer";
|
|
2803
|
+
Object.assign(layer.style, { position: "absolute", inset: "0", overflow: "hidden", zIndex: "6", pointerEvents: "none" });
|
|
2804
|
+
screen.append(layer);
|
|
2805
|
+
this.layer = layer;
|
|
2806
|
+
return layer;
|
|
2807
|
+
}
|
|
2808
|
+
browserKey(e) {
|
|
2809
|
+
return (this.opts.browserKeys ?? browserKeys)(e);
|
|
2810
|
+
}
|
|
2811
|
+
/** A box in the page as the buffer cells it covers (1-based, as xterm.js
|
|
2812
|
+
* gives an OSC 8 link's range). */
|
|
2813
|
+
cellRange(box) {
|
|
2814
|
+
const screen = this.term.element?.querySelector(".xterm-screen")?.getBoundingClientRect();
|
|
2815
|
+
const { cellW, cellH } = this.cell();
|
|
2816
|
+
const top = this.term.buffer.active.viewportY;
|
|
2817
|
+
const at = (x, y) => ({
|
|
2818
|
+
x: Math.floor((x - (screen?.left ?? 0)) / cellW) + 1,
|
|
2819
|
+
y: top + Math.floor((y - (screen?.top ?? 0)) / cellH) + 1
|
|
2820
|
+
});
|
|
2821
|
+
return { start: at(box.left, box.top), end: at(box.right - 1, box.bottom - 1) };
|
|
2822
|
+
}
|
|
2823
|
+
/** Cell size in CSS pixels (private in xterm.js), with a measured fallback. */
|
|
2824
|
+
cell() {
|
|
2825
|
+
const dims = this.term._core?._renderService?.dimensions?.css?.cell;
|
|
2826
|
+
if (dims && dims.width > 0 && dims.height > 0) return { cellW: dims.width, cellH: dims.height };
|
|
2827
|
+
const screen = this.term.element?.querySelector(".xterm-screen");
|
|
2828
|
+
if (screen && this.term.cols > 0) return { cellW: screen.clientWidth / this.term.cols, cellH: screen.clientHeight / this.term.rows };
|
|
2829
|
+
return { cellW: 9, cellH: 17 };
|
|
2830
|
+
}
|
|
2831
|
+
hostCss() {
|
|
2832
|
+
const { cellW, cellH } = this.cell();
|
|
2833
|
+
const o = this.term.options;
|
|
2834
|
+
this.css = hostCss(o.theme, { cellW, cellH, fontFamily: o.fontFamily ?? "monospace", fontSize: o.fontSize ?? 15 });
|
|
2835
|
+
return this.css;
|
|
2836
|
+
}
|
|
2837
|
+
scheme() {
|
|
2838
|
+
return palette(this.term.options.theme).dark ? "dark" : "light";
|
|
2839
|
+
}
|
|
2840
|
+
/** On every render: did the cell size, font or theme change? */
|
|
2841
|
+
checkMetrics() {
|
|
2842
|
+
const { cellW, cellH } = this.cell();
|
|
2843
|
+
const o = this.term.options;
|
|
2844
|
+
const key = `${cellW}x${cellH}|${o.fontFamily}|${o.fontSize}|${JSON.stringify(o.theme ?? {})}`;
|
|
2845
|
+
if (key === this.metrics.key) return;
|
|
2846
|
+
const sizeChanged = this.metrics.cellW !== 0 && (cellW !== this.metrics.cellW || cellH !== this.metrics.cellH);
|
|
2847
|
+
this.metrics = { cellW, cellH, key };
|
|
2848
|
+
if (this.surfaces.size === 0) return;
|
|
2849
|
+
const css = this.hostCss();
|
|
2850
|
+
const scheme = this.scheme();
|
|
2851
|
+
for (const s of this.surfaces.values()) s.setHostCss(css, scheme);
|
|
2852
|
+
this.reposition();
|
|
2853
|
+
if (sizeChanged) {
|
|
2854
|
+
if (this.resizeTimer) clearTimeout(this.resizeTimer);
|
|
2855
|
+
this.resizeTimer = setTimeout(() => {
|
|
2856
|
+
for (const p of this.placements.values()) {
|
|
2857
|
+
const s = p.surface;
|
|
2858
|
+
if (s.detached) continue;
|
|
2859
|
+
this.host().event(s.name, "resize", "", { w: Math.round(s.cols * cellW), h: s.rows * cellH });
|
|
2860
|
+
}
|
|
2861
|
+
}, 100);
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
};
|
|
2865
|
+
function sequence(chain, f) {
|
|
2866
|
+
if (chain) return chain.then(f);
|
|
2867
|
+
const r = f();
|
|
2868
|
+
return r instanceof Promise ? r : void 0;
|
|
2869
|
+
}
|
|
2870
|
+
function openAfterConfirm(uri) {
|
|
2871
|
+
if (!confirm(`Do you want to navigate to ${uri}?
|
|
2872
|
+
|
|
2873
|
+
WARNING: This link could potentially be dangerous`)) return;
|
|
2874
|
+
const w = window.open();
|
|
2875
|
+
if (!w) {
|
|
2876
|
+
console.warn("Opening link blocked as opener could not be cleared");
|
|
2877
|
+
return;
|
|
2878
|
+
}
|
|
2879
|
+
try {
|
|
2880
|
+
w.opener = null;
|
|
2881
|
+
} catch {
|
|
2882
|
+
}
|
|
2883
|
+
w.location.href = uri;
|
|
2884
|
+
}
|
|
2885
|
+
function legacyWheelDelta(ev, from) {
|
|
2886
|
+
if (!("wheelDeltaY" in WheelEvent.prototype)) return;
|
|
2887
|
+
const legacy = from;
|
|
2888
|
+
const real = from.isTrusted;
|
|
2889
|
+
const pixels = from.deltaMode === WheelEvent.DOM_DELTA_PIXEL ? 1 : from.deltaMode === WheelEvent.DOM_DELTA_LINE ? 20 : 400;
|
|
2890
|
+
const x = real && legacy.wheelDeltaX || -from.deltaX * pixels * (120 / 50);
|
|
2891
|
+
const y = real && legacy.wheelDeltaY || -from.deltaY * pixels * (120 / 50);
|
|
2892
|
+
Object.defineProperty(ev, "wheelDeltaX", { value: x });
|
|
2893
|
+
Object.defineProperty(ev, "wheelDeltaY", { value: y });
|
|
2894
|
+
}
|
|
2895
|
+
export {
|
|
2896
|
+
Control,
|
|
2897
|
+
EVENTS,
|
|
2898
|
+
HOST,
|
|
2899
|
+
HottyAddon,
|
|
2900
|
+
OSC,
|
|
2901
|
+
VERSION,
|
|
2902
|
+
browserKeys,
|
|
2903
|
+
decodeAll,
|
|
2904
|
+
encode
|
|
2905
|
+
};
|
|
2906
|
+
//# sourceMappingURL=hotty-xterm.js.map
|