Newer
Older
bugtrail / packages / ui / src / ScreenshotViewer.vue
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import type { AnnotationShape } from "@ltt/shared";
import AnnotationEditor from "./AnnotationEditor.vue";

const props = defineProps<{
  /** Image URL to display. */
  src: string;
  /** Existing annotation shapes (fractions of the screenshot). */
  shapes?: AnnotationShape[] | null;
  /** Whether the current user may edit annotations. */
  editable?: boolean;
  /** Button labels; defaults keep the component usable without an i18n setup. */
  labels?: { edit?: string; save?: string; saving?: string; cancel?: string; expand?: string };
}>();

const emit = defineEmits<{
  (e: "save", shapes: AnnotationShape[]): void;
}>();

const text = {
  edit: "Edit annotations",
  save: "Save annotations",
  saving: "Saving…",
  cancel: "Cancel",
};

const editing = ref(false);
const draft = ref<AnnotationShape[]>(props.shapes ?? []);
const saving = ref(false);

async function startEdit() {
  draft.value = [...(props.shapes ?? [])];
  editing.value = true;
}

async function save() {
  saving.value = true;
  try {
    emit("save", [...draft.value]);
  } finally {
    saving.value = false;
    editing.value = false;
  }
}

// ---------- lightbox (fullscreen view with zoom + pan) ----------

const lightboxOpen = ref(false);
/** natural pixel size of the image */
const natural = ref({ w: 0, h: 0 });
/** current zoom factor; capped at 1 — beyond natural size there is no detail to reveal */
const zoom = ref(1);
/** smallest useful zoom (fit-to-screen at open time) */
const fitZoom = ref(1);
/** pan offset in screen px */
const offset = ref({ x: 0, y: 0 });
const panning = ref(false);
const lightboxEl = ref<HTMLDivElement>();
/** viewport point the wheel zoom is anchored to (relative to center) */
let wheelAnchor: { x: number; y: number } | null = null;

const percent = computed(() => Math.round(zoom.value * 100));

watch(
  () => props.src,
  () => {
    const image = new Image();
    image.onload = () => {
      natural.value = { w: image.naturalWidth, h: image.naturalHeight };
    };
    image.src = props.src;
  },
  { immediate: true }
);

function openLightbox() {
  lightboxOpen.value = true;
  resetView();
  document.addEventListener("keydown", onLightboxKeydown);
  requestAnimationFrame(() => lightboxEl.value?.focus());
}

function closeLightbox() {
  lightboxOpen.value = false;
  document.removeEventListener("keydown", onLightboxKeydown);
}

function resetView() {
  const { w, h } = natural.value;
  const vw = window.innerWidth - 32;
  const vh = window.innerHeight - 110;
  fitZoom.value = w && h ? Math.min(1, vw / w, vh / h) : 1;
  zoom.value = fitZoom.value;
  offset.value = { x: 0, y: 0 };
}

function onLightboxKeydown(event: KeyboardEvent) {
  if (event.key === "Escape") {
    event.preventDefault();
    closeLightbox();
  } else if (event.key === "+" || event.key === "=") {
    zoomBy(1.25);
  } else if (event.key === "-") {
    zoomBy(1 / 1.25);
  } else if (event.key === "0") {
    resetView();
  }
}

function zoomBy(factor: number) {
  // cap at 1: a screenshot has no detail beyond its natural resolution
  const next = Math.min(1, Math.max(0.05, zoom.value * factor));
  const applied = next / zoom.value;
  zoom.value = next;
  // keep the point under the cursor stable when zooming via wheel
  const anchor = wheelAnchor;
  if (anchor) {
    offset.value = {
      x: anchor.x - (anchor.x - offset.value.x) * applied,
      y: anchor.y - (anchor.y - offset.value.y) * applied,
    };
    wheelAnchor = null;
  }
}

function onWheel(event: WheelEvent) {
  event.preventDefault();
  const rect = lightboxEl.value?.getBoundingClientRect();
  wheelAnchor = rect
    ? { x: event.clientX - rect.left - rect.width / 2, y: event.clientY - rect.top - rect.height / 2 }
    : null;
  zoomBy(event.deltaY < 0 ? 1.15 : 1 / 1.15);
}

/** true while a pointer drag covers more than a few px — suppresses the backdrop click */
let panMoved = false;

function startPan(event: PointerEvent) {
  if ((event.target as HTMLElement).closest(".lightbox-toolbar")) return;
  panMoved = false;
  panning.value = true;
  const startX = event.clientX - offset.value.x;
  const startY = event.clientY - offset.value.y;
  const originX = event.clientX;
  const originY = event.clientY;
  const onMove = (move: PointerEvent) => {
    offset.value = { x: move.clientX - startX, y: move.clientY - startY };
    if (Math.abs(move.clientX - originX) > 3 || Math.abs(move.clientY - originY) > 3) panMoved = true;
  };
  const onUp = () => {
    panning.value = false;
    window.removeEventListener("pointermove", onMove);
    window.removeEventListener("pointerup", onUp);
  };
  window.addEventListener("pointermove", onMove);
  window.addEventListener("pointerup", onUp);
}

function onBackdropClick(event: MouseEvent) {
  // a drag ending off the image retargets the click to the overlay itself —
  // only an unmoved click on the backdrop closes
  if (panMoved) {
    panMoved = false;
    return;
  }
  if (event.target === lightboxEl.value) closeLightbox();
}

onBeforeUnmount(closeLightbox);
</script>

<template>
  <div class="screenshot-viewer">
    <AnnotationEditor
      v-if="editing"
      :src="src"
      v-model:shapes="draft"
      editable
    />
    <div v-else class="screenshot-frame">
      <AnnotationEditor :src="src" :shapes="shapes ?? []" />
      <button type="button" class="screenshot-expand" :title="labels?.expand ?? 'Open fullscreen'" @click="openLightbox">
        <i class="ph ph-arrows-out" />
      </button>
    </div>
    <div v-if="editable" class="screenshot-viewer-actions">
      <template v-if="editing">
        <button type="button" class="screenshot-btn" :disabled="saving" @click="save">
          {{ saving ? props.labels?.saving ?? text.saving : props.labels?.save ?? text.save }}
        </button>
        <button type="button" class="screenshot-btn-ghost" @click="editing = false">
          {{ props.labels?.cancel ?? text.cancel }}
        </button>
      </template>
      <button v-else type="button" class="screenshot-btn-ghost" @click="startEdit">
        <i class="ph ph-pen" /> {{ props.labels?.edit ?? text.edit }}
      </button>
    </div>

    <!-- fullscreen viewer: wheel zoom (capped at natural size), drag to pan -->
    <div
      v-if="lightboxOpen"
      ref="lightboxEl"
      class="lightbox"
      tabindex="-1"
      @wheel.prevent="onWheel"
      @pointerdown="startPan"
      @click="onBackdropClick"
    >
      <img
        class="lightbox-img"
        :src="src"
        alt=""
        draggable="false"
        :class="{ 'is-panning': panning }"
        :style="{
          width: `${natural.w * zoom}px`,
          transform: `translate(-50%, -50%) translate(${offset.x}px, ${offset.y}px)`,
        }"
      />
      <div class="lightbox-toolbar">
        <button type="button" class="lightbox-btn" title="Zoom out" @click="zoomBy(1 / 1.25)">
          <i class="ph ph-minus" />
        </button>
        <span class="lightbox-zoom">{{ percent }}%</span>
        <button type="button" class="lightbox-btn" title="Zoom in" @click="zoomBy(1.25)">
          <i class="ph ph-plus" />
        </button>
        <button type="button" class="lightbox-btn" title="Fit to screen" @click="resetView">
          <i class="ph ph-arrows-in" />
        </button>
        <span class="lightbox-sep" />
        <button type="button" class="lightbox-btn" title="Close (Esc)" @click="closeLightbox">
          <i class="ph ph-x" />
        </button>
      </div>
    </div>
  </div>
</template>

<style scoped>
.screenshot-viewer {
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 0;
}
.screenshot-frame {
  position: relative;
  min-width: 0;
}
.screenshot-expand {
  position: absolute;
  top: 8px;
  right: 8px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border: 1px solid var(--color-border, #2f334d);
  background: rgba(16, 18, 28, 0.85);
  color: var(--color-text, #c0caf5);
  font-size: 16px;
  cursor: zoom-in;
  z-index: 2;
}
.screenshot-expand:hover {
  color: var(--color-accent, #7aa2f7);
  border-color: var(--color-accent, #7aa2f7);
}
.screenshot-viewer-actions {
  display: flex;
  gap: 8px;
}
.screenshot-btn-ghost,
.screenshot-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 12px;
  border: 2px solid var(--color-border, #2f334d);
  background: transparent;
  color: inherit;
  font: inherit;
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  cursor: pointer;
}
.screenshot-btn {
  border-color: var(--color-accent, #7aa2f7);
  color: var(--color-accent, #7aa2f7);
}
.screenshot-btn-ghost:hover {
  border-color: var(--color-accent, #7aa2f7);
}

.lightbox {
  position: fixed;
  inset: 0;
  z-index: 1000;
  overflow: hidden;
  background: rgba(10, 11, 16, 0.94);
  cursor: grab;
  outline: none;
}
.lightbox:active {
  cursor: grabbing;
}
.lightbox-img {
  position: absolute;
  left: 50%;
  top: 50%;
  height: auto;
  max-width: none;
  image-rendering: pixelated;
  user-select: none;
  box-shadow: 0 0 60px rgba(0, 0, 0, 0.6);
}
.lightbox-img.is-panning {
  cursor: grabbing;
}
.lightbox-toolbar {
  position: absolute;
  left: 50%;
  bottom: 18px;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 6px 10px;
  background: var(--color-surface, #1f2335);
  border: 2px solid var(--color-border, #2f334d);
  cursor: default;
}
.lightbox-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  padding: 0;
  border: none;
  background: transparent;
  color: var(--color-text, #c0caf5);
  font-size: 16px;
  cursor: pointer;
}
.lightbox-btn:hover {
  color: var(--color-accent, #7aa2f7);
}
.lightbox-zoom {
  min-width: 46px;
  text-align: center;
  font-size: 12px;
}
.lightbox-sep {
  width: 1px;
  height: 20px;
  background: var(--color-border, #2f334d);
  margin: 0 4px;
}
</style>