Newer
Older
bugtrail / packages / extension / src / content / overlay / RegionSelector.vue
<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>