| <script lang="ts"> |
| import { onMount, onDestroy, createEventDispatcher } from "svelte"; |
| import { BoundingBox, Hand, Trash } from "./icons/index"; |
| import ModalBox from "./ModalBox.svelte"; |
| import Box from "./Box"; |
| import { Colors } from './Colors.js'; |
| import AnnotatedImageData from "./AnnotatedImageData"; |
|
|
| enum Mode {creation, drag} |
|
|
| export let imageUrl: string | null = null; |
| export let interactive: boolean; |
| export let boxAlpha = 0.5; |
| export let boxMinSize = 25; |
| export let handleSize: number; |
| export let boxThickness: number; |
| export let boxSelectedThickness: number; |
| export let value: null | AnnotatedImageData; |
| export let choices = []; |
| export let choicesColors = []; |
| export let disableEditBoxes: boolean = false; |
| export let height: number | string = "100%"; |
| export let width: number | string = "100%"; |
| export let singleBox: boolean = false; |
| export let showRemoveButton: boolean = null; |
| export let handlesCursor: boolean = true; |
|
|
| if (showRemoveButton === null) { |
| showRemoveButton = (disableEditBoxes); |
| } |
|
|
| let canvas: HTMLCanvasElement; |
| let ctx: CanvasRenderingContext2D; |
| let image = null; |
| let selectedBox = -1; |
| let mode: Mode = Mode.drag; |
|
|
| if (value !== null && value.boxes.length == 0) { |
| mode = Mode.creation; |
| } |
|
|
| let canvasXmin = 0; |
| let canvasYmin = 0; |
| let canvasXmax = 0; |
| let canvasYmax = 0; |
| let scaleFactor = 1.0; |
|
|
| let imageWidth = 0; |
| let imageHeight = 0; |
|
|
| let editModalVisible = false; |
| let newModalVisible = false; |
|
|
| const dispatch = createEventDispatcher<{ |
| change: undefined; |
| }>(); |
|
|
| function colorHexToRGB(hex: string) { |
| var r = parseInt(hex.slice(1, 3), 16), |
| g = parseInt(hex.slice(3, 5), 16), |
| b = parseInt(hex.slice(5, 7), 16); |
| return "rgb(" + r + ", " + g + ", " + b + ")"; |
| } |
|
|
| function colorRGBAToHex(rgba: string) { |
| const rgbaValues = rgba.match(/(\d+(\.\d+)?)/g); |
| const r = parseInt(rgbaValues[0]); |
| const g = parseInt(rgbaValues[1]); |
| const b = parseInt(rgbaValues[2]); |
| const hex = "#" + ((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1); |
| return hex; |
| } |
| |
| function draw() { |
| if (ctx) { |
| ctx.clearRect(0, 0, canvas.width, canvas.height); |
| if (image !== null){ |
| ctx.drawImage(image, canvasXmin, canvasYmin, imageWidth, imageHeight); |
| } |
| for (const box of value.boxes.slice().reverse()) { |
| box.render(ctx); |
| } |
| } |
| } |
|
|
| function selectBox(index: number) { |
| selectedBox = index; |
| value.boxes.forEach(box => {box.setSelected(false);}); |
| if (index >= 0 && index < value.boxes.length){ |
| value.boxes[index].setSelected(true); |
| } |
| draw(); |
| } |
|
|
| function handlePointerDown(event: PointerEvent) { |
| if (!interactive) { |
| return; |
| } |
|
|
| if ( |
| event.target instanceof Element && |
| event.target.hasPointerCapture(event.pointerId) |
| ) { |
| event.target.releasePointerCapture(event.pointerId); |
| } |
|
|
| if (mode === Mode.creation) { |
| createBox(event); |
| } else if (mode === Mode.drag) { |
| clickBox(event); |
| } |
| } |
|
|
| function clickBox(event: PointerEvent) { |
| const rect = canvas.getBoundingClientRect(); |
| const mouseX = event.clientX - rect.left; |
| const mouseY = event.clientY - rect.top; |
|
|
| |
| for (const [i, box] of value.boxes.entries()) { |
| const handleIndex = box.indexOfPointInsideHandle(mouseX, mouseY); |
| if (handleIndex >= 0) { |
| selectBox(i); |
| box.startResize(handleIndex, event); |
| return; |
| } |
| } |
|
|
| |
| for (const [i, box] of value.boxes.entries()) { |
| if (box.isPointInsideBox(mouseX, mouseY)) { |
| selectBox(i); |
| box.startDrag(event); |
| return; |
| } |
| } |
|
|
| if (!singleBox) { |
| selectBox(-1); |
| } |
| } |
|
|
| function handlePointerUp(event: PointerEvent) { |
| dispatch("change"); |
| } |
|
|
| function handlePointerMove(event: PointerEvent) { |
| if (value === null) { |
| return; |
| } |
|
|
| if (mode !== Mode.drag) { |
| return; |
| } |
|
|
| const rect = canvas.getBoundingClientRect(); |
| const mouseX = event.clientX - rect.left; |
| const mouseY = event.clientY - rect.top; |
|
|
| for (const [_, box] of value.boxes.entries()) { |
| const handleIndex = box.indexOfPointInsideHandle(mouseX, mouseY); |
| if (handleIndex >= 0) { |
| canvas.style.cursor = box.resizeHandles[handleIndex].cursor; |
| return; |
| } |
| } |
|
|
| canvas.style.cursor = "default"; |
| } |
|
|
| function handleKeyPress(event: KeyboardEvent) { |
| if (!interactive) { |
| return; |
| } |
|
|
| switch (event.key) { |
| case "Delete": |
| onDeleteBox(); |
| break; |
| } |
| } |
|
|
| function createBox(event: PointerEvent) { |
| const rect = canvas.getBoundingClientRect(); |
| const x = (event.clientX - rect.left - canvasXmin) / scaleFactor; |
| const y = (event.clientY - rect.top - canvasYmin) / scaleFactor; |
| let color; |
| if (choicesColors.length > 0) { |
| color = colorHexToRGB(choicesColors[0]); |
| } else if (singleBox) { |
| if (value.boxes.length > 0) { |
| color = value.boxes[0].color; |
| } else { |
| color = Colors[0]; |
| } |
| } else { |
| color = Colors[value.boxes.length % Colors.length]; |
| } |
| |
| let box = new Box( |
| draw, |
| onBoxFinishCreation, |
| canvasXmin, |
| canvasYmin, |
| canvasXmax, |
| canvasYmax, |
| "", |
| x, |
| y, |
| x, |
| y, |
| color, |
| boxAlpha, |
| boxMinSize, |
| handleSize, |
| boxThickness, |
| boxSelectedThickness |
| ); |
| box.startCreating(event, rect.left, rect.top); |
| if (singleBox) { |
| value.boxes = [box]; |
| } else { |
| value.boxes = [box, ...value.boxes]; |
| } |
| selectBox(0); |
| draw(); |
| dispatch("change"); |
| } |
|
|
| function setCreateMode() { |
| mode = Mode.creation; |
| canvas.style.cursor = "crosshair"; |
| } |
|
|
| function setDragMode() { |
| mode = Mode.drag; |
| canvas.style.cursor = "default"; |
| } |
|
|
| function onBoxFinishCreation() { |
| if (selectedBox >= 0 && selectedBox < value.boxes.length) { |
| if (value.boxes[selectedBox].getArea() < 1) { |
| onDeleteBox(); |
| } else { |
| if (!disableEditBoxes) { |
| newModalVisible = true; |
| } |
| if (singleBox) { |
| setDragMode(); |
| } |
| } |
| } |
| } |
|
|
| function onEditBox() { |
| if (selectedBox >= 0 && selectedBox < value.boxes.length && !disableEditBoxes) { |
| editModalVisible = true; |
| } |
| } |
|
|
| function handleDoubleClick(event: MouseEvent){ |
| if (!interactive) { |
| return; |
| } |
| |
| onEditBox(); |
| } |
|
|
| function onModalEditChange(event) { |
| editModalVisible = false; |
| const { detail } = event; |
| let label = detail.label; |
| let color = detail.color; |
| let ret = detail.ret; |
| if (selectedBox >= 0 && selectedBox < value.boxes.length) { |
| let box = value.boxes[selectedBox]; |
| if (ret == 1) { |
| box.label = label; |
| box.color = colorHexToRGB(color); |
| draw(); |
| dispatch("change"); |
| } else if (ret == -1) { |
| onDeleteBox(); |
| } |
| } |
| } |
|
|
| function onModalNewChange(event) { |
| newModalVisible = false; |
| const { detail } = event; |
| let label = detail.label; |
| let color = detail.color; |
| let ret = detail.ret; |
| if (selectedBox >= 0 && selectedBox < value.boxes.length) { |
| let box = value.boxes[selectedBox]; |
| if (ret == 1) { |
| box.label = label; |
| box.color = colorHexToRGB(color); |
| draw(); |
| dispatch("change"); |
| } else { |
| onDeleteBox(); |
| } |
| } |
| } |
|
|
| function onDeleteBox() { |
| if (selectedBox >= 0 && selectedBox < value.boxes.length) { |
| value.boxes.splice(selectedBox, 1); |
| selectBox(-1); |
| if (singleBox) { |
| setCreateMode(); |
| } |
| dispatch("change"); |
| } |
| } |
|
|
| function resize() { |
| if (canvas) { |
| scaleFactor = 1; |
| canvas.width = canvas.clientWidth; |
| if (image !== null) { |
| if (image.width > canvas.width) { |
| scaleFactor = canvas.width / image.width; |
| imageWidth = image.width * scaleFactor; |
| imageHeight = image.height * scaleFactor; |
| canvasXmin = 0; |
| canvasYmin = 0; |
| canvasXmax = imageWidth; |
| canvasYmax = imageHeight; |
| canvas.height = imageHeight; |
| } else { |
| imageWidth = image.width; |
| imageHeight = image.height; |
| var x = (canvas.width - imageWidth) / 2; |
| canvasXmin = x; |
| canvasYmin = 0; |
| canvasXmax = x + imageWidth; |
| canvasYmax = image.height; |
| canvas.height = imageHeight; |
| } |
| } else { |
| canvasXmin = 0; |
| canvasYmin = 0; |
| canvasXmax = canvas.width; |
| canvasYmax = canvas.height; |
| canvas.height = canvas.clientHeight; |
| } |
| if (canvasXmax > 0 && canvasYmax > 0){ |
| for (const box of value.boxes) { |
| box.canvasXmin = canvasXmin; |
| box.canvasYmin = canvasYmin; |
| box.canvasXmax = canvasXmax; |
| box.canvasYmax = canvasYmax; |
| box.setScaleFactor(scaleFactor); |
| } |
| } |
| draw(); |
| dispatch("change"); |
| } |
| } |
| const observer = new ResizeObserver(resize); |
|
|
| function parseInputBoxes() { |
| for (let i = 0; i < value.boxes.length; i++) { |
| let box = value.boxes[i]; |
| if (!(box instanceof Box)) { |
| let color = ""; |
| let label = ""; |
| if (box.hasOwnProperty("color")) { |
| color = box["color"]; |
| if (Array.isArray(color) && color.length === 3) { |
| color = `rgb(${color[0]}, ${color[1]}, ${color[2]})`; |
| } |
| } else { |
| color = Colors[i % Colors.length]; |
| } |
| if (box.hasOwnProperty("label")) { |
| label = box["label"]; |
| } |
| box = new Box( |
| draw, |
| onBoxFinishCreation, |
| canvasXmin, |
| canvasYmin, |
| canvasXmax, |
| canvasYmax, |
| label, |
| box["xmin"], |
| box["ymin"], |
| box["xmax"], |
| box["ymax"], |
| color, |
| boxAlpha, |
| boxMinSize, |
| handleSize, |
| boxThickness, |
| boxSelectedThickness |
| ); |
| value.boxes[i] = box; |
| } |
| } |
| } |
|
|
| $: { |
| value; |
| setImage(); |
| parseInputBoxes(); |
| resize(); |
| draw(); |
| } |
|
|
| function setImage(){ |
| if (imageUrl !== null) { |
| if (image === null || image.src != imageUrl) { |
| image = new Image(); |
| image.src = imageUrl; |
| image.onload = function(){ |
| resize(); |
| draw(); |
| } |
| } |
| } |
| } |
|
|
| onMount(() => { |
| if (Array.isArray(choices) && choices.length > 0) { |
| if (!Array.isArray(choicesColors) || choicesColors.length == 0) { |
| for (let i = 0; i < choices.length; i++) { |
| let color = Colors[i % Colors.length]; |
| choicesColors.push(colorRGBAToHex(color)); |
| } |
| } |
| } |
|
|
| ctx = canvas.getContext("2d"); |
| observer.observe(canvas); |
|
|
| if (selectedBox < 0 && value !== null && value.boxes.length > 0) { |
| selectBox(0); |
| } |
| setImage(); |
| resize(); |
| draw(); |
| }); |
| |
| function handleCanvasFocus() { |
| document.addEventListener("keydown", handleKeyPress); |
| } |
| |
| function handleCanvasBlur() { |
| document.removeEventListener("keydown", handleKeyPress); |
| } |
|
|
| onDestroy(() => { |
| document.removeEventListener("keydown", handleKeyPress); |
| }); |
|
|
| </script> |
|
|
| <div |
| class="canvas-container" |
| tabindex="-1" |
| on:focusin={handleCanvasFocus} |
| on:focusout={handleCanvasBlur} |
| > |
| <canvas |
| bind:this={canvas} |
| on:pointerdown={handlePointerDown} |
| on:pointerup={handlePointerUp} |
| on:pointermove={handlesCursor ? handlePointerMove : null} |
| on:dblclick={handleDoubleClick} |
| style="height: {height}; width: {width};" |
| class="canvas-annotator" |
| ></canvas> |
| </div> |
|
|
| {#if interactive} |
| <span class="canvas-control"> |
| <button |
| class="icon" |
| class:selected={mode === Mode.creation} |
| aria-label="Create box" |
| on:click={() => setCreateMode()}><BoundingBox/></button |
| > |
| <button |
| class="icon" |
| class:selected={mode === Mode.drag} |
| aria-label="Edit boxes" |
| on:click={() => setDragMode()}><Hand/></button |
| > |
| {#if showRemoveButton} |
| <button |
| class="icon" |
| aria-label="Remove boxes" |
| on:click={() => onDeleteBox()}><Trash/></button |
| > |
| {/if} |
| </span> |
| {/if} |
|
|
| {#if editModalVisible} |
| <ModalBox |
| on:change={onModalEditChange} |
| on:enter{onModalEditChange} |
| choices={choices} |
| choicesColors={choicesColors} |
| label={selectedBox >= 0 && selectedBox < value.boxes.length ? value.boxes[selectedBox].label : ""} |
| color={selectedBox >= 0 && selectedBox < value.boxes.length ? colorRGBAToHex(value.boxes[selectedBox].color) : ""} |
| /> |
| {/if} |
|
|
| {#if newModalVisible} |
| <ModalBox |
| on:change={onModalNewChange} |
| on:enter{onModalNewChange} |
| choices={choices} |
| showRemove={false} |
| choicesColors={choicesColors} |
| label={selectedBox >= 0 && selectedBox < value.boxes.length ? value.boxes[selectedBox].label : ""} |
| color={selectedBox >= 0 && selectedBox < value.boxes.length ? colorRGBAToHex(value.boxes[selectedBox].color) : ""} |
| /> |
| {/if} |
|
|
| <style> |
| .canvas-annotator { |
| border-color: var(--block-border-color); |
| width: 100%; |
| height: 100%; |
| display: block; |
| touch-action: none; |
| } |
|
|
| .canvas-control { |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| border-top: 1px solid var(--border-color-primary); |
| width: 95%; |
| bottom: 0; |
| left: 0; |
| right: 0; |
| margin-left: auto; |
| margin-right: auto; |
| margin-top: var(--size-2); |
| } |
|
|
| .icon { |
| width: 22px; |
| height: 22px; |
| margin: var(--spacing-lg) var(--spacing-xs); |
| padding: var(--spacing-xs); |
| color: var(--neutral-400); |
| border-radius: var(--radius-md); |
| } |
|
|
| .icon:hover, |
| .icon:focus { |
| color: var(--color-accent); |
| } |
| |
| .selected { |
| color: var(--color-accent); |
| } |
|
|
| .canvas-container { |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| } |
|
|
| .canvas-container:focus { |
| outline: none; |
| } |
| </style> |
|
|