<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import browser from "webextension-polyfill";
/**
* Pre-composer screenshot step: captures the pristine page (before any dim
* overlay renders), then lets the user either drag a region on the page or
* take the whole page. Full-page capture scrolls and stitches viewport
* captures; fixed/sticky elements are hidden while scrolling so headers do
* not repeat in every frame.
*/
const emit = defineEmits<{
(e: "confirm", dataUrl: string): void;
(e: "cancel"): void;
}>();
const phase = ref<"capturing" | "select" | "stitching">("capturing");
const error = ref<string | null>(null);
/** pristine viewport capture taken before the overlay renders */
let rawCapture: string | null = null;
/** selection rect in CSS pixels, relative to the viewport */
const dragRect = ref<{ x: number; y: number; w: number; h: number } | null>(null);
const hint = ref("Выделите область скриншота мышью");
const layerRef = ref<HTMLDivElement>();
let dragStart: { x: number; y: number } | null = null;
onMounted(async () => {
try {
const response = (await browser.runtime.sendMessage({ type: "capture" })) as {
ok: boolean;
data?: { dataUrl: string };
error?: string;
};
if (!response?.ok || !response.data) throw new Error(response?.error ?? "capture failed");
rawCapture = response.data.dataUrl;
phase.value = "select";
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
phase.value = "select";
}
window.addEventListener("keydown", onKeyDown, true);
});
onUnmounted(() => {
window.removeEventListener("keydown", onKeyDown, true);
});
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
emit("cancel");
} else if (event.key === "Enter" && phase.value === "select") {
event.preventDefault();
event.stopPropagation();
void takeWholePage();
}
}
// ---------- selection ----------
function onMouseDown(event: MouseEvent) {
if (phase.value !== "select") return;
event.preventDefault();
dragStart = { x: event.clientX, y: event.clientY };
dragRect.value = { x: event.clientX, y: event.clientY, w: 0, h: 0 };
}
function onMouseMove(event: MouseEvent) {
if (!dragStart) return;
dragRect.value = {
x: Math.min(dragStart.x, event.clientX),
y: Math.min(dragStart.y, event.clientY),
w: Math.abs(event.clientX - dragStart.x),
h: Math.abs(event.clientY - dragStart.y),
};
}
async function onMouseUp() {
if (!dragStart || !dragRect.value) return;
dragStart = null;
const rect = dragRect.value;
dragRect.value = null;
// a click without a drag counts as a mis-click — keep selecting
if (rect.w < 8 || rect.h < 8 || !rawCapture) return;
emit("confirm", await cropDataUrl(rawCapture, rect));
}
async function cropDataUrl(dataUrl: string, rect: { x: number; y: number; w: number; h: number }): Promise<string> {
const image = await loadImage(dataUrl);
// captureVisibleTab renders at devicePixelRatio scale
const dpr = image.naturalWidth / window.innerWidth;
const canvas = document.createElement("canvas");
canvas.width = Math.round(rect.w * dpr);
canvas.height = Math.round(rect.h * dpr);
const c = canvas.getContext("2d")!;
c.drawImage(
image,
Math.round(rect.x * dpr),
Math.round(rect.y * dpr),
canvas.width,
canvas.height,
0,
0,
canvas.width,
canvas.height
);
return canvas.toDataURL("image/png");
}
// ---------- whole page (scroll + stitch) ----------
async function takeWholePage() {
if (!rawCapture) return;
const host = document.getElementById("ltt-overlay-host");
const doc = document.documentElement;
const pageHeight = Math.max(doc.scrollHeight, document.body?.scrollHeight ?? 0);
const viewportH = window.innerHeight;
// the visible viewport is all there is — no scrolling needed
if (pageHeight <= viewportH + 2) {
emit("confirm", rawCapture);
return;
}
phase.value = "stitching";
hint.value = "Захват полной страницы…";
try {
const dataUrl = await captureFullPage(host, pageHeight, viewportH);
emit("confirm", dataUrl);
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
phase.value = "select";
hint.value = "Выделите область скриншота мышью";
}
}
async function captureFullPage(host: HTMLElement | null, pageHeight: number, viewportH: number): Promise<string> {
const scrollY0 = window.scrollY;
const scrollX0 = window.scrollX;
// hide the overlay and fixed/sticky chrome so they don't repeat in every tile
if (host) host.style.display = "none";
const hidden = hideFixedElements();
const dpr = window.devicePixelRatio || 1;
const tiles: { y: number; image: HTMLImageElement }[] = [];
try {
for (let y = 0; y < pageHeight; y += viewportH) {
window.scrollTo(scrollX0, y);
await paintSettle();
tiles.push({ y, image: await captureTile() });
}
} finally {
window.scrollTo(scrollX0, scrollY0);
if (host) host.style.display = "";
restoreFixedElements(hidden);
}
const first = tiles[0].image;
const canvas = document.createElement("canvas");
canvas.width = first.naturalWidth;
canvas.height = Math.round(pageHeight * dpr);
const c = canvas.getContext("2d")!;
for (const tile of tiles) {
c.drawImage(tile.image, 0, Math.round(tile.y * dpr));
}
return canvas.toDataURL("image/png");
}
function paintSettle(): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, 250));
}
/**
* captureVisibleTab is throttled (~2 calls/sec); a tiled full-page capture
* hits that limit, so retry with a growing delay before giving up.
*/
async function captureTile(): Promise<HTMLImageElement> {
let lastError = "capture failed";
for (let attempt = 0; attempt < 5; attempt++) {
if (attempt > 0) await new Promise((r) => setTimeout(r, 400 * attempt));
const response = (await browser.runtime.sendMessage({ type: "capture" })) as {
ok: boolean;
data?: { dataUrl: string };
error?: string;
};
if (response?.ok && response.data) return loadImage(response.data.dataUrl);
lastError = response?.error ?? lastError;
}
throw new Error(lastError);
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("image decode failed"));
image.src = src;
});
}
let hiddenFixed: { el: HTMLElement; visibility: string }[] = [];
function hideFixedElements(): { el: HTMLElement; visibility: string }[] {
const saved: { el: HTMLElement; visibility: string }[] = [];
for (const el of document.body
? (Array.from(document.body.querySelectorAll("*")) as HTMLElement[])
: []) {
const position = getComputedStyle(el).position;
if (position !== "fixed" && position !== "sticky") continue;
if (el.id === "ltt-overlay-host") continue;
saved.push({ el, visibility: el.style.visibility });
el.style.visibility = "hidden";
}
hiddenFixed = saved;
return saved;
}
function restoreFixedElements(saved: { el: HTMLElement; visibility: string }[]) {
for (const { el, visibility } of saved) el.style.visibility = visibility;
hiddenFixed = [];
}
</script>
<template>
<!-- nothing renders until the pristine capture has landed: the dim layer
must never be inside the screenshot -->
<div v-if="phase !== 'capturing'" ref="layerRef" class="region-layer" @mousedown="onMouseDown" @mousemove="onMouseMove" @mouseup="onMouseUp">
<div v-if="dragRect" class="region-rect" :style="{
left: `${dragRect.x}px`,
top: `${dragRect.y}px`,
width: `${dragRect.w}px`,
height: `${dragRect.h}px`,
}" />
<div class="region-bar" @mousedown.stop>
<template v-if="phase === 'stitching'">
<i class="ph ph-spinner ph-spin" /> {{ hint }}
</template>
<template v-else>
<span class="region-hint">{{ error ?? hint }}</span>
<button type="button" class="region-btn" title="Вся страница (Enter)" @click="takeWholePage">
<i class="ph ph-arrows-out" /> Вся страница
</button>
<button type="button" class="region-btn region-btn-ghost" title="Отмена (Esc)" @click="emit('cancel')">
<i class="ph ph-x" /> Отмена
</button>
</template>
</div>
</div>
</template>
<style scoped>
.region-layer {
position: fixed;
inset: 0;
cursor: crosshair;
background: rgba(10, 12, 20, 0.45);
}
.region-rect {
position: fixed;
box-sizing: border-box;
border: 2px solid var(--color-accent, #7aa2f7);
background: rgba(122, 162, 247, 0.12);
pointer-events: none;
}
.region-bar {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--color-surface, #1f2335);
border: 2px solid var(--color-border, #2f334d);
color: var(--color-text, #c0caf5);
font-size: 12px;
cursor: default;
}
.region-hint {
font-size: 12px;
letter-spacing: normal;
}
.region-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 2px solid var(--color-accent, #7aa2f7);
background: var(--color-accent, #7aa2f7);
color: var(--color-bg, #10121c);
font: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.region-btn-ghost {
background: transparent;
color: inherit;
border-color: var(--color-border, #2f334d);
}
.ph-spin {
animation: ltt-spin 1s linear infinite;
}
@keyframes ltt-spin {
to {
transform: rotate(360deg);
}
}
</style>