<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>