<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import type { AnnotationShape } from "@ltt/shared";
import { ANNOTATION_COLORS } from "@ltt/shared";
import { drawShape } from "./shapeDraw";
const props = withDefaults(
defineProps<{
/** Image URL to draw over. */
src: string;
/** Vector shapes in screenshot-fraction coordinates. */
shapes?: AnnotationShape[];
editable?: boolean;
/** How the canvas fits its box: full container width (default) or contained inside it. */
fit?: "width" | "contain";
}>(),
{ shapes: () => [], editable: false, fit: "width" }
);
const emit = defineEmits<{
/** Fired on every edit (undo included) with the new shapes array. */
(e: "update:shapes", shapes: AnnotationShape[]): void;
}>();
type Tool = AnnotationShape["tool"];
const tool = ref<Tool>("pen");
const color = ref<string>(ANNOTATION_COLORS[0]);
const strokeWidth = ref<number>(3);
const drawing = ref(false);
const current = ref<AnnotationShape | null>(null);
const canvasRef = ref<HTMLCanvasElement>();
const containerRef = ref<HTMLDivElement>();
let image: HTMLImageElement | null = null;
let ctx: CanvasRenderingContext2D | null = null;
const tools: { id: Tool; icon: string; label: string }[] = [
{ id: "pen", icon: "ph-pen", label: "Pen" },
{ id: "arrow", icon: "ph-arrow-up-right", label: "Arrow" },
{ id: "rect", icon: "ph-rectangle", label: "Rectangle" },
{ id: "text", icon: "ph-text-t", label: "Text" },
];
const shapes = computed(() => props.shapes);
function loadImage(src: string) {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
image = img;
const canvas = canvasRef.value;
if (!canvas) return;
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
ctx = canvas.getContext("2d");
redraw();
};
img.src = src;
}
function redraw() {
if (!ctx || !image) return;
ctx.clearRect(0, 0, image.naturalWidth, image.naturalHeight);
ctx.drawImage(image, 0, 0);
for (const shape of [...shapes.value, ...(current.value ? [current.value] : [])]) {
drawShape(ctx, shape, image.naturalWidth, image.naturalHeight);
}
}
function toFraction(event: PointerEvent): [number, number] {
const canvas = canvasRef.value!;
const rect = canvas.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width;
const y = (event.clientY - rect.top) / rect.height;
return [
Math.min(Math.max(x, 0), 1),
Math.min(Math.max(y, 0), 1),
];
}
function onPointerDown(event: PointerEvent) {
if (!props.editable || !canvasRef.value) return;
canvasRef.value.setPointerCapture(event.pointerId);
const [x, y] = toFraction(event);
if (tool.value === "text") {
const text = window.prompt("Annotation text");
if (text) {
const shape: AnnotationShape = {
tool: "text", color: color.value, strokeWidth: strokeWidth.value, pos: [x, y], text,
};
emit("update:shapes", [...shapes.value, shape]);
}
return;
}
drawing.value = true;
current.value =
tool.value === "rect"
? { tool: "rect", color: color.value, strokeWidth: strokeWidth.value, rect: [x, y, 0, 0] }
: { tool: tool.value as "pen" | "arrow", color: color.value, strokeWidth: strokeWidth.value, points: [[x, y]] };
}
function onPointerMove(event: PointerEvent) {
if (!drawing.value || !current.value) return;
const [x, y] = toFraction(event);
if (current.value.tool === "rect" && current.value.rect) {
const [sx, sy] = current.value.rect;
current.value = { ...current.value, rect: [sx, sy, x - sx, y - sy] };
} else if (current.value.points) {
current.value = { ...current.value, points: [...current.value.points, [x, y]] };
}
redraw();
}
function onPointerUp() {
if (!drawing.value || !current.value) return;
drawing.value = false;
const shape = current.value;
current.value = null;
// drop degenerate shapes
const empty =
(shape.tool === "pen" && (shape.points?.length ?? 0) < 2) ||
(shape.tool === "arrow" && (shape.points?.length ?? 0) < 2) ||
(shape.tool === "rect" && Math.abs(shape.rect?.[2] ?? 0) < 0.005 && Math.abs(shape.rect?.[3] ?? 0) < 0.005);
if (!empty) emit("update:shapes", [...shapes.value, shape]);
redraw();
}
function undo() {
emit("update:shapes", shapes.value.slice(0, -1));
redraw();
}
/** Flatten shapes onto the image at natural resolution and return a PNG blob. */
async function exportPng(): Promise<Blob | null> {
if (!image) return null;
const off = document.createElement("canvas");
off.width = image.naturalWidth;
off.height = image.naturalHeight;
const c = off.getContext("2d");
if (!c) return null;
c.drawImage(image, 0, 0);
for (const shape of shapes.value) drawShape(c, shape, off.width, off.height);
return new Promise((resolve) => off.toBlob((blob) => resolve(blob), "image/png"));
}
watch(() => props.src, () => loadImage(props.src));
watch(() => props.shapes, () => redraw(), { deep: true });
onMounted(() => loadImage(props.src));
onUnmounted(() => {
image = null;
ctx = null;
});
defineExpose({ exportPng, undo });
</script>
<template>
<div ref="containerRef" class="annotation-editor" :class="{ 'is-contain': fit === 'contain' }">
<div v-if="editable" class="annotation-toolbar">
<div class="annotation-toolbar-group">
<button
v-for="t in tools"
:key="t.id"
type="button"
class="annotation-tool"
:class="{ 'is-active': tool === t.id }"
:title="t.label"
@click="tool = t.id"
>
<i class="ph" :class="t.icon" />
</button>
</div>
<div class="annotation-toolbar-group">
<button
v-for="c in ANNOTATION_COLORS"
:key="c"
type="button"
class="annotation-color"
:class="{ 'is-active': color === c }"
:style="{ backgroundColor: c }"
@click="color = c"
/>
</div>
<div class="annotation-toolbar-group">
<label class="annotation-width-label">
Size
<input v-model.number="strokeWidth" type="range" min="1" max="12" step="1" />
</label>
<button type="button" class="annotation-tool" title="Undo" @click="undo">
<i class="ph ph-arrow-counter-clockwise" />
</button>
</div>
</div>
<div class="annotation-canvas-wrap">
<canvas
ref="canvasRef"
class="annotation-canvas"
:class="{ 'is-editable': editable }"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
/>
</div>
</div>
</template>
<style scoped>
.annotation-editor {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.annotation-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.annotation-toolbar-group {
display: flex;
gap: 4px;
align-items: center;
padding: 4px 8px;
border: 2px solid var(--color-border, #2f334d);
background: var(--color-surface, #1a1c2b);
}
.annotation-tool {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
font-size: 16px;
}
.annotation-tool:hover,
.annotation-tool.is-active {
color: var(--color-accent, #7aa2f7);
background: rgba(122, 162, 247, 0.12);
}
.annotation-color {
width: 20px;
height: 20px;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
}
.annotation-color.is-active {
border-color: #fff;
outline: 1px solid rgba(0, 0, 0, 0.6);
}
.annotation-width-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.annotation-canvas-wrap {
border: 2px solid var(--color-border, #2f334d);
background: #10121c;
max-width: 100%;
overflow: hidden;
}
.annotation-canvas {
display: block;
width: 100%;
height: auto;
touch-action: none;
}
.annotation-canvas.is-editable {
cursor: crosshair;
}
/* contain mode: the editor fills its parent and the canvas fits inside
without cropping — used by the fullscreen editor overlay */
.annotation-editor.is-contain {
height: 100%;
min-height: 0;
}
.annotation-editor.is-contain .annotation-canvas-wrap {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: auto;
}
.annotation-editor.is-contain .annotation-canvas {
width: auto;
height: auto;
max-width: 100%;
max-height: 100%;
}
</style>