Newer
Older
bugtrail / packages / ui / src / AnnotationEditor.vue
<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="20" 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>