Spaces:
Running
Running
| <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; | |
| // Check if the mouse is over any of the resizing handles | |
| for (const [i, box] of value.boxes.entries()) { | |
| const handleIndex = box.indexOfPointInsideHandle(mouseX, mouseY); | |
| if (handleIndex >= 0) { | |
| selectBox(i); | |
| box.startResize(handleIndex, event); | |
| return; | |
| } | |
| } | |
| // Check if the mouse is inside a box | |
| 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> | |