@botiverse/porthole 0.1.2 → 0.1.4
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/dist/annotate/types.d.ts +120 -0
- package/dist/annotate/types.js +42 -0
- package/dist/annotate/validate.d.ts +11 -0
- package/dist/annotate/validate.js +156 -0
- package/dist/bridge/bridge.d.ts +4 -0
- package/dist/bridge/bridge.js +12 -1
- package/dist/compile/policy.d.ts +10 -3
- package/dist/compile/policy.js +16 -5
- package/dist/config/capabilities.d.ts +37 -4
- package/dist/config/capabilities.js +20 -0
- package/dist/config/schema.d.ts +11 -0
- package/dist/config/validate.js +12 -0
- package/dist/conformance/clipboard.d.ts +2 -0
- package/dist/conformance/clipboard.js +167 -0
- package/dist/conformance/kit.d.ts +1 -1
- package/dist/conformance/probes.js +2 -0
- package/dist/conformance/served.d.ts +8 -1
- package/dist/conformance/served.js +12 -8
- package/dist/guide/guide.js +11 -5
- package/dist/host/clipboard.d.ts +11 -0
- package/dist/host/clipboard.js +43 -0
- package/dist/host/create-host.js +165 -7
- package/dist/host/types.d.ts +68 -2
- package/dist/index.d.ts +8 -5
- package/dist/index.js +7 -3
- package/dist/mcp/adapter.js +10 -1
- package/dist/presets/presets.d.ts +3 -1
- package/dist/presets/presets.js +1 -0
- package/dist/protocol/mcp-apps.d.ts +5 -0
- package/dist/protocol/mcp-apps.js +5 -0
- package/dist/protocol/shell-adapter.js +6 -2
- package/dist/react/dialog.js +2 -0
- package/dist/react/frame.d.ts +50 -2
- package/dist/react/frame.js +153 -11
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.js +2 -1
- package/dist/react/toast.d.ts +33 -0
- package/dist/react/toast.js +42 -0
- package/dist/server/naming.d.ts +6 -0
- package/dist/server/naming.js +38 -0
- package/dist/server/pages.js +2 -0
- package/dist/server/server.js +49 -17
- package/dist/server/types.d.ts +6 -3
- package/dist/shell/annotate.d.ts +20 -0
- package/dist/shell/annotate.js +700 -0
- package/dist/shell/document.d.ts +6 -0
- package/dist/shell/document.js +12 -3
- package/dist/shell/plan.d.ts +2 -0
- package/dist/shell/plan.js +2 -1
- package/dist/shell/runtime.d.ts +4 -1
- package/dist/shell/runtime.js +71 -3
- package/package.json +1 -1
- package/src/annotate/types.ts +126 -0
- package/src/annotate/validate.ts +186 -0
- package/src/bridge/bridge.ts +13 -0
- package/src/compile/policy.ts +17 -5
- package/src/config/capabilities.ts +33 -3
- package/src/config/schema.ts +12 -0
- package/src/config/validate.ts +15 -0
- package/src/conformance/clipboard.ts +184 -0
- package/src/conformance/kit.ts +1 -1
- package/src/conformance/probes.ts +2 -0
- package/src/conformance/served.ts +24 -9
- package/src/guide/guide.ts +22 -5
- package/src/host/clipboard.ts +41 -0
- package/src/host/create-host.ts +181 -7
- package/src/host/types.ts +64 -2
- package/src/index.ts +24 -0
- package/src/mcp/adapter.ts +10 -1
- package/src/presets/presets.ts +4 -0
- package/src/protocol/mcp-apps.ts +6 -0
- package/src/protocol/shell-adapter.ts +11 -2
- package/src/react/dialog.tsx +2 -0
- package/src/react/frame.tsx +231 -13
- package/src/react/index.ts +9 -1
- package/src/react/toast.tsx +96 -0
- package/src/server/naming.ts +41 -0
- package/src/server/pages.ts +2 -0
- package/src/server/server.ts +61 -20
- package/src/server/types.ts +6 -3
- package/src/shell/annotate.ts +700 -0
- package/src/shell/document.ts +21 -3
- package/src/shell/plan.ts +4 -1
- package/src/shell/runtime.ts +71 -3
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell's annotation runtime (P053), spliced into the shell's runtime (`__ANNOTATE__`) when
|
|
3
|
+
* the config declares `annotate`, so it runs before any content does, with the DOM's own
|
|
4
|
+
* functions as they were. Plain ES2020 like the rest of the shell; no template literals (it sits
|
|
5
|
+
* in one).
|
|
6
|
+
*
|
|
7
|
+
* The host drives it (`porthole/annotate.start|stop|show|focus|clear`) and it reports
|
|
8
|
+
* (`porthole/annotate.selected|unselected|cancelled|anchored`):
|
|
9
|
+
* - picking an element: the element under the pointer (or finger) is outlined with its name; a
|
|
10
|
+
* click or tap picks it, and the content's own handlers never see that click; Escape cancels;
|
|
11
|
+
* - selecting text: each selection that settles is reported, with where it is, and its clearing;
|
|
12
|
+
* - marks: each anchor is found in the content as it is now (its CSS selector, its text where it
|
|
13
|
+
* was, its text anywhere, its words with any spacing) and drawn over it, in a layer of the
|
|
14
|
+
* shell's own (a closed shadow root), again whenever the content changes; what can't be found
|
|
15
|
+
* is reported `orphaned`.
|
|
16
|
+
*
|
|
17
|
+
* It shares the realm with the content, which can interfere with all of it: the host takes its
|
|
18
|
+
* reports as UI hints only.
|
|
19
|
+
*/
|
|
20
|
+
export const ANNOTATE_RUNTIME = String.raw`if (declared("annotate")) {
|
|
21
|
+
(() => {
|
|
22
|
+
const LIMIT = { exact: 4000, quote: 300, context: 32, label: 120, css: 1000, marks: 500, boxes: 200 };
|
|
23
|
+
const M = {
|
|
24
|
+
start: "porthole/annotate.start",
|
|
25
|
+
stop: "porthole/annotate.stop",
|
|
26
|
+
show: "porthole/annotate.show",
|
|
27
|
+
focus: "porthole/annotate.focus",
|
|
28
|
+
clear: "porthole/annotate.clear",
|
|
29
|
+
selected: "porthole/annotate.selected",
|
|
30
|
+
unselected: "porthole/annotate.unselected",
|
|
31
|
+
cancelled: "porthole/annotate.cancelled",
|
|
32
|
+
anchored: "porthole/annotate.anchored",
|
|
33
|
+
};
|
|
34
|
+
// The DOM's own functions, taken before content runs.
|
|
35
|
+
const elementFromPoint = Document.prototype.elementFromPoint;
|
|
36
|
+
const querySelector = Document.prototype.querySelector;
|
|
37
|
+
const createRange = Document.prototype.createRange;
|
|
38
|
+
const createTreeWalker = Document.prototype.createTreeWalker;
|
|
39
|
+
const createElement = Document.prototype.createElement;
|
|
40
|
+
const selectionOf = Document.prototype.getSelection;
|
|
41
|
+
const elementRect = Element.prototype.getBoundingClientRect;
|
|
42
|
+
const rangeRect = Range.prototype.getBoundingClientRect;
|
|
43
|
+
const rangeRects = Range.prototype.getClientRects;
|
|
44
|
+
const attachShadow = Element.prototype.attachShadow;
|
|
45
|
+
const post = window.parent.postMessage.bind(window.parent);
|
|
46
|
+
const tell = (method, params) => {
|
|
47
|
+
if (window.parent !== window) {
|
|
48
|
+
post({ jsonrpc: "2.0", method, params }, "*");
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const squash = (text) => String(text).replace(/\s+/g, " ").trim();
|
|
52
|
+
const box = (rect) => ({ x: rect.left, y: rect.top, width: rect.width, height: rect.height });
|
|
53
|
+
|
|
54
|
+
// The document's text: the body's text nodes in order, without scripts, styles, templates.
|
|
55
|
+
const SKIPPED = { script: 1, style: 1, noscript: 1, template: 1 };
|
|
56
|
+
const textIndex = () => {
|
|
57
|
+
const nodes = [];
|
|
58
|
+
const starts = [];
|
|
59
|
+
const at = new Map();
|
|
60
|
+
let text = "";
|
|
61
|
+
const body = document.body;
|
|
62
|
+
if (body) {
|
|
63
|
+
const walker = createTreeWalker.call(document, body, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, {
|
|
64
|
+
acceptNode: (node) => (node.nodeType === 1 ? (SKIPPED[node.localName] ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_SKIP) : NodeFilter.FILTER_ACCEPT),
|
|
65
|
+
});
|
|
66
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
67
|
+
at.set(node, nodes.length);
|
|
68
|
+
nodes.push(node);
|
|
69
|
+
starts.push(text.length);
|
|
70
|
+
text += node.nodeValue;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { nodes, starts, at, text };
|
|
74
|
+
};
|
|
75
|
+
// A boundary point as an offset into the text: in a text node, where it is; elsewhere, the
|
|
76
|
+
// first text at or after it.
|
|
77
|
+
const offsetOf = (index, container, offset) => {
|
|
78
|
+
if (container.nodeType === 3 && index.at.has(container)) {
|
|
79
|
+
return index.starts[index.at.get(container)] + Math.min(offset, container.nodeValue.length);
|
|
80
|
+
}
|
|
81
|
+
const point = createRange.call(document);
|
|
82
|
+
try {
|
|
83
|
+
point.setStart(container, offset);
|
|
84
|
+
} catch (_) {
|
|
85
|
+
return index.text.length;
|
|
86
|
+
}
|
|
87
|
+
point.collapse(true);
|
|
88
|
+
let low = 0;
|
|
89
|
+
let high = index.nodes.length;
|
|
90
|
+
while (low < high) {
|
|
91
|
+
const middle = (low + high) >> 1;
|
|
92
|
+
if (point.comparePoint(index.nodes[middle], 0) >= 0) {
|
|
93
|
+
high = middle;
|
|
94
|
+
} else {
|
|
95
|
+
low = middle + 1;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return low < index.nodes.length ? index.starts[low] : index.text.length;
|
|
99
|
+
};
|
|
100
|
+
const pointAt = (index, at, end) => {
|
|
101
|
+
let low = 0;
|
|
102
|
+
let high = index.nodes.length - 1;
|
|
103
|
+
let found = -1;
|
|
104
|
+
while (low <= high) {
|
|
105
|
+
const middle = (low + high) >> 1;
|
|
106
|
+
if (index.starts[middle] <= at) {
|
|
107
|
+
found = middle;
|
|
108
|
+
low = middle + 1;
|
|
109
|
+
} else {
|
|
110
|
+
high = middle - 1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (found === -1) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
let node = index.nodes[found];
|
|
117
|
+
let offset = at - index.starts[found];
|
|
118
|
+
if (end) {
|
|
119
|
+
// An end at a node's very start belongs to the text before it.
|
|
120
|
+
while (offset === 0 && found > 0) {
|
|
121
|
+
found -= 1;
|
|
122
|
+
node = index.nodes[found];
|
|
123
|
+
offset = node.nodeValue.length;
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
while (offset >= node.nodeValue.length && found + 1 < index.nodes.length) {
|
|
127
|
+
found += 1;
|
|
128
|
+
node = index.nodes[found];
|
|
129
|
+
offset = 0;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { node, offset: Math.min(offset, node.nodeValue.length) };
|
|
133
|
+
};
|
|
134
|
+
const rangeAt = (index, start, end) => {
|
|
135
|
+
const from = pointAt(index, start, false);
|
|
136
|
+
const to = pointAt(index, end, true);
|
|
137
|
+
if (from === null || to === null) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const range = createRange.call(document);
|
|
141
|
+
try {
|
|
142
|
+
range.setStart(from.node, from.offset);
|
|
143
|
+
range.setEnd(to.node, to.offset);
|
|
144
|
+
} catch (_) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return range;
|
|
148
|
+
};
|
|
149
|
+
const spanOf = (index, node) => {
|
|
150
|
+
const range = createRange.call(document);
|
|
151
|
+
range.selectNodeContents(node);
|
|
152
|
+
return [offsetOf(index, range.startContainer, range.startOffset), offsetOf(index, range.endContainer, range.endOffset)];
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// A CSS selector that matches this element and no other: from the nearest id, else the body.
|
|
156
|
+
const escape = typeof CSS === "object" && CSS !== null && typeof CSS.escape === "function" ? CSS.escape : (value) => String(value).replace(/[^a-zA-Z0-9_-]/g, (each) => "\\" + each);
|
|
157
|
+
const only = (selector, element) => {
|
|
158
|
+
try {
|
|
159
|
+
return querySelector.call(document, selector) === element;
|
|
160
|
+
} catch (_) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const cssPath = (element) => {
|
|
165
|
+
const steps = [];
|
|
166
|
+
for (let node = element; node !== null && node.nodeType === 1 && steps.length < 16; node = node.parentElement) {
|
|
167
|
+
if (node.id && only("#" + escape(node.id), node)) {
|
|
168
|
+
steps.unshift("#" + escape(node.id));
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
if (node.localName === "body" || node.localName === "html") {
|
|
172
|
+
steps.unshift(node.localName);
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
let step = escape(node.localName);
|
|
176
|
+
const parent = node.parentElement;
|
|
177
|
+
if (parent !== null) {
|
|
178
|
+
const same = Array.from(parent.children).filter((child) => child.localName === node.localName);
|
|
179
|
+
if (same.length > 1) {
|
|
180
|
+
step += ":nth-of-type(" + (same.indexOf(node) + 1) + ")";
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
steps.unshift(step);
|
|
184
|
+
}
|
|
185
|
+
const selector = steps.join(" > ");
|
|
186
|
+
return selector.length <= LIMIT.css && only(selector, element) ? selector : null;
|
|
187
|
+
};
|
|
188
|
+
const describe = (element) => {
|
|
189
|
+
let name = element.localName + (element.id ? "#" + element.id : "");
|
|
190
|
+
const classes = typeof element.className === "string" ? element.className.trim().split(/\s+/).filter(Boolean).slice(0, 2) : [];
|
|
191
|
+
for (const each of classes) {
|
|
192
|
+
name += "." + each;
|
|
193
|
+
}
|
|
194
|
+
const words = squash(element.textContent || "").slice(0, 40);
|
|
195
|
+
return (words ? name + " " + words : name).slice(0, LIMIT.label);
|
|
196
|
+
};
|
|
197
|
+
const quoteOf = (index, start, end) => {
|
|
198
|
+
const quote = { type: "TextQuoteSelector", exact: index.text.slice(start, end) };
|
|
199
|
+
const prefix = index.text.slice(Math.max(0, start - LIMIT.context), start);
|
|
200
|
+
const suffix = index.text.slice(end, end + LIMIT.context);
|
|
201
|
+
if (prefix) {
|
|
202
|
+
quote.prefix = prefix;
|
|
203
|
+
}
|
|
204
|
+
if (suffix) {
|
|
205
|
+
quote.suffix = suffix;
|
|
206
|
+
}
|
|
207
|
+
return quote;
|
|
208
|
+
};
|
|
209
|
+
const elementAnchor = (element, index) => {
|
|
210
|
+
const selectors = [];
|
|
211
|
+
const css = cssPath(element);
|
|
212
|
+
if (css !== null) {
|
|
213
|
+
selectors.push({ type: "CssSelector", value: css });
|
|
214
|
+
}
|
|
215
|
+
const span = spanOf(index, element);
|
|
216
|
+
const end = Math.min(span[1], span[0] + LIMIT.quote);
|
|
217
|
+
if (index.text.slice(span[0], end).trim() !== "") {
|
|
218
|
+
selectors.push(quoteOf(index, span[0], end), { type: "TextPositionSelector", start: span[0], end });
|
|
219
|
+
}
|
|
220
|
+
return selectors.length === 0 ? null : { kind: "element", selectors, label: describe(element) };
|
|
221
|
+
};
|
|
222
|
+
const textAnchor = (range, index) => {
|
|
223
|
+
const start = offsetOf(index, range.startContainer, range.startOffset);
|
|
224
|
+
const end = Math.min(offsetOf(index, range.endContainer, range.endOffset), start + LIMIT.exact);
|
|
225
|
+
const exact = index.text.slice(start, end);
|
|
226
|
+
if (exact.trim() === "") {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const selectors = [quoteOf(index, start, end), { type: "TextPositionSelector", start, end }];
|
|
230
|
+
let container = range.commonAncestorContainer;
|
|
231
|
+
if (container.nodeType !== 1) {
|
|
232
|
+
container = container.parentElement;
|
|
233
|
+
}
|
|
234
|
+
const css = container !== null && container !== document.body ? cssPath(container) : null;
|
|
235
|
+
if (css !== null) {
|
|
236
|
+
selectors.push({ type: "CssSelector", value: css });
|
|
237
|
+
}
|
|
238
|
+
return { kind: "text", selectors, label: squash(exact).slice(0, LIMIT.label) };
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// Finding an anchor again, best-effort: where it was, its words anywhere (the occurrence
|
|
242
|
+
// whose surroundings match best), then its words with any spacing.
|
|
243
|
+
const shared = (a, b, fromEnd) => {
|
|
244
|
+
let n = 0;
|
|
245
|
+
while (n < a.length && n < b.length && (fromEnd ? a.charAt(a.length - 1 - n) === b.charAt(b.length - 1 - n) : a.charAt(n) === b.charAt(n))) {
|
|
246
|
+
n += 1;
|
|
247
|
+
}
|
|
248
|
+
return n;
|
|
249
|
+
};
|
|
250
|
+
const locate = (index, quote, position) => {
|
|
251
|
+
const text = index.text;
|
|
252
|
+
const length = quote.exact.length;
|
|
253
|
+
if (position && text.slice(position.start, position.start + length) === quote.exact) {
|
|
254
|
+
return [position.start, position.start + length];
|
|
255
|
+
}
|
|
256
|
+
let best = null;
|
|
257
|
+
let bestScore = -1;
|
|
258
|
+
let seen = 0;
|
|
259
|
+
for (let at = text.indexOf(quote.exact); at !== -1 && seen < 1000; at = text.indexOf(quote.exact, at + 1)) {
|
|
260
|
+
seen += 1;
|
|
261
|
+
const score =
|
|
262
|
+
shared(text.slice(Math.max(0, at - LIMIT.context), at), quote.prefix || "", true) +
|
|
263
|
+
shared(text.slice(at + length, at + length + LIMIT.context), quote.suffix || "", false) -
|
|
264
|
+
(position ? Math.min(1, Math.abs(at - position.start) / 1e6) : 0);
|
|
265
|
+
if (score > bestScore) {
|
|
266
|
+
bestScore = score;
|
|
267
|
+
best = [at, at + length];
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (best !== null) {
|
|
271
|
+
return best;
|
|
272
|
+
}
|
|
273
|
+
const wanted = squash(quote.exact);
|
|
274
|
+
if (wanted === "") {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
const map = [];
|
|
278
|
+
let loose = "";
|
|
279
|
+
let space = false;
|
|
280
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
281
|
+
const each = text.charAt(i);
|
|
282
|
+
if (/\s/.test(each)) {
|
|
283
|
+
if (!space && loose !== "") {
|
|
284
|
+
loose += " ";
|
|
285
|
+
map.push(i);
|
|
286
|
+
}
|
|
287
|
+
space = true;
|
|
288
|
+
} else {
|
|
289
|
+
loose += each;
|
|
290
|
+
map.push(i);
|
|
291
|
+
space = false;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const at = loose.indexOf(wanted);
|
|
295
|
+
return at === -1 ? null : [map[at], map[at + wanted.length - 1] + 1];
|
|
296
|
+
};
|
|
297
|
+
const find = (anchor, index) => {
|
|
298
|
+
const of = (type) => anchor.selectors.filter((each) => each && each.type === type)[0];
|
|
299
|
+
const css = of("CssSelector");
|
|
300
|
+
const quote = of("TextQuoteSelector");
|
|
301
|
+
const position = of("TextPositionSelector");
|
|
302
|
+
if (anchor.kind === "text") {
|
|
303
|
+
const span = quote ? locate(index, quote, position) : null;
|
|
304
|
+
const range = span === null ? null : rangeAt(index, span[0], span[1]);
|
|
305
|
+
return range === null ? null : { range };
|
|
306
|
+
}
|
|
307
|
+
let element = null;
|
|
308
|
+
if (css && typeof css.value === "string") {
|
|
309
|
+
try {
|
|
310
|
+
element = querySelector.call(document, css.value);
|
|
311
|
+
} catch (_) {
|
|
312
|
+
element = null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (element !== null && quote) {
|
|
316
|
+
const span = spanOf(index, element);
|
|
317
|
+
if (index.text.slice(span[0], span[1]).indexOf(quote.exact) === 0) {
|
|
318
|
+
return { element };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// Without words to check, or by an id, the selector is enough.
|
|
322
|
+
if (element !== null && (!quote || css.value.charAt(0) === "#")) {
|
|
323
|
+
return { element };
|
|
324
|
+
}
|
|
325
|
+
const span = quote ? locate(index, quote, position) : null;
|
|
326
|
+
const range = span === null ? null : rangeAt(index, span[0], span[1]);
|
|
327
|
+
if (range !== null) {
|
|
328
|
+
let node = range.commonAncestorContainer;
|
|
329
|
+
if (node.nodeType !== 1) {
|
|
330
|
+
node = node.parentElement;
|
|
331
|
+
}
|
|
332
|
+
if (node !== null) {
|
|
333
|
+
return { element: node };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
};
|
|
338
|
+
const rectOf = (found) => (found.range ? rangeRect.call(found.range) : elementRect.call(found.element));
|
|
339
|
+
|
|
340
|
+
// The shell's own layer, over the content: marks, the outline of what would be picked.
|
|
341
|
+
let layer = null;
|
|
342
|
+
let shadow = null;
|
|
343
|
+
const STYLE = ".mark{position:absolute;border-radius:2px;background:rgba(250,204,21,.3);box-shadow:inset 0 -2px 0 rgba(202,138,4,.8)}.mark.element{background:rgba(250,204,21,.12);box-shadow:none;outline:2px solid rgba(202,138,4,.85);outline-offset:1px}.mark.active{background:rgba(250,204,21,.5)}.mark.element.active{background:rgba(250,204,21,.22);outline-width:3px}.mark.flash{animation:flash 1.8s ease-out}@keyframes flash{from{background:rgba(250,204,21,.9)}}.hover{position:absolute;outline:2px solid #2563eb;outline-offset:1px;background:rgba(37,99,235,.12)}.tag{position:absolute;max-width:70vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:12px/18px ui-monospace,SFMono-Regular,Menlo,monospace;padding:0 6px;border-radius:4px;background:#1e293b;color:#fff}";
|
|
344
|
+
const ensureLayer = () => {
|
|
345
|
+
if (layer === null) {
|
|
346
|
+
layer = createElement.call(document, "div");
|
|
347
|
+
layer.setAttribute("aria-hidden", "true");
|
|
348
|
+
layer.setAttribute("data-porthole-annotations", "");
|
|
349
|
+
// Inline and important: the content's own styles can't hide or move it by accident.
|
|
350
|
+
layer.style.cssText = "position:fixed!important;inset:0!important;display:block!important;visibility:visible!important;opacity:1!important;pointer-events:none!important;z-index:2147483647!important;overflow:hidden!important;margin:0!important;padding:0!important;border:0!important;background:none!important;transform:none!important";
|
|
351
|
+
shadow = attachShadow.call(layer, { mode: "closed" });
|
|
352
|
+
}
|
|
353
|
+
if (!layer.isConnected) {
|
|
354
|
+
document.documentElement.appendChild(layer);
|
|
355
|
+
}
|
|
356
|
+
return shadow;
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
let mode = { elements: false, text: false };
|
|
360
|
+
let hover = null;
|
|
361
|
+
let marks = [];
|
|
362
|
+
let active = null;
|
|
363
|
+
let flashing = null;
|
|
364
|
+
let flashUntil = 0;
|
|
365
|
+
const draw = () => {
|
|
366
|
+
if (marks.length === 0 && hover === null && layer === null) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const root = ensureLayer();
|
|
370
|
+
const parts = [];
|
|
371
|
+
const style = createElement.call(document, "style");
|
|
372
|
+
if (nonce !== "") {
|
|
373
|
+
style.nonce = nonce;
|
|
374
|
+
}
|
|
375
|
+
style.textContent = STYLE;
|
|
376
|
+
parts.push(style);
|
|
377
|
+
const flashOn = Date.now() < flashUntil;
|
|
378
|
+
for (const mark of marks) {
|
|
379
|
+
if (mark.found === null) {
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const rects = mark.found.range ? Array.from(rangeRects.call(mark.found.range)) : [elementRect.call(mark.found.element)];
|
|
383
|
+
for (const rect of rects.slice(0, LIMIT.boxes)) {
|
|
384
|
+
if (rect.width === 0 && rect.height === 0) {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const each = createElement.call(document, "div");
|
|
388
|
+
each.className = "mark " + (mark.found.range ? "text" : "element") + (mark.id === active ? " active" : "") + (flashOn && mark.id === flashing ? " flash" : "");
|
|
389
|
+
each.style.cssText = "left:" + rect.left + "px;top:" + rect.top + "px;width:" + rect.width + "px;height:" + rect.height + "px";
|
|
390
|
+
parts.push(each);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (hover !== null && hover.isConnected) {
|
|
394
|
+
const rect = elementRect.call(hover);
|
|
395
|
+
const outline = createElement.call(document, "div");
|
|
396
|
+
outline.className = "hover";
|
|
397
|
+
outline.style.cssText = "left:" + rect.left + "px;top:" + rect.top + "px;width:" + rect.width + "px;height:" + rect.height + "px";
|
|
398
|
+
const tag = createElement.call(document, "div");
|
|
399
|
+
tag.className = "tag";
|
|
400
|
+
tag.textContent = describe(hover).slice(0, 80);
|
|
401
|
+
tag.style.cssText = "left:" + Math.max(4, rect.left) + "px;top:" + (rect.top >= 24 ? rect.top - 22 : Math.max(4, rect.bottom + 4)) + "px";
|
|
402
|
+
parts.push(outline, tag);
|
|
403
|
+
}
|
|
404
|
+
root.replaceChildren(...parts);
|
|
405
|
+
};
|
|
406
|
+
let drawing = false;
|
|
407
|
+
const redraw = () => {
|
|
408
|
+
if (!drawing) {
|
|
409
|
+
drawing = true;
|
|
410
|
+
requestAnimationFrame(() => {
|
|
411
|
+
drawing = false;
|
|
412
|
+
draw();
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const resultOf = (mark) => (mark.found === null ? { id: mark.id, state: "orphaned" } : { id: mark.id, state: "attached", rect: box(rectOf(mark.found)) });
|
|
418
|
+
// Finds every mark again; tells the host every result (after show) or only when one changed.
|
|
419
|
+
const anchorAll = (all) => {
|
|
420
|
+
const index = textIndex();
|
|
421
|
+
let changed = false;
|
|
422
|
+
for (const mark of marks) {
|
|
423
|
+
let found = null;
|
|
424
|
+
try {
|
|
425
|
+
found = find(mark.anchor, index);
|
|
426
|
+
} catch (_) {
|
|
427
|
+
found = null;
|
|
428
|
+
}
|
|
429
|
+
const state = found === null ? "orphaned" : "attached";
|
|
430
|
+
if (state !== mark.state) {
|
|
431
|
+
changed = true;
|
|
432
|
+
}
|
|
433
|
+
mark.found = found;
|
|
434
|
+
mark.state = state;
|
|
435
|
+
}
|
|
436
|
+
if (all || changed) {
|
|
437
|
+
tell(M.anchored, { results: marks.map(resultOf) });
|
|
438
|
+
}
|
|
439
|
+
draw();
|
|
440
|
+
};
|
|
441
|
+
// Once the content has been quiet a moment (it may still be drawing), at most a second late.
|
|
442
|
+
let anchorTimer = 0;
|
|
443
|
+
let deadline = 0;
|
|
444
|
+
let reportAll = false;
|
|
445
|
+
// Nothing is reported before the content is in place (an empty shell would orphan every
|
|
446
|
+
// mark), nor before a run of changes settles; a focus asked for meanwhile waits for it.
|
|
447
|
+
let waitingForContent = false;
|
|
448
|
+
let pendingFocus = null;
|
|
449
|
+
const settling = () => anchorTimer !== 0 || waitingForContent || marks.some((mark) => mark.state === null);
|
|
450
|
+
const scheduleAnchor = (all) => {
|
|
451
|
+
if (all) {
|
|
452
|
+
reportAll = true;
|
|
453
|
+
}
|
|
454
|
+
if (!contentIsIn()) {
|
|
455
|
+
if (!waitingForContent) {
|
|
456
|
+
waitingForContent = true;
|
|
457
|
+
whenContentIsIn(() => {
|
|
458
|
+
waitingForContent = false;
|
|
459
|
+
scheduleAnchor(false);
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const now = Date.now();
|
|
465
|
+
if (deadline === 0) {
|
|
466
|
+
deadline = now + 1000;
|
|
467
|
+
}
|
|
468
|
+
clearTimeout(anchorTimer);
|
|
469
|
+
anchorTimer = setTimeout(() => {
|
|
470
|
+
anchorTimer = 0;
|
|
471
|
+
deadline = 0;
|
|
472
|
+
const every = reportAll;
|
|
473
|
+
reportAll = false;
|
|
474
|
+
anchorAll(every);
|
|
475
|
+
if (pendingFocus !== null) {
|
|
476
|
+
const id = pendingFocus;
|
|
477
|
+
pendingFocus = null;
|
|
478
|
+
focus(id);
|
|
479
|
+
}
|
|
480
|
+
}, Math.max(0, Math.min(150, deadline - now)));
|
|
481
|
+
};
|
|
482
|
+
new MutationObserver((records) => {
|
|
483
|
+
if (marks.length === 0) {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
for (const record of records) {
|
|
487
|
+
if (record.target === layer || (record.addedNodes.length + record.removedNodes.length === 1 && (record.addedNodes[0] === layer || record.removedNodes[0] === layer))) {
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
scheduleAnchor(false);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
}).observe(document, { childList: true, subtree: true, characterData: true });
|
|
494
|
+
window.addEventListener("resize", redraw);
|
|
495
|
+
window.addEventListener("scroll", redraw, true);
|
|
496
|
+
|
|
497
|
+
const focus = (id) => {
|
|
498
|
+
const mark = marks.filter((each) => each.id === id)[0];
|
|
499
|
+
if (!mark) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (settling()) {
|
|
503
|
+
// Where it is isn't known yet: focus it once anchoring has settled, then say where.
|
|
504
|
+
pendingFocus = id;
|
|
505
|
+
active = id;
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
active = id;
|
|
509
|
+
if (mark.found !== null) {
|
|
510
|
+
const rect = rectOf(mark.found);
|
|
511
|
+
if (rect.top < 0 || rect.bottom > window.innerHeight) {
|
|
512
|
+
window.scrollBy(0, rect.top - (window.innerHeight - Math.min(rect.height, window.innerHeight)) / 2);
|
|
513
|
+
}
|
|
514
|
+
flashing = id;
|
|
515
|
+
flashUntil = Date.now() + 1800;
|
|
516
|
+
setTimeout(redraw, 1850);
|
|
517
|
+
}
|
|
518
|
+
draw();
|
|
519
|
+
tell(M.anchored, { results: [resultOf(mark)] });
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
// Picking an element: what is under the pointer, never the page itself or the shell's layer.
|
|
523
|
+
let cursor = null;
|
|
524
|
+
const setCursor = (on) => {
|
|
525
|
+
if (typeof CSSStyleSheet !== "function" || !Array.isArray(document.adoptedStyleSheets)) {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (cursor === null) {
|
|
529
|
+
cursor = new CSSStyleSheet();
|
|
530
|
+
cursor.replaceSync("*, *::before, *::after { cursor: crosshair !important; }");
|
|
531
|
+
}
|
|
532
|
+
const others = document.adoptedStyleSheets.filter((sheet) => sheet !== cursor);
|
|
533
|
+
document.adoptedStyleSheets = on ? others.concat([cursor]) : others;
|
|
534
|
+
};
|
|
535
|
+
const pickable = (element) => (element && element.nodeType === 1 && element !== document.documentElement && element !== document.body && element !== layer ? element : null);
|
|
536
|
+
const under = (x, y) => pickable(elementFromPoint.call(document, x, y));
|
|
537
|
+
const stop = () => {
|
|
538
|
+
mode = { elements: false, text: false };
|
|
539
|
+
hover = null;
|
|
540
|
+
reported = null;
|
|
541
|
+
setCursor(false);
|
|
542
|
+
draw();
|
|
543
|
+
};
|
|
544
|
+
// The content's own handlers never see the pointer while an element is being picked; a
|
|
545
|
+
// finger outlines what it is on as it goes down.
|
|
546
|
+
let pressed = null;
|
|
547
|
+
const swallow = (event) => {
|
|
548
|
+
if (mode.elements) {
|
|
549
|
+
if (event.type === "pointerdown") {
|
|
550
|
+
pressed = { x: event.clientX, y: event.clientY };
|
|
551
|
+
hover = under(event.clientX, event.clientY);
|
|
552
|
+
redraw();
|
|
553
|
+
}
|
|
554
|
+
event.stopImmediatePropagation();
|
|
555
|
+
if (!mode.text && (event.type === "mousedown" || event.type === "mouseup")) {
|
|
556
|
+
event.preventDefault();
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
for (const type of ["pointerdown", "pointerup", "mousedown", "mouseup", "dblclick", "auxclick", "touchstart", "touchend"]) {
|
|
561
|
+
window.addEventListener(type, swallow, true);
|
|
562
|
+
}
|
|
563
|
+
window.addEventListener("pointermove", (event) => {
|
|
564
|
+
if (mode.elements) {
|
|
565
|
+
hover = under(event.clientX, event.clientY);
|
|
566
|
+
redraw();
|
|
567
|
+
}
|
|
568
|
+
}, true);
|
|
569
|
+
window.addEventListener("click", (event) => {
|
|
570
|
+
if (!mode.elements) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
event.preventDefault();
|
|
574
|
+
event.stopImmediatePropagation();
|
|
575
|
+
// A drag that selected text: the selection is what was meant. A press and release in
|
|
576
|
+
// one place is a pick.
|
|
577
|
+
const selection = selectionOf.call(document);
|
|
578
|
+
const dragged = pressed !== null && event.detail !== 0 && Math.abs(event.clientX - pressed.x) + Math.abs(event.clientY - pressed.y) > 4;
|
|
579
|
+
if (mode.text && dragged && selection !== null && !selection.isCollapsed) {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const element = event.detail === 0 ? pickable(event.target) : under(event.clientX, event.clientY);
|
|
583
|
+
if (element === null) {
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const anchor = elementAnchor(element, textIndex());
|
|
587
|
+
if (anchor === null) {
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
mode = { elements: false, text: mode.text };
|
|
591
|
+
hover = null;
|
|
592
|
+
setCursor(false);
|
|
593
|
+
draw();
|
|
594
|
+
tell(M.selected, { anchor, rect: box(elementRect.call(element)) });
|
|
595
|
+
}, true);
|
|
596
|
+
window.addEventListener("keydown", (event) => {
|
|
597
|
+
if (mode.elements && event.key === "Escape") {
|
|
598
|
+
event.preventDefault();
|
|
599
|
+
event.stopImmediatePropagation();
|
|
600
|
+
mode = { elements: false, text: mode.text };
|
|
601
|
+
hover = null;
|
|
602
|
+
setCursor(false);
|
|
603
|
+
draw();
|
|
604
|
+
tell(M.cancelled, {});
|
|
605
|
+
}
|
|
606
|
+
}, true);
|
|
607
|
+
|
|
608
|
+
// Selecting text: each selection that settles, and its clearing; again after a scroll.
|
|
609
|
+
let reported = null;
|
|
610
|
+
let selectionTimer = 0;
|
|
611
|
+
const reportSelection = (again) => {
|
|
612
|
+
if (!mode.text) {
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const selection = selectionOf.call(document);
|
|
616
|
+
if (selection === null || selection.isCollapsed || selection.rangeCount === 0) {
|
|
617
|
+
if (reported !== null) {
|
|
618
|
+
reported = null;
|
|
619
|
+
tell(M.unselected, {});
|
|
620
|
+
}
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const range = selection.getRangeAt(0);
|
|
624
|
+
if (!document.body || !document.body.contains(range.commonAncestorContainer)) {
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const anchor = textAnchor(range, textIndex());
|
|
628
|
+
if (anchor === null) {
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const key = anchor.selectors[1].start + ":" + anchor.selectors[1].end;
|
|
632
|
+
if (key === reported && !again) {
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
reported = key;
|
|
636
|
+
tell(M.selected, { anchor, rect: box(rangeRect.call(range)) });
|
|
637
|
+
};
|
|
638
|
+
document.addEventListener("selectionchange", () => {
|
|
639
|
+
if (mode.text) {
|
|
640
|
+
clearTimeout(selectionTimer);
|
|
641
|
+
selectionTimer = setTimeout(() => reportSelection(false), 250);
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
let scrollTimer = 0;
|
|
645
|
+
window.addEventListener("scroll", () => {
|
|
646
|
+
if (reported !== null) {
|
|
647
|
+
clearTimeout(scrollTimer);
|
|
648
|
+
scrollTimer = setTimeout(() => reportSelection(true), 150);
|
|
649
|
+
}
|
|
650
|
+
}, true);
|
|
651
|
+
|
|
652
|
+
window.addEventListener("message", (event) => {
|
|
653
|
+
if (event.source !== window.parent || plan.origins.indexOf(event.origin) === -1) {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const data = event.data;
|
|
657
|
+
if (data === null || typeof data !== "object" || data.jsonrpc !== "2.0" || typeof data.method !== "string") {
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const params = data.params !== null && typeof data.params === "object" ? data.params : {};
|
|
661
|
+
switch (data.method) {
|
|
662
|
+
case M.start:
|
|
663
|
+
mode = { elements: params.elements === true, text: params.text === true };
|
|
664
|
+
hover = null;
|
|
665
|
+
reported = null;
|
|
666
|
+
setCursor(mode.elements);
|
|
667
|
+
draw();
|
|
668
|
+
reportSelection(false);
|
|
669
|
+
break;
|
|
670
|
+
case M.stop:
|
|
671
|
+
stop();
|
|
672
|
+
break;
|
|
673
|
+
case M.show:
|
|
674
|
+
marks = (Array.isArray(params.marks) ? params.marks : [])
|
|
675
|
+
.slice(0, LIMIT.marks)
|
|
676
|
+
.filter((mark) => mark && typeof mark.id === "string" && mark.anchor && Array.isArray(mark.anchor.selectors))
|
|
677
|
+
.map((mark) => ({ id: mark.id, anchor: mark.anchor, found: null, state: null }));
|
|
678
|
+
if (typeof params.active === "string") {
|
|
679
|
+
active = params.active;
|
|
680
|
+
}
|
|
681
|
+
if (!marks.some((mark) => mark.id === active)) {
|
|
682
|
+
active = null;
|
|
683
|
+
}
|
|
684
|
+
scheduleAnchor(true);
|
|
685
|
+
break;
|
|
686
|
+
case M.focus:
|
|
687
|
+
focus(String(params.id));
|
|
688
|
+
break;
|
|
689
|
+
case M.clear:
|
|
690
|
+
pendingFocus = null;
|
|
691
|
+
marks = [];
|
|
692
|
+
active = null;
|
|
693
|
+
draw();
|
|
694
|
+
break;
|
|
695
|
+
default:
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
})();
|
|
700
|
+
}`;
|