@devaccel-labs/app-comments-toolbar 1.0.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +152 -18
- package/dist/react.js +516 -61
- package/dist/server.js +342 -14
- package/dist/types/capture/api.d.ts +30 -0
- package/dist/types/capture/editor.d.ts +10 -0
- package/dist/types/capture/ignore.d.ts +19 -0
- package/dist/types/capture/index.d.ts +1 -0
- package/dist/types/capture/picker.d.ts +9 -0
- package/dist/types/capture/picture.d.ts +29 -0
- package/dist/types/capture/surface.d.ts +39 -0
- package/dist/types/core/index.d.ts +2 -2
- package/dist/types/core/resolve.d.ts +2 -1
- package/dist/types/core/types.d.ts +11 -0
- package/dist/types/debug-context.d.ts +7 -0
- package/dist/types/dictation.d.ts +9 -0
- package/dist/types/embed/controller.d.ts +61 -0
- package/dist/types/embed/index.d.ts +1 -0
- package/dist/types/embed/options.d.ts +35 -0
- package/dist/types/help/index.d.ts +1 -0
- package/dist/types/help/panel-index.d.ts +1 -0
- package/dist/types/help/panel-styles.d.ts +1 -0
- package/dist/types/help/panel.d.ts +4 -0
- package/dist/types/help/pill-styles.d.ts +11 -0
- package/dist/types/help/pill.d.ts +44 -0
- package/dist/types/help/react.d.ts +12 -0
- package/dist/types/help/requests.d.ts +2 -0
- package/dist/types/help/types.d.ts +119 -0
- package/dist/types/help/wire.d.ts +61 -0
- package/dist/types/native-wire.d.ts +63 -0
- package/dist/types/native.d.ts +43 -0
- package/dist/types/react.d.ts +8 -1
- package/dist/types/replay.d.ts +6 -0
- package/dist/types/runtime/api.d.ts +250 -0
- package/dist/types/runtime/attachment-model.d.ts +16 -0
- package/dist/types/runtime/attachments.d.ts +43 -7
- package/dist/types/runtime/boot.d.ts +39 -0
- package/dist/types/runtime/browser-label.d.ts +5 -0
- package/dist/types/runtime/build-id.d.ts +7 -0
- package/dist/types/runtime/capture-ui.d.ts +45 -21
- package/dist/types/runtime/clarity-model.d.ts +260 -0
- package/dist/types/runtime/clarity-ui.d.ts +62 -0
- package/dist/types/runtime/client.d.ts +6 -197
- package/dist/types/runtime/composer-size.d.ts +30 -0
- package/dist/types/runtime/dashboard.d.ts +17 -2
- package/dist/types/runtime/debug-context-core.d.ts +88 -0
- package/dist/types/runtime/debug-context-host.d.ts +39 -0
- package/dist/types/runtime/debug-context-model.d.ts +352 -0
- package/dist/types/runtime/debug-context-redact.d.ts +48 -0
- package/dist/types/runtime/debug-context.d.ts +36 -0
- package/dist/types/runtime/details-model.d.ts +7 -0
- package/dist/types/runtime/details.d.ts +7 -5
- package/dist/types/runtime/dictation-engine.d.ts +98 -0
- package/dist/types/runtime/dictation-model.d.ts +28 -0
- package/dist/types/runtime/dictation-ui.d.ts +56 -0
- package/dist/types/runtime/dictation.d.ts +66 -0
- package/dist/types/runtime/draft-dock.d.ts +32 -0
- package/dist/types/runtime/draft-model.d.ts +104 -0
- package/dist/types/runtime/draft-store.d.ts +15 -0
- package/dist/types/runtime/edits.d.ts +1 -1
- package/dist/types/runtime/font.d.ts +16 -0
- package/dist/types/runtime/format.d.ts +30 -1
- package/dist/types/runtime/host-watch.d.ts +20 -0
- package/dist/types/runtime/icons.d.ts +16 -2
- package/dist/types/runtime/index.d.ts +3 -1
- package/dist/types/runtime/ink.d.ts +8 -0
- package/dist/types/runtime/kind-model.d.ts +14 -0
- package/dist/types/runtime/live-model.d.ts +25 -0
- package/dist/types/runtime/loader.d.ts +5 -11
- package/dist/types/runtime/markup-editor.d.ts +54 -0
- package/dist/types/runtime/markup-model.d.ts +126 -0
- package/dist/types/runtime/markup.d.ts +69 -0
- package/dist/types/runtime/mention-parse.d.ts +18 -0
- package/dist/types/runtime/mentions.d.ts +4 -4
- package/dist/types/runtime/page-background.d.ts +9 -0
- package/dist/types/runtime/page-image.d.ts +123 -0
- package/dist/types/runtime/placement.d.ts +6 -3
- package/dist/types/runtime/record.d.ts +18 -2
- package/dist/types/runtime/related.d.ts +1 -1
- package/dist/types/runtime/replay-model.d.ts +80 -0
- package/dist/types/runtime/replay.d.ts +5 -38
- package/dist/types/runtime/runtime.d.ts +6 -0
- package/dist/types/runtime/screen.d.ts +47 -0
- package/dist/types/runtime/screenshot.d.ts +60 -6
- package/dist/types/runtime/session-source.d.ts +45 -0
- package/dist/types/runtime/session.d.ts +20 -18
- package/dist/types/runtime/shield.d.ts +9 -0
- package/dist/types/runtime/shot.d.ts +45 -0
- package/dist/types/runtime/sketch.d.ts +26 -0
- package/dist/types/runtime/styles.d.ts +1 -1
- package/dist/types/runtime/sync.d.ts +1 -0
- package/dist/types/runtime/thread-model.d.ts +18 -0
- package/dist/types/runtime/ticket-form-model.d.ts +57 -0
- package/dist/types/runtime/ticket-form.d.ts +9 -0
- package/dist/types/runtime/toolbar.d.ts +37 -1
- package/dist/types/runtime/ui-scale.d.ts +17 -0
- package/dist/types/runtime/update.d.ts +37 -0
- package/dist/types/runtime/use-debug-context.d.ts +27 -0
- package/dist/types/server/index.d.ts +22 -0
- package/dist/types/server/support.d.ts +20 -0
- package/dist/types/server.d.ts +2 -0
- package/dist/types/vite/plugin.d.ts +15 -0
- package/dist/vite.js +121 -41
- package/package.json +26 -13
- package/src/capture/api.ts +128 -0
- package/src/capture/editor.tsx +178 -0
- package/src/capture/ignore.ts +38 -0
- package/src/capture/index.ts +27 -0
- package/src/capture/picker.tsx +171 -0
- package/src/capture/picture.ts +90 -0
- package/src/capture/surface.tsx +185 -0
- package/src/compiler/annotate.ts +202 -0
- package/src/compiler/babel.d.ts +11 -0
- package/src/compiler/enabled.ts +55 -0
- package/src/compiler/index.ts +2 -0
- package/src/core/index.ts +8 -0
- package/src/core/resolve.ts +307 -0
- package/src/core/types.ts +58 -0
- package/src/debug-context.ts +47 -0
- package/src/dictation.ts +17 -0
- package/src/embed/controller.ts +215 -0
- package/src/embed/index.ts +68 -0
- package/src/embed/options.ts +100 -0
- package/src/help/index.ts +38 -0
- package/src/help/panel-index.ts +17 -0
- package/src/help/panel-styles.ts +123 -0
- package/src/help/panel.ts +840 -0
- package/src/help/pill-styles.ts +75 -0
- package/src/help/pill.ts +338 -0
- package/src/help/react.tsx +143 -0
- package/src/help/requests.ts +111 -0
- package/src/help/types.ts +124 -0
- package/src/help/wire.ts +55 -0
- package/src/native-wire.ts +124 -0
- package/src/native.ts +282 -0
- package/src/react.ts +27 -0
- package/src/replay.ts +13 -0
- package/src/runtime/api.ts +531 -0
- package/src/runtime/attachment-model.ts +42 -0
- package/src/runtime/attachments.tsx +1260 -0
- package/src/runtime/boot.ts +95 -0
- package/src/runtime/browser-label.ts +31 -0
- package/src/runtime/build-id.ts +10 -0
- package/src/runtime/capture-ui.tsx +306 -0
- package/src/runtime/capture.ts +508 -0
- package/src/runtime/clarity-model.ts +634 -0
- package/src/runtime/clarity-ui.tsx +454 -0
- package/src/runtime/client.ts +151 -0
- package/src/runtime/composer-size.tsx +146 -0
- package/src/runtime/dashboard.tsx +1130 -0
- package/src/runtime/debug-context-core.ts +681 -0
- package/src/runtime/debug-context-host.ts +56 -0
- package/src/runtime/debug-context-model.ts +1319 -0
- package/src/runtime/debug-context-redact.ts +259 -0
- package/src/runtime/debug-context.ts +613 -0
- package/src/runtime/details-model.ts +30 -0
- package/src/runtime/details.tsx +395 -0
- package/src/runtime/dictation-engine.ts +616 -0
- package/src/runtime/dictation-model.ts +79 -0
- package/src/runtime/dictation-ui.tsx +363 -0
- package/src/runtime/dictation.ts +271 -0
- package/src/runtime/dock.ts +108 -0
- package/src/runtime/draft-dock.tsx +124 -0
- package/src/runtime/draft-model.ts +251 -0
- package/src/runtime/draft-store.ts +149 -0
- package/src/runtime/edits.tsx +165 -0
- package/src/runtime/font.ts +42 -0
- package/src/runtime/format.ts +129 -0
- package/src/runtime/freshness.ts +23 -0
- package/src/runtime/handoff.ts +76 -0
- package/src/runtime/host-watch.ts +64 -0
- package/src/runtime/icons.tsx +295 -0
- package/src/runtime/idle.ts +20 -0
- package/src/runtime/index.ts +11 -0
- package/src/runtime/ink.ts +6 -0
- package/src/runtime/kind-model.ts +20 -0
- package/src/runtime/live-model.ts +32 -0
- package/src/runtime/loader.tsx +87 -0
- package/src/runtime/markup-editor.tsx +1333 -0
- package/src/runtime/markup-model.ts +485 -0
- package/src/runtime/markup.ts +382 -0
- package/src/runtime/members.ts +58 -0
- package/src/runtime/mention-parse.ts +56 -0
- package/src/runtime/mentions.tsx +205 -0
- package/src/runtime/page-background.ts +51 -0
- package/src/runtime/page-image.ts +1592 -0
- package/src/runtime/placement.ts +336 -0
- package/src/runtime/record.ts +402 -0
- package/src/runtime/redact.ts +30 -0
- package/src/runtime/related.ts +34 -0
- package/src/runtime/render-page.ts +2 -0
- package/src/runtime/replay-model.ts +232 -0
- package/src/runtime/replay.ts +173 -0
- package/src/runtime/runtime-version.ts +7 -0
- package/src/runtime/runtime.tsx +51 -0
- package/src/runtime/screen.ts +182 -0
- package/src/runtime/screenshot.ts +260 -0
- package/src/runtime/session-source.ts +112 -0
- package/src/runtime/session.ts +108 -0
- package/src/runtime/shield.ts +34 -0
- package/src/runtime/shot.tsx +422 -0
- package/src/runtime/sketch.ts +139 -0
- package/src/runtime/styles.ts +1541 -0
- package/src/runtime/sync.ts +300 -0
- package/src/runtime/thread-model.ts +71 -0
- package/src/runtime/ticket-form-model.ts +128 -0
- package/src/runtime/ticket-form.tsx +342 -0
- package/src/runtime/toolbar.tsx +4427 -0
- package/src/runtime/ui-scale.ts +75 -0
- package/src/runtime/update.ts +98 -0
- package/src/runtime/use-debug-context.ts +81 -0
- package/src/server/index.ts +133 -0
- package/src/server/support.ts +499 -0
- package/src/server.ts +4 -0
- package/src/vite/plugin.ts +102 -0
- package/src/vite.ts +1 -0
- package/dist/chunks/chunk-CSKXLF33.js +0 -153
- package/dist/chunks/chunk-HUM5FQII.js +0 -160
- package/dist/chunks/render-page-76MLIT6M.js +0 -1421
- package/dist/chunks/runtime-JX75TKER.js +0 -9267
- package/dist/chunks/screenshot-66ARTBGR.js +0 -12
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { consoleText, redactSecretSearchParams, type ReplayBuffer } from "./replay-model.js";
|
|
2
|
+
import { fieldIsSensitive, redactText } from "./redact.js";
|
|
3
|
+
|
|
4
|
+
export * from "./replay-model.js";
|
|
5
|
+
|
|
6
|
+
function requestPath(input: RequestInfo | URL) {
|
|
7
|
+
try {
|
|
8
|
+
const url = new URL(
|
|
9
|
+
typeof input === "string" ? input : input instanceof URL ? input.href : input.url,
|
|
10
|
+
location.href,
|
|
11
|
+
);
|
|
12
|
+
redactSecretSearchParams(url);
|
|
13
|
+
return redactText(`${url.pathname}${url.search}`).slice(0, 300);
|
|
14
|
+
} catch {
|
|
15
|
+
return "";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Only what it reads: a workspace may type-check this source with the Workers types,
|
|
20
|
+
// whose global `Element` conflicts with `HTMLSelectElement` (`remove(index)`), so a plain
|
|
21
|
+
// `Element` parameter rejects form controls there.
|
|
22
|
+
function controlLabel(element: Pick<Element, "closest" | "getAttribute" | "tagName">) {
|
|
23
|
+
const target = element.closest(
|
|
24
|
+
"button, a, input, textarea, select, [role='button'], [role='link'], label",
|
|
25
|
+
);
|
|
26
|
+
if (!target)
|
|
27
|
+
return redactText(element.getAttribute("aria-label") || element.tagName.toLowerCase()).slice(
|
|
28
|
+
0,
|
|
29
|
+
120,
|
|
30
|
+
);
|
|
31
|
+
const aria = target.getAttribute("aria-label");
|
|
32
|
+
const textContent = (target.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 120);
|
|
33
|
+
return redactText(
|
|
34
|
+
aria || textContent || target.getAttribute("placeholder") || target.tagName.toLowerCase(),
|
|
35
|
+
).slice(0, 120);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Records clicks, field changes, navigations, and console errors on this page.
|
|
40
|
+
* Ignores the toolbar. Does not read network traffic. Call the return to stop and drop the buffer.
|
|
41
|
+
*/
|
|
42
|
+
export function watchActivity(
|
|
43
|
+
buffer: ReplayBuffer,
|
|
44
|
+
/** Requests that are the toolbar's own (the comment service): never part of what the person did. */
|
|
45
|
+
ignoreUrl: (url: string) => boolean = () => false,
|
|
46
|
+
) {
|
|
47
|
+
const notePath = () => {
|
|
48
|
+
const url = new URL(location.href);
|
|
49
|
+
redactSecretSearchParams(url);
|
|
50
|
+
buffer.push({ type: "navigate", path: `${url.pathname}${url.search}`.slice(0, 300) });
|
|
51
|
+
};
|
|
52
|
+
notePath();
|
|
53
|
+
const onClick = (event: MouseEvent) => {
|
|
54
|
+
const target = event.target;
|
|
55
|
+
if (!(target instanceof Element) || target.closest("#cs-root")) return;
|
|
56
|
+
buffer.push({ type: "click", label: controlLabel(target) });
|
|
57
|
+
};
|
|
58
|
+
const onChange = (event: Event) => {
|
|
59
|
+
const target = event.target;
|
|
60
|
+
if (
|
|
61
|
+
!(
|
|
62
|
+
target instanceof HTMLInputElement ||
|
|
63
|
+
target instanceof HTMLTextAreaElement ||
|
|
64
|
+
target instanceof HTMLSelectElement
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
return;
|
|
68
|
+
if (target.closest("#cs-root")) return;
|
|
69
|
+
const label = controlLabel(target);
|
|
70
|
+
const sensitive = fieldIsSensitive({
|
|
71
|
+
type: target instanceof HTMLSelectElement ? "select" : target.type,
|
|
72
|
+
name: target.name,
|
|
73
|
+
id: target.id,
|
|
74
|
+
autocomplete: target.getAttribute("autocomplete") ?? "",
|
|
75
|
+
label,
|
|
76
|
+
});
|
|
77
|
+
const value = sensitive ? "" : redactText(target.value).slice(0, 80);
|
|
78
|
+
buffer.push({
|
|
79
|
+
type: "input",
|
|
80
|
+
label,
|
|
81
|
+
redacted: sensitive || !value,
|
|
82
|
+
...(value && !sensitive ? { value } : {}),
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
// eslint-disable-next-line no-console -- the replay recorder captures page console errors and warnings, then restores them.
|
|
86
|
+
const original = { error: console.error, warn: console.warn, fetch: window.fetch };
|
|
87
|
+
// A patch someone installed after ours keeps ours in its chain after stop: from then on it only passes through.
|
|
88
|
+
let active = true;
|
|
89
|
+
const patchedError = (...args: unknown[]) => {
|
|
90
|
+
if (!active) return original.error.apply(console, args);
|
|
91
|
+
buffer.push({ type: "console", level: "error", message: consoleText(args) });
|
|
92
|
+
original.error.apply(console, args);
|
|
93
|
+
};
|
|
94
|
+
const patchedWarn = (...args: unknown[]) => {
|
|
95
|
+
if (!active) return original.warn.apply(console, args);
|
|
96
|
+
buffer.push({ type: "console", level: "warn", message: consoleText(args) });
|
|
97
|
+
original.warn.apply(console, args);
|
|
98
|
+
};
|
|
99
|
+
// eslint-disable-next-line no-console -- the replay recorder captures page console errors and warnings, then restores them.
|
|
100
|
+
console.error = patchedError;
|
|
101
|
+
// eslint-disable-next-line no-console -- the replay recorder captures page console errors and warnings, then restores them.
|
|
102
|
+
console.warn = patchedWarn;
|
|
103
|
+
// Errors nobody logged: an exception that escaped, a promise nobody caught.
|
|
104
|
+
const onError = (event: ErrorEvent) => {
|
|
105
|
+
const message = event.error instanceof Error ? event.error.message : event.message;
|
|
106
|
+
buffer.push({
|
|
107
|
+
type: "console",
|
|
108
|
+
level: "error",
|
|
109
|
+
message: redactText(`Uncaught: ${message || "error"}`),
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
const onRejection = (event: PromiseRejectionEvent) => {
|
|
113
|
+
const reason = event.reason;
|
|
114
|
+
const message =
|
|
115
|
+
reason instanceof Error ? reason.message : typeof reason === "string" ? reason : "rejection";
|
|
116
|
+
buffer.push({ type: "console", level: "error", message: redactText(`Unhandled: ${message}`) });
|
|
117
|
+
};
|
|
118
|
+
window.addEventListener("error", onError);
|
|
119
|
+
window.addEventListener("unhandledrejection", onRejection);
|
|
120
|
+
// Requests that failed, by path only: no bodies, no headers, no query values that look secret.
|
|
121
|
+
// Assigned through `unknown`: the workspace type-checks this file with the Workers types, whose `fetch`
|
|
122
|
+
// signature differs from the browser's.
|
|
123
|
+
const page = window as unknown as { fetch: unknown };
|
|
124
|
+
let patchedFetch: unknown = null;
|
|
125
|
+
if (typeof original.fetch === "function") {
|
|
126
|
+
const patched = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
127
|
+
const method = (
|
|
128
|
+
init?.method ?? (input instanceof Request ? input.method : "GET")
|
|
129
|
+
).toUpperCase();
|
|
130
|
+
const href =
|
|
131
|
+
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
132
|
+
if (!active || ignoreUrl(href)) return original.fetch.call(window, input, init);
|
|
133
|
+
const path = requestPath(input);
|
|
134
|
+
try {
|
|
135
|
+
const response = await original.fetch.call(window, input, init);
|
|
136
|
+
if (path && response.status >= 400)
|
|
137
|
+
buffer.push({ type: "network", method, path, status: response.status });
|
|
138
|
+
return response;
|
|
139
|
+
} catch (caught) {
|
|
140
|
+
if (path) buffer.push({ type: "network", method, path, status: 0 });
|
|
141
|
+
throw caught;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
page.fetch = patched;
|
|
145
|
+
patchedFetch = patched;
|
|
146
|
+
}
|
|
147
|
+
let href = location.href;
|
|
148
|
+
const onNavigate = () => {
|
|
149
|
+
if (location.href === href) return;
|
|
150
|
+
href = location.href;
|
|
151
|
+
notePath();
|
|
152
|
+
};
|
|
153
|
+
const timer = window.setInterval(onNavigate, 1000);
|
|
154
|
+
window.addEventListener("popstate", onNavigate);
|
|
155
|
+
document.addEventListener("click", onClick, true);
|
|
156
|
+
document.addEventListener("change", onChange, true);
|
|
157
|
+
return () => {
|
|
158
|
+
active = false;
|
|
159
|
+
document.removeEventListener("click", onClick, true);
|
|
160
|
+
document.removeEventListener("change", onChange, true);
|
|
161
|
+
window.removeEventListener("popstate", onNavigate);
|
|
162
|
+
window.removeEventListener("error", onError);
|
|
163
|
+
window.removeEventListener("unhandledrejection", onRejection);
|
|
164
|
+
window.clearInterval(timer);
|
|
165
|
+
// Put back only what is still ours: a patch installed after this one (debug context, Sentry) stays.
|
|
166
|
+
// eslint-disable-next-line no-console -- the replay recorder captures page console errors and warnings, then restores them.
|
|
167
|
+
if (console.error === patchedError) console.error = original.error;
|
|
168
|
+
// eslint-disable-next-line no-console -- the replay recorder captures page console errors and warnings, then restores them.
|
|
169
|
+
if (console.warn === patchedWarn) console.warn = original.warn;
|
|
170
|
+
if (patchedFetch !== null && page.fetch === patchedFetch) page.fetch = original.fetch;
|
|
171
|
+
buffer.clear();
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract between the loader in the npm package and the toolbar the Worker serves at
|
|
3
|
+
* `/v1/toolbar/<runtimeVersion>.js`. Bump it only when `mount` or `ToolbarProps` change incompatibly,
|
|
4
|
+
* and commit the last build of the old version under apps/worker/public/v1/toolbar/ first so apps
|
|
5
|
+
* still on it keep working.
|
|
6
|
+
*/
|
|
7
|
+
export const runtimeVersion = 1;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createRoot } from "react-dom/client";
|
|
2
|
+
import { ToolbarApp, type RuntimeProps } from "./toolbar.js";
|
|
3
|
+
import { registerSwapper } from "./update.js";
|
|
4
|
+
|
|
5
|
+
export { runtimeBuild } from "./build-id.js";
|
|
6
|
+
export { runtimeVersion } from "./runtime-version.js";
|
|
7
|
+
export type { RuntimeProps } from "./toolbar.js";
|
|
8
|
+
|
|
9
|
+
export type ToolbarHandle = { update(props: RuntimeProps): void; unmount(): void };
|
|
10
|
+
|
|
11
|
+
type RuntimeModule = {
|
|
12
|
+
mount?: (container: HTMLElement, props: RuntimeProps) => ToolbarHandle;
|
|
13
|
+
runtimeBuild?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Mounts the toolbar. The handle stays valid across a live update: when the worker starts serving a newer build,
|
|
18
|
+
* the toolbar loads it, unmounts this one and mounts the new one in the same container with the same props, and
|
|
19
|
+
* the handle the host holds forwards to it.
|
|
20
|
+
*/
|
|
21
|
+
export function mount(container: HTMLElement, props: RuntimeProps): ToolbarHandle {
|
|
22
|
+
let current = props;
|
|
23
|
+
let next: ToolbarHandle | null = null;
|
|
24
|
+
const root = createRoot(container);
|
|
25
|
+
root.render(<ToolbarApp {...props} />);
|
|
26
|
+
registerSwapper(async (url, build) => {
|
|
27
|
+
const loaded = (await import(
|
|
28
|
+
/* @vite-ignore */ /* webpackIgnore: true */ url
|
|
29
|
+
)) as RuntimeModule;
|
|
30
|
+
if (typeof loaded.mount !== "function") throw new Error(`${url} has no mount export`);
|
|
31
|
+
// A stale edge answered with another bundle: this one keeps running (see `swapRuntime`).
|
|
32
|
+
if (loaded.runtimeBuild !== build) return false;
|
|
33
|
+
registerSwapper(null);
|
|
34
|
+
root.unmount();
|
|
35
|
+
// The new build registers its own swapper, so later updates go through it.
|
|
36
|
+
next = loaded.mount(container, current);
|
|
37
|
+
return true;
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
update: (props) => {
|
|
41
|
+
current = props;
|
|
42
|
+
if (next) next.update(props);
|
|
43
|
+
else root.render(<ToolbarApp {...props} />);
|
|
44
|
+
},
|
|
45
|
+
unmount: () => {
|
|
46
|
+
registerSwapper(null);
|
|
47
|
+
if (next) next.unmount();
|
|
48
|
+
else root.unmount();
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which screen of the host app an address is, and so which threads' pins belong on the page. DOM-free.
|
|
3
|
+
*
|
|
4
|
+
* A thread stores the address it was left on (`url`: path, query and hash) next to its `route` (the path). Two
|
|
5
|
+
* addresses are one screen when they have the same path, the same hash route (a hash that starts with `#/` or
|
|
6
|
+
* `#!/`, as hash routers write them; any other hash is a place on the page and does not count), and the same
|
|
7
|
+
* values for every query parameter that names a screen.
|
|
8
|
+
*
|
|
9
|
+
* Without a rule from the host, every query parameter names a screen except the ones that never do: tracking
|
|
10
|
+
* (`utm_*`, `gclid`, …), paging, sorting, searching and filtering by their usual names, and the toolbar's own.
|
|
11
|
+
* A host whose screens differ by fewer parameters names them (`{ params: ["view"] }`), and one whose screens
|
|
12
|
+
* are not in the address that way names each screen itself with a function.
|
|
13
|
+
*
|
|
14
|
+
* A thread whose address is a bare path (no query, no hash route) was stored before addresses were kept, or was
|
|
15
|
+
* left where the path was all there was: it shows on every screen at that path, as every thread did before. A
|
|
16
|
+
* host function decides for those too.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** What the host says identifies a screen: the query parameters that do, or a function that names it. */
|
|
20
|
+
export type ScreenRule = { readonly params: readonly string[] } | ((address: URL) => string);
|
|
21
|
+
|
|
22
|
+
/** Query parameters that never select a screen when the host gave no rule. */
|
|
23
|
+
const NOISE = new Set([
|
|
24
|
+
// Tracking and attribution.
|
|
25
|
+
"gclid",
|
|
26
|
+
"gbraid",
|
|
27
|
+
"wbraid",
|
|
28
|
+
"dclid",
|
|
29
|
+
"fbclid",
|
|
30
|
+
"msclkid",
|
|
31
|
+
"twclid",
|
|
32
|
+
"ttclid",
|
|
33
|
+
"li_fat_id",
|
|
34
|
+
"igshid",
|
|
35
|
+
"yclid",
|
|
36
|
+
"mc_cid",
|
|
37
|
+
"mc_eid",
|
|
38
|
+
"_ga",
|
|
39
|
+
"_gl",
|
|
40
|
+
"_hsenc",
|
|
41
|
+
"_hsmi",
|
|
42
|
+
"mkt_tok",
|
|
43
|
+
"ref",
|
|
44
|
+
"ref_src",
|
|
45
|
+
"srsltid",
|
|
46
|
+
// Paging and sorting.
|
|
47
|
+
"page",
|
|
48
|
+
"p",
|
|
49
|
+
"per_page",
|
|
50
|
+
"perpage",
|
|
51
|
+
"page_size",
|
|
52
|
+
"pagesize",
|
|
53
|
+
"limit",
|
|
54
|
+
"offset",
|
|
55
|
+
"cursor",
|
|
56
|
+
"after",
|
|
57
|
+
"before",
|
|
58
|
+
"sort",
|
|
59
|
+
"sort_by",
|
|
60
|
+
"sortby",
|
|
61
|
+
"order",
|
|
62
|
+
"order_by",
|
|
63
|
+
"orderby",
|
|
64
|
+
"dir",
|
|
65
|
+
"direction",
|
|
66
|
+
// Searching and filtering.
|
|
67
|
+
"q",
|
|
68
|
+
"query",
|
|
69
|
+
"search",
|
|
70
|
+
"filter",
|
|
71
|
+
"filters",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
function isNoise(name: string): boolean {
|
|
75
|
+
const lower = name.toLowerCase();
|
|
76
|
+
return (
|
|
77
|
+
NOISE.has(lower) ||
|
|
78
|
+
lower.startsWith("utm_") ||
|
|
79
|
+
lower.startsWith("hsa_") ||
|
|
80
|
+
lower.startsWith("cs-") ||
|
|
81
|
+
/^filters?[[._]/.test(lower)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const BASE = "https://screen.invalid";
|
|
86
|
+
|
|
87
|
+
/** The part of a hash a hash router owns (`#/orders?tab=2` → `/orders?tab=2`), or null for an in-page hash. */
|
|
88
|
+
function hashRoute(hash: string): URL | null {
|
|
89
|
+
const route = hash.startsWith("#!/")
|
|
90
|
+
? hash.slice(2)
|
|
91
|
+
: hash.startsWith("#/")
|
|
92
|
+
? hash.slice(1)
|
|
93
|
+
: null;
|
|
94
|
+
if (route === null) return null;
|
|
95
|
+
try {
|
|
96
|
+
return new URL(route, BASE);
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function paramsKey(search: URLSearchParams, rule: { readonly params: readonly string[] } | null) {
|
|
103
|
+
const kept = [...search].filter(([name]) => (rule ? rule.params.includes(name) : !isNoise(name)));
|
|
104
|
+
kept.sort(([a, x], [b, y]) => (a === b ? (x < y ? -1 : x > y ? 1 : 0) : a < b ? -1 : 1));
|
|
105
|
+
return new URLSearchParams(kept).toString();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** `address` (a path with its query and hash, or a whole URL) as a URL, or null when it is not a web page. */
|
|
109
|
+
function parse(address: string, origin: string): URL | null {
|
|
110
|
+
if (!address.startsWith("/") || address.startsWith("//")) {
|
|
111
|
+
try {
|
|
112
|
+
const url = new URL(address);
|
|
113
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url : null;
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
return new URL(address, origin);
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The screen `url` is, as a string two addresses of one screen share. A host function that throws names
|
|
127
|
+
* nothing, and the address is read by the parameters instead.
|
|
128
|
+
*/
|
|
129
|
+
export function screenKey(url: URL, rule: ScreenRule | null = null): string {
|
|
130
|
+
if (typeof rule === "function") {
|
|
131
|
+
try {
|
|
132
|
+
return `=${String(rule(new URL(url.href)))}`;
|
|
133
|
+
} catch {
|
|
134
|
+
return screenKey(url, null);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const route = hashRoute(url.hash);
|
|
138
|
+
const parts = [url.pathname, paramsKey(url.searchParams, rule)];
|
|
139
|
+
if (route) parts.push(route.pathname, paramsKey(route.searchParams, rule));
|
|
140
|
+
return parts.join("\u0000");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** True for an address that is only a path: no query and no hash route. */
|
|
144
|
+
export function isBarePath(address: string): boolean {
|
|
145
|
+
const url = parse(address, BASE);
|
|
146
|
+
return url !== null && url.search === "" && hashRoute(url.hash) === null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The address a thread was left on, as the toolbar reads it: its `url`, else its `route`. */
|
|
150
|
+
export type Placed = { readonly route: string; readonly url?: string | null };
|
|
151
|
+
|
|
152
|
+
export type Here = {
|
|
153
|
+
/** The page's path, which anchors are captured and resolved against. */
|
|
154
|
+
readonly route: string;
|
|
155
|
+
/** The screen the page is on. */
|
|
156
|
+
readonly key: string;
|
|
157
|
+
/** Whether `thread` was left on this screen, so its pin belongs here. */
|
|
158
|
+
has(thread: Placed): boolean;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The screen at `href` (the page's whole address), and which threads belong on it. A thread is only ever on
|
|
163
|
+
* a screen at its own path, because its anchor is found among that path's elements. One whose `url` is not a
|
|
164
|
+
* page of this app (a native app's screen) belongs here by its `route` alone, as before.
|
|
165
|
+
*/
|
|
166
|
+
export function screenAt(href: string, rule: ScreenRule | null = null): Here {
|
|
167
|
+
const page = new URL(href);
|
|
168
|
+
const key = screenKey(page, rule);
|
|
169
|
+
const route = page.pathname;
|
|
170
|
+
return {
|
|
171
|
+
route,
|
|
172
|
+
key,
|
|
173
|
+
has(thread) {
|
|
174
|
+
if (thread.route !== route) return false;
|
|
175
|
+
const address = thread.url || thread.route;
|
|
176
|
+
const url = parse(address, page.origin);
|
|
177
|
+
if (!url) return true;
|
|
178
|
+
if (typeof rule !== "function" && isBarePath(address)) return true;
|
|
179
|
+
return screenKey(url, rule) === key;
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import type { Sketch } from "../core/index.js";
|
|
2
|
+
import { browserLabel } from "./browser-label.js";
|
|
3
|
+
import type { Capture } from "./client.js";
|
|
4
|
+
import { pageBackground } from "./page-background.js";
|
|
5
|
+
import { renderSnapshot, snapshotPage, type PageSnapshot } from "./page-image.js";
|
|
6
|
+
import { paintSketch, sketchBox } from "./sketch.js";
|
|
7
|
+
|
|
8
|
+
const MAX_WIDTH = 1200;
|
|
9
|
+
/** Widest page screenshot that is attached. */
|
|
10
|
+
const PAGE_MAX_WIDTH = 1600;
|
|
11
|
+
/** Room kept around the ring, in CSS pixels. */
|
|
12
|
+
const RING_PAD = 6;
|
|
13
|
+
/** The smallest crop `cropFor` cuts around an element, in CSS pixels. */
|
|
14
|
+
const CROP_MIN = { width: 760, height: 420 };
|
|
15
|
+
/** Room kept around the element (and its drawing) in a crop, in CSS pixels. */
|
|
16
|
+
const CROP_PAD = 200;
|
|
17
|
+
|
|
18
|
+
/** The page, viewport and browser a screenshot is being taken in. */
|
|
19
|
+
export function describeCapture(url: string): Capture {
|
|
20
|
+
return {
|
|
21
|
+
url,
|
|
22
|
+
viewport: { width: document.documentElement.clientWidth, height: window.innerHeight },
|
|
23
|
+
browser: browserLabel(navigator.userAgent),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function roundedRect(
|
|
28
|
+
ctx: CanvasRenderingContext2D,
|
|
29
|
+
x: number,
|
|
30
|
+
y: number,
|
|
31
|
+
w: number,
|
|
32
|
+
h: number,
|
|
33
|
+
r: number,
|
|
34
|
+
) {
|
|
35
|
+
ctx.moveTo(x + r, y);
|
|
36
|
+
ctx.arcTo(x + w, y, x + w, y + h, r);
|
|
37
|
+
ctx.arcTo(x + w, y + h, x, y + h, r);
|
|
38
|
+
ctx.arcTo(x, y + h, x, y, r);
|
|
39
|
+
ctx.arcTo(x, y, x + w, y, r);
|
|
40
|
+
ctx.closePath();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isToolbar(node: Element) {
|
|
44
|
+
return node.id === "cs-root";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Device pixels per CSS pixel for a page-wide screenshot: the display's ratio, capped by the output width. */
|
|
48
|
+
function pageScale(width: number) {
|
|
49
|
+
return Math.min(window.devicePixelRatio || 1, 2, PAGE_MAX_WIDTH / width);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function currentViewport() {
|
|
53
|
+
return { width: document.documentElement.clientWidth, height: window.innerHeight };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A box in CSS pixels from the top-left of the viewport. */
|
|
57
|
+
export type Frame = { left: number; top: number; width: number; height: number };
|
|
58
|
+
export type Viewport = { width: number; height: number };
|
|
59
|
+
|
|
60
|
+
/** `rect` as a frame, or null when none of it is inside the viewport. */
|
|
61
|
+
export function frameOf(rect: DOMRect | Frame, viewport: Viewport): Frame | null {
|
|
62
|
+
const right = rect.left + rect.width;
|
|
63
|
+
const bottom = rect.top + rect.height;
|
|
64
|
+
if (
|
|
65
|
+
rect.width < 1 ||
|
|
66
|
+
bottom <= 0 ||
|
|
67
|
+
rect.top >= viewport.height ||
|
|
68
|
+
right <= 0 ||
|
|
69
|
+
rect.left >= viewport.width
|
|
70
|
+
)
|
|
71
|
+
return null;
|
|
72
|
+
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The ring drawn around `frame`, in output pixels, when the picture is `scale` output pixels per CSS pixel. */
|
|
76
|
+
export function ringFor(frame: Frame, scale: number, origin = { left: 0, top: 0 }) {
|
|
77
|
+
return {
|
|
78
|
+
x: (frame.left - origin.left - RING_PAD) * scale,
|
|
79
|
+
y: (frame.top - origin.top - RING_PAD) * scale,
|
|
80
|
+
w: (frame.width + RING_PAD * 2) * scale,
|
|
81
|
+
h: (frame.height + RING_PAD * 2) * scale,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The part of the viewport a crop around `frame` shows: the element and its drawing with room around them, at
|
|
87
|
+
* least `CROP_MIN`, never beyond the viewport, centred on the element as far as the viewport allows.
|
|
88
|
+
*/
|
|
89
|
+
export function cropFor(frame: Frame, viewport: Viewport, sketch?: Sketch): Frame {
|
|
90
|
+
const ink = sketch ? sketchBox(sketch) : null;
|
|
91
|
+
const left = ink ? Math.min(frame.left, frame.left + ink.left) : frame.left;
|
|
92
|
+
const top = ink ? Math.min(frame.top, frame.top + ink.top) : frame.top;
|
|
93
|
+
const right = ink
|
|
94
|
+
? Math.max(frame.left + frame.width, frame.left + ink.right)
|
|
95
|
+
: frame.left + frame.width;
|
|
96
|
+
const bottom = ink
|
|
97
|
+
? Math.max(frame.top + frame.height, frame.top + ink.bottom)
|
|
98
|
+
: frame.top + frame.height;
|
|
99
|
+
const width = Math.min(viewport.width, Math.max(right - left + CROP_PAD, CROP_MIN.width));
|
|
100
|
+
const height = Math.min(viewport.height, Math.max(bottom - top + CROP_PAD, CROP_MIN.height));
|
|
101
|
+
const x = Math.max(0, Math.min((left + right) / 2 - width / 2, viewport.width - width));
|
|
102
|
+
const y = Math.max(0, Math.min((top + bottom) / 2 - height / 2, viewport.height - height));
|
|
103
|
+
return { left: x, top: y, width, height };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The page as it was at one instant: a copy of its DOM and, if an element was being pointed at, that element's
|
|
108
|
+
* box in the viewport at the same instant. Drawing the copy happens later and takes a while; measuring the
|
|
109
|
+
* element then instead would put its ring wherever the element had moved to in the meantime.
|
|
110
|
+
*/
|
|
111
|
+
export type PageShot = {
|
|
112
|
+
/** The highlighted element's box when the page was copied; null when nothing was, or it was off screen. */
|
|
113
|
+
frame: Frame | null;
|
|
114
|
+
viewport: Viewport;
|
|
115
|
+
/** Device pixels per CSS pixel the drawing is made at. */
|
|
116
|
+
scale: number;
|
|
117
|
+
/** The drawing, made once on the first request and shared by every picture cut from this shot. */
|
|
118
|
+
render: () => Promise<HTMLCanvasElement>;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Draws the already-copied page. A failure stays a failure: painting the live document would include
|
|
123
|
+
* password and token fields the copy had blanked.
|
|
124
|
+
*/
|
|
125
|
+
async function drawSnapshot(snapshot: PageSnapshot, scale: number, timeout: number) {
|
|
126
|
+
return renderSnapshot(snapshot, scale, timeout);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Copies the visible page now, synchronously, with `highlight`'s box measured at the same moment. The toolbar is
|
|
131
|
+
* left out. Nothing is drawn until `render` is first called (see `shotPage`, `shotAround`).
|
|
132
|
+
*/
|
|
133
|
+
export function takeShot(highlight?: Element | null, timeout = 6000): PageShot {
|
|
134
|
+
const viewport = currentViewport();
|
|
135
|
+
const frame = highlight ? frameOf(highlight.getBoundingClientRect(), viewport) : null;
|
|
136
|
+
const snapshot = snapshotPage({
|
|
137
|
+
width: viewport.width,
|
|
138
|
+
height: viewport.height,
|
|
139
|
+
exclude: isToolbar,
|
|
140
|
+
backgroundColor: pageBackground(),
|
|
141
|
+
});
|
|
142
|
+
const scale = pageScale(viewport.width);
|
|
143
|
+
let drawn: Promise<HTMLCanvasElement> | null = null;
|
|
144
|
+
return {
|
|
145
|
+
frame,
|
|
146
|
+
viewport,
|
|
147
|
+
scale,
|
|
148
|
+
render: () => {
|
|
149
|
+
drawn ??= drawSnapshot(snapshot, scale, timeout);
|
|
150
|
+
return drawn;
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The visible page as a canvas, toolbar left out, drawn from the page as it is now. `timeout` bounds a slow frame. */
|
|
156
|
+
export function renderViewport(scale: number, timeout = 6000) {
|
|
157
|
+
const viewport = currentViewport();
|
|
158
|
+
const snapshot = snapshotPage({
|
|
159
|
+
width: viewport.width,
|
|
160
|
+
height: viewport.height,
|
|
161
|
+
exclude: isToolbar,
|
|
162
|
+
backgroundColor: pageBackground(),
|
|
163
|
+
});
|
|
164
|
+
return drawSnapshot(snapshot, scale, timeout);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function outline(
|
|
168
|
+
ctx: CanvasRenderingContext2D,
|
|
169
|
+
width: number,
|
|
170
|
+
height: number,
|
|
171
|
+
ring: { x: number; y: number; w: number; h: number },
|
|
172
|
+
scale: number,
|
|
173
|
+
) {
|
|
174
|
+
const radius = Math.min(12 * scale, ring.w / 2, ring.h / 2);
|
|
175
|
+
ctx.beginPath();
|
|
176
|
+
ctx.rect(0, 0, width, height);
|
|
177
|
+
roundedRect(ctx, ring.x, ring.y, ring.w, ring.h, radius);
|
|
178
|
+
ctx.fillStyle = "rgba(15, 15, 20, 0.22)";
|
|
179
|
+
ctx.fill("evenodd");
|
|
180
|
+
ctx.beginPath();
|
|
181
|
+
roundedRect(ctx, ring.x, ring.y, ring.w, ring.h, radius);
|
|
182
|
+
ctx.lineWidth = 3 * scale;
|
|
183
|
+
ctx.strokeStyle = "#007aff";
|
|
184
|
+
ctx.stroke();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function toBlob(canvas: HTMLCanvasElement) {
|
|
188
|
+
return new Promise<Blob | null>((resolve) =>
|
|
189
|
+
canvas.toBlob((blob) => resolve(blob), "image/jpeg", 0.85),
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The whole visible page as a JPEG, from `shot`. If the shot framed an element it's outlined, or `sketch`
|
|
195
|
+
* (drawn against it) is painted on instead.
|
|
196
|
+
*/
|
|
197
|
+
export async function shotPage(shot: PageShot, sketch?: Sketch): Promise<Blob | null> {
|
|
198
|
+
let page: HTMLCanvasElement;
|
|
199
|
+
try {
|
|
200
|
+
page = await shot.render();
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const { frame, scale } = shot;
|
|
205
|
+
if (!frame) return toBlob(page);
|
|
206
|
+
const out = document.createElement("canvas");
|
|
207
|
+
out.width = page.width;
|
|
208
|
+
out.height = page.height;
|
|
209
|
+
const ctx = out.getContext("2d");
|
|
210
|
+
if (!ctx) return null;
|
|
211
|
+
ctx.drawImage(page, 0, 0);
|
|
212
|
+
if (sketch) paintSketch(ctx, sketch, { x: frame.left, y: frame.top }, scale);
|
|
213
|
+
else outline(ctx, out.width, out.height, ringFor(frame, scale), scale);
|
|
214
|
+
return toBlob(out);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* What the person saw around the framed element, cut from `shot`, with the rest dimmed and the element
|
|
219
|
+
* outlined, or with `sketch` painted on instead. A JPEG data URL, or null when nothing was framed or the
|
|
220
|
+
* page couldn't be drawn.
|
|
221
|
+
*/
|
|
222
|
+
export async function shotAround(shot: PageShot, sketch?: Sketch): Promise<string | null> {
|
|
223
|
+
const { frame, viewport, scale } = shot;
|
|
224
|
+
if (!frame) return null;
|
|
225
|
+
let page: HTMLCanvasElement;
|
|
226
|
+
try {
|
|
227
|
+
page = await shot.render();
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
const crop = cropFor(frame, viewport, sketch);
|
|
232
|
+
const out = document.createElement("canvas");
|
|
233
|
+
const outScale = Math.min(scale, MAX_WIDTH / crop.width);
|
|
234
|
+
out.width = Math.round(crop.width * outScale);
|
|
235
|
+
out.height = Math.round(crop.height * outScale);
|
|
236
|
+
const ctx = out.getContext("2d");
|
|
237
|
+
if (!ctx) return null;
|
|
238
|
+
ctx.drawImage(
|
|
239
|
+
page,
|
|
240
|
+
crop.left * scale,
|
|
241
|
+
crop.top * scale,
|
|
242
|
+
crop.width * scale,
|
|
243
|
+
crop.height * scale,
|
|
244
|
+
0,
|
|
245
|
+
0,
|
|
246
|
+
out.width,
|
|
247
|
+
out.height,
|
|
248
|
+
);
|
|
249
|
+
if (sketch) {
|
|
250
|
+
paintSketch(ctx, sketch, { x: frame.left - crop.left, y: frame.top - crop.top }, outScale);
|
|
251
|
+
return out.toDataURL("image/jpeg", 0.82);
|
|
252
|
+
}
|
|
253
|
+
outline(ctx, out.width, out.height, ringFor(frame, outScale, crop), outScale);
|
|
254
|
+
return out.toDataURL("image/jpeg", 0.82);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** `shotAround` for the page as it is now. */
|
|
258
|
+
export function captureAround(element: Element, sketch?: Sketch): Promise<string | null> {
|
|
259
|
+
return shotAround(takeShot(element), sketch);
|
|
260
|
+
}
|