///
import { isScreenshotWatermarkVisible, setScreenshotWatermarkVisible } from "./screenshot-watermark";
export interface PngScreenshot {
pngBase64: string;
width: number;
height: number;
}
interface CaptureOrigin {
left: number;
top: number;
}
function escapeAttributeValue(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
}
function findPaneScreenshotTarget(paneId?: string): HTMLElement | null {
if (typeof document === "undefined") return null;
if (paneId) {
const escapedPaneId = escapeAttributeValue(paneId);
const target = document.querySelector(`[data-gloom-pane-id="${escapedPaneId}"]`);
if (target) return target;
}
return document.querySelector(
"[data-gloom-role='pane-window'][data-focused='true'], [data-gloom-role='detached-pane-window'][data-focused='true']",
);
}
function isVisible(style: CSSStyleDeclaration): boolean {
return style.display !== "none" && style.visibility !== "hidden" && Number.parseFloat(style.opacity || "1") > 0;
}
function isPaintableColor(value: string): boolean {
return value !== "" && value !== "transparent" && !/^rgba?\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)$/i.test(value);
}
function px(value: string): number {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function localRect(rect: DOMRect, origin: CaptureOrigin) {
return {
x: rect.left - origin.left,
y: rect.top - origin.top,
width: rect.width,
height: rect.height,
};
}
function roundedRectPath(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
): void {
const r = Math.max(0, Math.min(radius, width / 2, height / 2));
context.beginPath();
context.moveTo(x + r, y);
context.lineTo(x + width - r, y);
context.quadraticCurveTo(x + width, y, x + width, y + r);
context.lineTo(x + width, y + height - r);
context.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
context.lineTo(x + r, y + height);
context.quadraticCurveTo(x, y + height, x, y + height - r);
context.lineTo(x, y + r);
context.quadraticCurveTo(x, y, x + r, y);
context.closePath();
}
function fillBackground(
context: CanvasRenderingContext2D,
style: CSSStyleDeclaration,
rect: ReturnType,
): void {
if (!isPaintableColor(style.backgroundColor)) return;
context.fillStyle = style.backgroundColor;
roundedRectPath(context, rect.x, rect.y, rect.width, rect.height, px(style.borderTopLeftRadius));
context.fill();
}
function strokeBorder(
context: CanvasRenderingContext2D,
style: CSSStyleDeclaration,
rect: ReturnType,
): void {
const widths = [
px(style.borderTopWidth),
px(style.borderRightWidth),
px(style.borderBottomWidth),
px(style.borderLeftWidth),
];
if (widths.every((width) => width <= 0) || !isPaintableColor(style.borderTopColor)) return;
context.strokeStyle = style.borderTopColor;
context.lineWidth = Math.max(...widths);
roundedRectPath(
context,
rect.x + context.lineWidth / 2,
rect.y + context.lineWidth / 2,
Math.max(0, rect.width - context.lineWidth),
Math.max(0, rect.height - context.lineWidth),
px(style.borderTopLeftRadius),
);
context.stroke();
}
function shouldClip(style: CSSStyleDeclaration): boolean {
return [style.overflow, style.overflowX, style.overflowY].some((value) => (
value === "hidden" || value === "clip" || value === "auto" || value === "scroll"
));
}
function applyClip(
context: CanvasRenderingContext2D,
style: CSSStyleDeclaration,
rect: ReturnType,
): boolean {
if (!shouldClip(style)) return false;
roundedRectPath(context, rect.x, rect.y, rect.width, rect.height, px(style.borderTopLeftRadius));
context.clip();
return true;
}
function fontFor(style: CSSStyleDeclaration): string {
if (style.font) return style.font;
return [
style.fontStyle || "normal",
style.fontVariant || "normal",
style.fontWeight || "400",
`${style.fontSize || "14px"}/${style.lineHeight || "normal"}`,
style.fontFamily || "monospace",
].join(" ");
}
function textBaselineOffset(style: CSSStyleDeclaration, rect: Pick): number {
const fontSize = px(style.fontSize) || rect.height;
const lineHeight = px(style.lineHeight) || rect.height || fontSize;
return Math.max(fontSize * 0.78, (lineHeight + fontSize * 0.58) / 2);
}
type TextRect = Pick;
/** Recover the browser's actual line breaks, including font and inline layout. */
export function renderedTextFragments(
value: string,
measure: (start: number, end: number) => readonly TextRect[],
): { value: string; rect: TextRect }[] {
const offsets = [0];
for (const character of value) offsets.push(offsets.at(-1)! + character.length);
const fragments: { value: string; rect: TextRect }[] = [];
let start = 0;
const visible = (rects: readonly TextRect[]) => rects.filter((rect) => rect.width > 0 && rect.height > 0);
while (start < offsets.length - 1) {
const remaining = visible(measure(offsets[start]!, value.length));
const first = remaining[0];
if (!first) break;
// Binary search by code-point boundaries; measuring every glyph in long
// research notes would add unnecessary layout work to each screenshot.
let low = start + 1;
let high = offsets.length - 1;
let end = low;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
const rects = visible(measure(offsets[start]!, offsets[middle]!));
if (rects.every((rect) => Math.abs(rect.top - first.top) < 0.5)) {
end = middle;
low = middle + 1;
} else high = middle - 1;
}
const rects = visible(measure(offsets[start]!, offsets[end]!));
if (rects.length) {
const left = Math.min(...rects.map((rect) => rect.left));
fragments.push({ value: value.slice(offsets[start], offsets[end]), rect: {
left, top: first.top, height: Math.max(...rects.map((rect) => rect.height)),
width: Math.max(...rects.map((rect) => rect.left + rect.width)) - left,
} });
}
start = end;
}
return fragments;
}
function drawTextNode(
context: CanvasRenderingContext2D,
textNode: Text,
origin: CaptureOrigin,
): void {
const value = textNode.nodeValue ?? "";
if (value.length === 0 || value.trim().length === 0) return;
const parent = textNode.parentElement;
if (!parent) return;
const style = getComputedStyle(parent);
if (!isVisible(style) || !isPaintableColor(style.color)) return;
const range = document.createRange();
range.selectNodeContents(textNode);
const rects = Array.from(range.getClientRects()).filter((rect) => rect.width > 0 && rect.height > 0);
if (rects.length === 0) { range.detach(); return; }
context.fillStyle = style.color;
context.font = fontFor(style);
context.textAlign = "left";
context.textBaseline = "alphabetic";
context.direction = style.direction === "rtl" ? "rtl" : "ltr";
try {
const fragments = rects.length === 1 ? [{ value, rect: rects[0]! }]
: renderedTextFragments(value, (start, end) => {
range.setStart(textNode, start);
range.setEnd(textNode, end);
return Array.from(range.getClientRects());
});
for (const fragment of fragments) {
const text = style.whiteSpace === "normal" || style.whiteSpace === "nowrap"
? fragment.value.replace(/\s+/g, " ") : fragment.value.replace(/\r?\n/g, "");
if (!text) continue;
const rect = fragment.rect;
context.fillText(text, rect.left - origin.left, rect.top - origin.top + textBaselineOffset(style, rect));
}
} finally { range.detach(); }
}
function drawCanvasElement(
context: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
origin: CaptureOrigin,
): void {
const rect = canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
try {
context.drawImage(canvas, rect.left - origin.left, rect.top - origin.top, rect.width, rect.height);
} catch {
// Ignore canvases the browser considers unsafe to read.
}
}
function drawInputElement(
context: CanvasRenderingContext2D,
element: HTMLInputElement | HTMLTextAreaElement,
origin: CaptureOrigin,
): void {
// Paint what the screen shows: a password field is dots, never its value.
const value = element instanceof HTMLInputElement && element.type === "password"
? "\u2022".repeat(element.value.length)
: element.value;
if (!value) return;
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
if (!isVisible(style) || !isPaintableColor(style.color) || rect.width <= 0 || rect.height <= 0) return;
context.fillStyle = style.color;
context.font = fontFor(style);
const left = rect.left - origin.left + px(style.paddingLeft);
const top = rect.top - origin.top + px(style.paddingTop) + textBaselineOffset(style, rect);
for (const [index, line] of value.split(/\r?\n/).entries()) {
context.fillText(line, left, top + index * (px(style.lineHeight) || rect.height));
}
}
function drawElement(
context: CanvasRenderingContext2D,
element: Element,
origin: CaptureOrigin,
skip?: (element: Element) => boolean,
): void {
if (!(element instanceof HTMLElement || element instanceof SVGElement)) return;
if (skip?.(element)) return;
const style = getComputedStyle(element);
if (!isVisible(style)) return;
const rect = localRect(element.getBoundingClientRect(), origin);
if (rect.width <= 0 || rect.height <= 0) return;
context.save();
const opacity = Number.parseFloat(style.opacity || "1");
if (Number.isFinite(opacity)) {
context.globalAlpha *= Math.max(0, Math.min(opacity, 1));
}
fillBackground(context, style, rect);
strokeBorder(context, style, rect);
const clipped = applyClip(context, style, rect);
if (element instanceof HTMLCanvasElement) {
drawCanvasElement(context, element, origin);
} else if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
drawInputElement(context, element, origin);
}
for (const child of Array.from(element.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) {
drawTextNode(context, child as Text, origin);
} else if (child.nodeType === Node.ELEMENT_NODE) {
drawElement(context, child as Element, origin, skip);
}
}
if (clipped) {
context.restore();
} else {
context.restore();
}
}
function blobToBase64(blob: Blob): Promise {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error("Could not read screenshot data."));
reader.onload = () => {
const result = typeof reader.result === "string" ? reader.result : "";
resolve(result.includes(",") ? result.slice(result.indexOf(",") + 1) : result);
};
reader.readAsDataURL(blob);
});
}
function canvasToPngBlob(canvas: HTMLCanvasElement): Promise {
return new Promise((resolve, reject) => {
try {
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error("Could not encode pane screenshot."));
}, "image/png");
} catch {
reject(new Error("Could not encode pane screenshot."));
}
});
}
async function waitForFonts(): Promise {
const fontSet = document.fonts;
if (!fontSet) return;
await Promise.race([
fontSet.ready,
new Promise((resolve) => setTimeout(resolve, 250)),
]).catch(() => {});
}
function paintElement(
element: HTMLElement,
scale: number,
options: { background?: string; skip?: (element: Element) => boolean } = {},
): { canvas: HTMLCanvasElement; width: number; height: number } {
const rect = element.getBoundingClientRect();
const width = Math.ceil(rect.width);
const height = Math.ceil(rect.height);
if (width <= 0 || height <= 0) {
throw new Error("Pane is not visible.");
}
const canvas = document.createElement("canvas");
canvas.width = Math.ceil(width * scale);
canvas.height = Math.ceil(height * scale);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const context = canvas.getContext("2d");
if (!context) throw new Error("Could not create pane screenshot.");
context.scale(scale, scale);
context.clearRect(0, 0, width, height);
if (options.background) {
context.fillStyle = options.background;
context.fillRect(0, 0, width, height);
}
// Reveal the chart watermark only for the synchronous paint below: the
// browser never gets a frame in between, so the live UI does not flicker.
const watermarkWasVisible = isScreenshotWatermarkVisible();
setScreenshotWatermarkVisible(true);
try {
drawElement(context, element, { left: rect.left, top: rect.top }, options.skip);
} finally {
if (!watermarkWasVisible) setScreenshotWatermarkVisible(false);
}
return { canvas, width, height };
}
async function captureElementPngBase64(element: HTMLElement): Promise {
await waitForFonts();
const scale = Math.max(1, Math.min(globalThis.devicePixelRatio || 1, 3));
const { canvas, width, height } = paintElement(element, scale);
return {
pngBase64: await blobToBase64(await canvasToPngBlob(canvas)),
width,
height,
};
}
export interface JpegScreenshot {
jpegBase64: string;
width: number;
height: number;
}
function canvasToJpegBlob(canvas: HTMLCanvasElement, quality: number): Promise {
return new Promise((resolve, reject) => {
try {
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error("Could not encode screenshot."));
}, "image/jpeg", quality);
} catch {
reject(new Error("Could not encode screenshot."));
}
});
}
function downscale(source: HTMLCanvasElement, factor: number): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(source.width * factor));
canvas.height = Math.max(1, Math.round(source.height * factor));
const context = canvas.getContext("2d");
if (!context) throw new Error("Could not encode screenshot.");
context.imageSmoothingQuality = "high";
context.drawImage(source, 0, 0, canvas.width, canvas.height);
return canvas;
}
/**
* The whole app window as a JPEG no longer than `maxBase64Length`, for bug
* reports. Painted at 1x (text stays legible, the file stays small), then
* shrunk until it fits. Elements matching `skip` (the feedback dialog itself)
* are left out.
*/
export async function captureAppScreenshotJpegBase64(options: {
maxBase64Length: number;
skipSelector?: string;
}): Promise {
if (typeof document === "undefined") throw new Error("Screenshots need a window.");
const root = document.getElementById("root") ?? document.body;
await waitForFonts();
const background = getComputedStyle(document.body).backgroundColor;
const skipSelector = options.skipSelector;
let { canvas } = paintElement(root, 1, {
background: isPaintableColor(background) ? background : "#000",
skip: skipSelector ? (element) => element.matches(skipSelector) : undefined,
});
for (const quality of [0.8, 0.7, 0.6, 0.6, 0.6]) {
const jpegBase64 = await blobToBase64(await canvasToJpegBlob(canvas, quality));
if (jpegBase64.length <= options.maxBase64Length) {
return { jpegBase64, width: canvas.width, height: canvas.height };
}
canvas = downscale(canvas, 0.8);
}
throw new Error("Screenshot is too large.");
}
export async function capturePaneScreenshotPngBase64(paneId?: string): Promise {
const target = findPaneScreenshotTarget(paneId);
if (!target) throw new Error("Pane is not visible.");
return captureElementPngBase64(target);
}