194 lines
6.0 KiB
JavaScript
194 lines
6.0 KiB
JavaScript
import { toBlob } from 'html-to-image';
|
|
import { CANVAS_COLORS } from './constants';
|
|
|
|
export const OVERLAY_CAPTURE_SELECTORS = [
|
|
'.lineplot-overlay',
|
|
'.cs-overlay',
|
|
'.crop-overlay',
|
|
];
|
|
|
|
function encodeBase64(bytes) {
|
|
if (typeof Buffer !== 'undefined') {
|
|
return Buffer.from(bytes).toString('base64');
|
|
}
|
|
|
|
let binary = '';
|
|
for (let i = 0; i < bytes.length; i += 1) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
async function blobToDataUrl(blob) {
|
|
if (!blob) return null;
|
|
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
return `data:${blob.type || 'image/png'};base64,${encodeBase64(bytes)}`;
|
|
}
|
|
|
|
function getElementSize(el) {
|
|
const rect = el.getBoundingClientRect?.() ?? { width: 0, height: 0 };
|
|
return {
|
|
width: Math.max(1, Math.round(el.clientWidth || rect.width || 0)),
|
|
height: Math.max(1, Math.round(el.clientHeight || rect.height || 0)),
|
|
};
|
|
}
|
|
|
|
export async function waitForImageElement(img) {
|
|
if (img.complete && img.naturalWidth > 0) return;
|
|
if (typeof img.decode === 'function') {
|
|
try {
|
|
await img.decode();
|
|
return;
|
|
} catch {
|
|
// Fall back to load/error listeners below.
|
|
}
|
|
}
|
|
await new Promise((resolve) => {
|
|
const done = () => {
|
|
img.removeEventListener('load', done);
|
|
img.removeEventListener('error', done);
|
|
resolve();
|
|
};
|
|
img.addEventListener('load', done, { once: true });
|
|
img.addEventListener('error', done, { once: true });
|
|
});
|
|
}
|
|
|
|
export async function getCaptureImageDataUrl(img) {
|
|
const src = img.currentSrc || img.src;
|
|
if (!src) return null;
|
|
if (!src.startsWith('data:')) return src;
|
|
|
|
const { width, height } = getElementSize(img);
|
|
const scale = Math.min(2, globalThis.window?.devicePixelRatio || 1);
|
|
|
|
const canvas = globalThis.document.createElement('canvas');
|
|
canvas.width = Math.max(1, Math.round(width * scale));
|
|
canvas.height = Math.max(1, Math.round(height * scale));
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return src;
|
|
|
|
try {
|
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
return canvas.toDataURL('image/png');
|
|
} catch {
|
|
return src;
|
|
}
|
|
}
|
|
|
|
export function createCapturePlaceholder(
|
|
el,
|
|
dataUrl,
|
|
{ stretch = false, documentRef = globalThis.document, getComputedStyleFn = globalThis.window?.getComputedStyle?.bind(globalThis.window) } = {},
|
|
) {
|
|
const rect = el.getBoundingClientRect();
|
|
const style = getComputedStyleFn(el);
|
|
const placeholder = documentRef.createElement('div');
|
|
|
|
placeholder.style.display = style.display === 'inline' ? 'inline-block' : style.display;
|
|
placeholder.style.width = `${el.clientWidth || rect.width}px`;
|
|
placeholder.style.height = `${el.clientHeight || rect.height}px`;
|
|
placeholder.style.maxWidth = style.maxWidth;
|
|
placeholder.style.maxHeight = style.maxHeight;
|
|
placeholder.style.minWidth = style.minWidth;
|
|
placeholder.style.minHeight = style.minHeight;
|
|
placeholder.style.borderRadius = style.borderRadius;
|
|
placeholder.style.backgroundImage = `url("${dataUrl}")`;
|
|
placeholder.style.backgroundRepeat = 'no-repeat';
|
|
placeholder.style.backgroundPosition = 'center';
|
|
placeholder.style.backgroundSize = stretch ? '100% 100%' : 'contain';
|
|
placeholder.style.flexShrink = style.flexShrink;
|
|
|
|
return placeholder;
|
|
}
|
|
|
|
async function renderCanvasToDataUrl(canvas) {
|
|
try {
|
|
return canvas.toDataURL('image/png');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function renderElementToDataUrl(el, toBlobImpl) {
|
|
const { width, height } = getElementSize(el);
|
|
const blob = await toBlobImpl(el, {
|
|
width,
|
|
height,
|
|
backgroundColor: CANVAS_COLORS.bgDeep,
|
|
style: {
|
|
width: `${width}px`,
|
|
height: `${height}px`,
|
|
transform: 'none',
|
|
},
|
|
});
|
|
return blobToDataUrl(blob);
|
|
}
|
|
|
|
async function replaceElementsWithPlaceholders(elements, renderDataUrl, createPlaceholderFn, stretch) {
|
|
const restorers = [];
|
|
|
|
for (const el of elements) {
|
|
if (!el?.parentNode) continue;
|
|
const dataUrl = await renderDataUrl(el);
|
|
if (!dataUrl) continue;
|
|
|
|
const placeholder = createPlaceholderFn(el, dataUrl, { stretch });
|
|
el.parentNode.replaceChild(placeholder, el);
|
|
restorers.push(() => {
|
|
if (placeholder.parentNode) {
|
|
placeholder.parentNode.replaceChild(el, placeholder);
|
|
}
|
|
});
|
|
}
|
|
|
|
return restorers;
|
|
}
|
|
|
|
function defaultQueryAll(root, selector) {
|
|
return Array.from(root.querySelectorAll(selector));
|
|
}
|
|
|
|
function defaultNextFrame() {
|
|
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
}
|
|
|
|
export async function captureViewportBlob(viewportEl, options, deps = {}) {
|
|
const queryAll = deps.queryAll ?? defaultQueryAll;
|
|
const toBlobImpl = deps.toBlobImpl ?? toBlob;
|
|
const waitForImage = deps.waitForImageElement ?? waitForImageElement;
|
|
const renderImage = deps.renderImageToDataUrl ?? getCaptureImageDataUrl;
|
|
const renderCanvas = deps.renderCanvasToDataUrl ?? renderCanvasToDataUrl;
|
|
const renderOverlay = deps.renderOverlayToDataUrl ?? ((el) => renderElementToDataUrl(el, toBlobImpl));
|
|
const createPlaceholderFn = deps.createPlaceholder ?? createCapturePlaceholder;
|
|
const nextFrame = deps.nextFrame ?? defaultNextFrame;
|
|
const overlaySelectors = deps.overlaySelectors ?? OVERLAY_CAPTURE_SELECTORS;
|
|
const restorers = [];
|
|
|
|
const overlays = [];
|
|
const seen = new Set();
|
|
for (const selector of overlaySelectors) {
|
|
for (const el of queryAll(viewportEl, selector)) {
|
|
if (seen.has(el)) continue;
|
|
seen.add(el);
|
|
overlays.push(el);
|
|
}
|
|
}
|
|
|
|
const images = queryAll(viewportEl, 'img');
|
|
await Promise.all(images.map(waitForImage));
|
|
|
|
try {
|
|
restorers.push(...await replaceElementsWithPlaceholders(overlays, renderOverlay, createPlaceholderFn, true));
|
|
restorers.push(...await replaceElementsWithPlaceholders(queryAll(viewportEl, 'img'), renderImage, createPlaceholderFn, false));
|
|
restorers.push(...await replaceElementsWithPlaceholders(queryAll(viewportEl, 'canvas'), renderCanvas, createPlaceholderFn, true));
|
|
|
|
await nextFrame();
|
|
await nextFrame();
|
|
return await toBlobImpl(viewportEl, options);
|
|
} finally {
|
|
restorers.reverse().forEach((restore) => restore());
|
|
}
|
|
}
|