diff --git a/README.md b/README.md index d9ea1f3114..eef68117a0 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ Need more info, check out [our docs](https://argilla-io.github.io/argilla/latest ## 🥇 Contributors -To help our community with the creation of contributions, we have created our [community](https://argilla-io.github.io/argilla/latest/community/) docs. +To help our community with the creation of contributions, we have created our [community](https://argilla-io.github.io/argilla/latest/community/) docs. diff --git a/argilla-frontend/__mocks__/konva.ts b/argilla-frontend/__mocks__/konva.ts new file mode 100644 index 0000000000..f9a111f08f --- /dev/null +++ b/argilla-frontend/__mocks__/konva.ts @@ -0,0 +1,230 @@ +/** + * Mock implementation of Konva for Jest tests + * This avoids the need for the 'canvas' package in Node.js environment + */ + +class MockNode { + attrs: any = {}; + children: MockNode[] = []; + parent: MockNode | null = null; + + constructor(config: any = {}) { + this.attrs = { ...config }; + } + + x(val?: number) { + if (val !== undefined) this.attrs.x = val; + return this.attrs.x ?? 0; + } + + y(val?: number) { + if (val !== undefined) this.attrs.y = val; + return this.attrs.y ?? 0; + } + + width(val?: number) { + if (val !== undefined) this.attrs.width = val; + return this.attrs.width ?? 0; + } + + height(val?: number) { + if (val !== undefined) this.attrs.height = val; + return this.attrs.height ?? 0; + } + + radius(val?: number) { + if (val !== undefined) this.attrs.radius = val; + return this.attrs.radius ?? 0; + } + + fill(val?: string) { + if (val !== undefined) this.attrs.fill = val; + return this.attrs.fill; + } + + stroke(val?: string) { + if (val !== undefined) this.attrs.stroke = val; + return this.attrs.stroke; + } + + strokeWidth(val?: number) { + if (val !== undefined) this.attrs.strokeWidth = val; + return this.attrs.strokeWidth ?? 0; + } + + points(val?: number[]) { + if (val !== undefined) this.attrs.points = val; + return this.attrs.points ?? []; + } + + opacity(val?: number) { + if (val !== undefined) this.attrs.opacity = val; + return this.attrs.opacity ?? 1; + } + + id(val?: string) { + if (val !== undefined) this.attrs.id = val; + return this.attrs.id; + } + + name(val?: string) { + if (val !== undefined) this.attrs.name = val; + return this.attrs.name; + } + + listening(val?: boolean) { + if (val !== undefined) this.attrs.listening = val; + return this.attrs.listening ?? true; + } + + draggable(val?: boolean) { + if (val !== undefined) this.attrs.draggable = val; + return this.attrs.draggable ?? false; + } + + visible(val?: boolean) { + if (val !== undefined) this.attrs.visible = val; + return this.attrs.visible ?? true; + } + + zIndex(val?: number) { + if (val !== undefined) this.attrs.zIndex = val; + return this.attrs.zIndex ?? 0; + } + + add(child: MockNode) { + this.children.push(child); + child.parent = this; + return this; + } + + destroy() { + if (this.parent) { + const index = this.parent.children.indexOf(this); + if (index > -1) this.parent.children.splice(index, 1); + } + this.children = []; + } + + remove() { + this.destroy(); + } + + moveToTop() { + return this; + } + + moveToBottom() { + return this; + } + + getLayer() { + let node: MockNode | null = this; + while (node && !(node instanceof MockLayer)) { + node = node.parent; + } + return node; + } + + find(selector: string) { + const results: MockNode[] = []; + const search = (node: MockNode) => { + if (selector.startsWith("#") && node.attrs.id === selector.slice(1)) { + results.push(node); + } else if (selector.startsWith(".") && node.attrs.name === selector.slice(1)) { + results.push(node); + } + node.children.forEach(search); + }; + search(this); + return results; + } + + findOne(selector: string) { + return this.find(selector)[0] || null; + } + + on() { + return this; + } + + off() { + return this; + } + + fire() { + return this; + } +} + +class MockLayer extends MockNode { + batchDraw() { + return this; + } + + draw() { + return this; + } + + clear() { + return this; + } +} + +class MockStage extends MockNode { + container() { + return document.createElement("div"); + } + + batchDraw() { + return this; + } + + draw() { + return this; + } + + getLayers() { + return this.children.filter((c) => c instanceof MockLayer); + } +} + +class MockRect extends MockNode {} +class MockCircle extends MockNode {} +class MockLine extends MockNode { + closed(val?: boolean) { + if (val !== undefined) this.attrs.closed = val; + return this.attrs.closed ?? false; + } + + dash(val?: number[]) { + if (val !== undefined) this.attrs.dash = val; + return this.attrs.dash; + } +} +class MockGroup extends MockNode {} +class MockImage extends MockNode { + image(val?: any) { + if (val !== undefined) this.attrs.image = val; + return this.attrs.image; + } +} +class MockTransformer extends MockNode { + nodes(val?: MockNode[]) { + if (val !== undefined) this.attrs.nodes = val; + return this.attrs.nodes ?? []; + } +} + +const Konva = { + Stage: MockStage, + Layer: MockLayer, + Rect: MockRect, + Circle: MockCircle, + Line: MockLine, + Group: MockGroup, + Image: MockImage, + Transformer: MockTransformer, +}; + +export default Konva; diff --git a/argilla-frontend/components/features/annotation/container/fields/RecordFields.vue b/argilla-frontend/components/features/annotation/container/fields/RecordFields.vue index d9cdc15aef..d2c4c87f05 100644 --- a/argilla-frontend/components/features/annotation/container/fields/RecordFields.vue +++ b/argilla-frontend/components/features/annotation/container/fields/RecordFields.vue @@ -41,6 +41,14 @@ :content="content" :searchText="recordCriteria.committed.searchText.value.text" /> + q.settings.field === fieldName); + }, + hasImageAnnotationQuestion(fieldName) { + return !!this.getImageAnnotationQuestion(fieldName); + }, }, computed: { spanQuestions() { return this.record?.questions?.filter((q) => q.isSpanType); }, + imageAnnotationQuestions() { + return this.record?.questions?.filter((q) => q.isImageAnnotationType); + }, }, }; diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/ImageAnnotationField.vue b/argilla-frontend/components/features/annotation/container/fields/image-annotation/ImageAnnotationField.vue new file mode 100644 index 0000000000..7e0bc3e05c --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/ImageAnnotationField.vue @@ -0,0 +1,289 @@ + + + + + \ No newline at end of file diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/composables/useContextMenu.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/composables/useContextMenu.ts new file mode 100644 index 0000000000..7d9ebe2a1b --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/composables/useContextMenu.ts @@ -0,0 +1,152 @@ +import { ref } from "vue-demi"; +import Konva from "konva"; + +export interface ContextMenuState { + visible: boolean; + x: number; + y: number; + annotationIndex: number | null; + holeIndex: number | null; +} + +export interface ContextMenuActions { + onDelete: (annotationIndex: number) => void; + onEdit: (annotationIndex: number) => void; + onAddHole: (annotationIndex: number) => void; + onDeleteHole: (annotationIndex: number, holeIndex: number) => void; +} + +/** + * Consolidated context menu composable + * Manages state, visibility, and action handlers for annotation context menus + * + * @param actions - Callbacks for menu actions (delete, edit, add hole, delete hole) + * @returns Context menu state and handler functions + */ +export const useContextMenu = (actions?: ContextMenuActions) => { + const state = ref({ + visible: false, + x: 0, + y: 0, + annotationIndex: null, + holeIndex: null, + }); + + /** + * Show context menu at specified position + */ + const show = ( + index: number, + x: number, + y: number, + holeIndex: number | null = null + ) => { + state.value = { + visible: true, + x, + y, + annotationIndex: index, + holeIndex, + }; + }; + + /** + * Hide context menu and reset state + */ + const hide = () => { + state.value = { + visible: false, + x: 0, + y: 0, + annotationIndex: null, + holeIndex: null, + }; + }; + + /** + * Handle delete action from context menu + */ + const handleDelete = () => { + if (state.value.annotationIndex !== null && actions) { + actions.onDelete(state.value.annotationIndex); + hide(); + } + }; + + /** + * Handle edit action from context menu + */ + const handleEdit = () => { + if (state.value.annotationIndex !== null && actions) { + actions.onEdit(state.value.annotationIndex); + } + }; + + /** + * Handle add hole action from context menu + */ + const handleAddHole = () => { + if (state.value.annotationIndex !== null && actions) { + actions.onAddHole(state.value.annotationIndex); + hide(); + } + }; + + /** + * Handle delete hole action from context menu + */ + const handleDeleteHole = () => { + if ( + state.value.annotationIndex !== null && + state.value.holeIndex !== null && + actions + ) { + actions.onDeleteHole(state.value.annotationIndex, state.value.holeIndex); + hide(); + } + }; + + /** + * Attach context menu handler to a Konva element + * This creates the right-click behavior for annotations + */ + const attachContextMenuHandler = ( + element: Konva.Node, + annotationIndex: number, + holeIndex?: number + ) => { + element.on("contextmenu", (e) => { + e.evt.preventDefault(); + + if (holeIndex !== undefined) { + e.cancelBubble = true; // Prevent parent group from handling + } + + const stage = element.getStage(); + if (stage) { + const pointerPos = stage.getPointerPosition(); + if (pointerPos) { + const container = stage.container(); + const rect = container.getBoundingClientRect(); + show( + annotationIndex, + rect.left + pointerPos.x, + rect.top + pointerPos.y, + holeIndex + ); + } + } + }); + }; + + return { + state, + show, + hide, + handleDelete, + handleEdit, + handleAddHole, + handleDeleteHole, + attachContextMenuHandler, + }; +}; diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/composables/useImageLoader.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/composables/useImageLoader.ts new file mode 100644 index 0000000000..3b7ff8a33e --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/composables/useImageLoader.ts @@ -0,0 +1,55 @@ +import Konva from "konva"; + +/** + * Load image and create Konva.Image node + * Returns a promise that resolves with the image node and dimensions + */ +export const loadImageNode = ( + content: string, + stage: Konva.Stage, + imageLayer: Konva.Layer +): Promise<{ + imageNode: Konva.Image; + originalWidth: number; + originalHeight: number; +}> => { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + + img.onload = () => { + const stageWidth = stage.width(); + const stageHeight = stage.height(); + + // Calculate scale to fit image in canvas + const scale = Math.min( + stageWidth / img.width, + stageHeight / img.height, + 1 // Don't scale up + ); + + const imageNode = new Konva.Image({ + image: img, + x: (stageWidth - img.width * scale) / 2, + y: (stageHeight - img.height * scale) / 2, + width: img.width * scale, + height: img.height * scale, + }); + + imageLayer.add(imageNode); + imageLayer.batchDraw(); + + resolve({ + imageNode, + originalWidth: img.width, + originalHeight: img.height, + }); + }; + + img.onerror = () => { + reject(new Error("Failed to load image")); + }; + + img.src = content; + }); +}; diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/composables/useKeyboardShortcuts.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/composables/useKeyboardShortcuts.ts new file mode 100644 index 0000000000..cad18050cb --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/composables/useKeyboardShortcuts.ts @@ -0,0 +1,92 @@ +import { onMounted, onUnmounted, Ref } from "vue-demi"; +import { ANNOTATION_SHORTCUTS, matchesKey } from "../utils/keyboardShortcuts"; + +export interface KeyboardShortcutHandlers { + // Edit mode handlers + onExitEditMode?: () => void; + onNextAnnotation?: () => void; + onPreviousAnnotation?: () => void; + onDeleteInEditMode?: () => void; + + // Idle mode handlers + onExitHoleDrawingMode?: () => void; + + // Interaction handlers + onInteractionKeyDown?: (e: KeyboardEvent) => { + shouldComplete?: boolean; + shouldCancel?: boolean; + shouldContinue?: boolean; + } | void; +} + +export interface KeyboardShortcutState { + mode: Ref< + | { kind: "idle" } + | { kind: "drawing" } + | { kind: "edit"; annotationIndex: number } + >; + activeInteraction: Ref<{ onKeyDown: (e: KeyboardEvent) => any } | null>; + holeDrawingModeActive: Ref; + annotationCount: Ref; +} + +/** + * Composable for managing keyboard shortcuts in image annotation + * Handles edit mode, idle mode, and interaction shortcuts + */ +export const useKeyboardShortcuts = ( + state: KeyboardShortcutState, + handlers: KeyboardShortcutHandlers +) => { + const handleKeyDown = (e: KeyboardEvent) => { + // Priority 1: Active interaction (drawing) + if (state.activeInteraction.value) { + const result = state.activeInteraction.value.onKeyDown(e); + if (handlers.onInteractionKeyDown && result) { + handlers.onInteractionKeyDown(e); + } + return; + } + + // Priority 2: Edit mode shortcuts + if (state.mode.value.kind === "edit") { + if (matchesKey(e, ANNOTATION_SHORTCUTS.CANCEL)) { + e.preventDefault(); + handlers.onExitEditMode?.(); + } else if (matchesKey(e, ANNOTATION_SHORTCUTS.NEXT)) { + e.preventDefault(); + handlers.onNextAnnotation?.(); + } else if (matchesKey(e, ANNOTATION_SHORTCUTS.PREVIOUS)) { + e.preventDefault(); + handlers.onPreviousAnnotation?.(); + } else if (matchesKey(e, ANNOTATION_SHORTCUTS.DELETE)) { + e.preventDefault(); + handlers.onDeleteInEditMode?.(); + } + return; + } + + // Priority 3: Idle mode shortcuts + if (state.mode.value.kind === "idle") { + if ( + matchesKey(e, ANNOTATION_SHORTCUTS.CANCEL) && + state.holeDrawingModeActive.value + ) { + e.preventDefault(); + handlers.onExitHoleDrawingMode?.(); + } + } + }; + + onMounted(() => { + window.addEventListener("keydown", handleKeyDown); + }); + + onUnmounted(() => { + window.removeEventListener("keydown", handleKeyDown); + }); + + return { + handleKeyDown, + }; +}; diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/composables/useKonvaStage.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/composables/useKonvaStage.ts new file mode 100644 index 0000000000..689855738f --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/composables/useKonvaStage.ts @@ -0,0 +1,44 @@ +import Konva from "konva"; + +/** + * Initialize Konva stage with layers + * Simple helper that creates and returns stage objects + */ +export const initKonvaStage = ( + container: HTMLDivElement, + onMouseDown?: () => void, + onMouseMove?: () => void, + onMouseUp?: () => void +) => { + const containerWidth = container.offsetWidth; + const containerHeight = container.offsetHeight || 500; + + const stage = new Konva.Stage({ + container, + width: containerWidth, + height: containerHeight, + }); + + const imageLayer = new Konva.Layer(); + const annotationLayer = new Konva.Layer(); + + stage.add(imageLayer); + stage.add(annotationLayer); + + // Setup event handlers + if (onMouseDown) { + stage.on("mousedown touchstart", onMouseDown); + } + if (onMouseMove) { + stage.on("mousemove touchmove", onMouseMove); + } + if (onMouseUp) { + stage.on("mouseup touchend", onMouseUp); + } + + return { + stage, + imageLayer, + annotationLayer, + }; +}; diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/composables/useResize.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/composables/useResize.ts new file mode 100644 index 0000000000..75b3ccae97 --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/composables/useResize.ts @@ -0,0 +1,131 @@ +import { onMounted, onUnmounted, Ref } from "vue-demi"; +import Konva from "konva"; +import { calculateImageScale, centerImage } from "../utils/geometry"; + +export interface ResizeOptions { + debounceMs?: number; + onResize?: () => void; +} + +/** + * Composable for handling canvas resize with debouncing + * Manages window resize and ResizeObserver for container changes + */ +export const useResize = ( + container: Ref, + getStage: () => Konva.Stage | null, + getImageNode: () => Konva.Image | null, + getOriginalDimensions: () => { width: number; height: number }, + getAnnotationLayer: () => Konva.Layer | null, + options: ResizeOptions = {} +) => { + const { debounceMs = 150, onResize } = options; + + let resizeTimeout: NodeJS.Timeout | null = null; + let resizeObserver: ResizeObserver | null = null; + + /** + * Resize the canvas and reposition image + */ + const resizeCanvas = () => { + const stage = getStage(); + const imageNode = getImageNode(); + const annotationLayer = getAnnotationLayer(); + + if (!stage || !container.value || !imageNode) return; + + // Get new container dimensions + const containerWidth = container.value.offsetWidth; + const containerHeight = container.value.offsetHeight; + + if (containerWidth === 0 || containerHeight === 0) return; + + // Update stage size + stage.width(containerWidth); + stage.height(containerHeight); + + const { width: originalWidth, height: originalHeight } = + getOriginalDimensions(); + + // Calculate scale and position for image + const scale = calculateImageScale( + containerWidth, + containerHeight, + originalWidth, + originalHeight + ); + + const imageProps = centerImage( + containerWidth, + containerHeight, + originalWidth, + originalHeight, + scale + ); + + // Apply image positioning + imageNode.x(imageProps.x); + imageNode.y(imageProps.y); + imageNode.width(imageProps.width); + imageNode.height(imageProps.height); + + // Trigger callback for re-rendering annotations + if (onResize) { + onResize(); + } + + annotationLayer?.batchDraw(); + }; + + /** + * Debounced resize handler + */ + const handleResize = () => { + if (resizeTimeout) clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => { + resizeCanvas(); + }, debounceMs); + }; + + /** + * Setup resize listeners + */ + const setupResizeListeners = () => { + // Window resize + window.addEventListener("resize", handleResize); + + // Container resize (e.g., from resizable bar) + if (container.value) { + resizeObserver = new ResizeObserver(() => { + handleResize(); + }); + resizeObserver.observe(container.value); + } + }; + + /** + * Cleanup resize listeners + */ + const cleanupResizeListeners = () => { + window.removeEventListener("resize", handleResize); + if (resizeTimeout) clearTimeout(resizeTimeout); + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = null; + } + }; + + onMounted(() => { + setupResizeListeners(); + }); + + onUnmounted(() => { + cleanupResizeListeners(); + }); + + return { + resizeCanvas, + setupResizeListeners, + cleanupResizeListeners, + }; +}; diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/rendering/AnnotationRenderer.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/rendering/AnnotationRenderer.ts new file mode 100644 index 0000000000..84584978dd --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/rendering/AnnotationRenderer.ts @@ -0,0 +1,490 @@ +import Konva from "konva"; +import { getAnnotationNodes } from "../utils/konvaShapes"; +import { getCanvasCoordinates } from "../utils/coordinates"; +import { AnnotationToolFactory } from "../tools/AnnotationToolFactory"; +import { ImageAnnotationAnswer } from "~/v1/domain/entities/IAnswer"; + +export interface AnchorConfig { + annotationIndex: number; + pointIndex: number; + holeIndex: number | null; + onDragStart: (annIdx: number, ptIdx: number, holeIdx: number | null) => void; + onDragMove: ( + annIdx: number, + ptIdx: number, + pos: { x: number; y: number }, + holeIdx: number | null + ) => void; + onDragEnd: (annIdx: number) => void; + attachContextMenuHandler: ( + element: Konva.Node, + annotationIndex: number, + holeIndex?: number + ) => void; +} + +export interface RendererDependencies { + annotationLayer: Konva.Layer | null; + imageLayer: Konva.Layer | null; + imageNode: Konva.Image | null; + toolFactory: AnnotationToolFactory | null; + getAnnotationColor: (labelValue: string) => string; + onHoverAnnotation: (index: number) => void; + onUnhoverAnnotation: () => void; +} + +/** + * AnnotationRenderer - Handles all rendering logic for annotations + * Separates visualization from business logic, making it easier to add new tools + */ +export class AnnotationRenderer { + constructor(private deps: RendererDependencies) {} + + /** + * Create a Konva shape (rectangle or polygon) for an annotation + */ + private createShape( + shapeType: string, + canvasPoints: number[][], + color: string, + annotationIndex: number, + isParent: boolean, + holeIndex?: number + ): Konva.Rect | Konva.Line | null { + const shapeId = isParent + ? `annotation-${annotationIndex}` + : `annotation-${annotationIndex}-hole-${holeIndex}`; + const shapeName = isParent ? "annotation-shape" : "annotation-hole"; + + if (shapeType === "rectangle" && canvasPoints.length === 2) { + const [p1, p2] = canvasPoints; + return new Konva.Rect({ + id: shapeId, + name: shapeName, + x: Math.min(p1[0], p2[0]), + y: Math.min(p1[1], p2[1]), + width: Math.abs(p2[0] - p1[0]), + height: Math.abs(p2[1] - p1[1]), + stroke: color, + strokeWidth: 2, + fill: color, + opacity: isParent ? 0.3 : 1, + listening: true, + }); + } else if (shapeType === "polygon") { + const points = canvasPoints.flat(); + return new Konva.Line({ + id: shapeId, + name: shapeName, + points, + stroke: color, + strokeWidth: 2, + fill: color, + opacity: isParent ? 0.3 : 1, + closed: true, + listening: true, + }); + } + return null; + } + + /** + * Attach hover handlers to a Konva element + */ + private attachHoverHandlers(element: Konva.Node, annotationIndex: number) { + element.on("mouseenter", () => + this.deps.onHoverAnnotation(annotationIndex) + ); + element.on("mouseleave", () => this.deps.onUnhoverAnnotation()); + } + + /** + * Render annotation with holes (uses Konva group with composite operation) + */ + private renderAnnotationWithHoles( + annotation: ImageAnnotationAnswer, + index: number, + color: string, + canvasPoints: number[][], + attachContextMenuHandler: ( + element: Konva.Node, + annotationIndex: number, + holeIndex?: number + ) => void + ) { + if (!this.deps.annotationLayer) return; + + const group = new Konva.Group({ + id: `annotation-${index}`, + name: "annotation-group", + }); + + // Create parent shape + const parentShape = this.createShape( + annotation.shape_type, + canvasPoints, + color, + index, + true + ); + if (parentShape) { + group.add(parentShape); + } + + // Render holes as cutouts + annotation.holes!.forEach((hole, holeIndex) => { + const holeCanvasPoints = getCanvasCoordinates( + hole.points, + this.deps.imageNode + ); + const holeShape = this.createShape( + hole.shape_type, + holeCanvasPoints, + color, + index, + false, + holeIndex + ); + + if (holeShape) { + attachContextMenuHandler(holeShape, index, holeIndex); + holeShape.globalCompositeOperation("destination-out"); + group.add(holeShape); + } + }); + + // Attach event handlers to group + this.attachHoverHandlers(group, index); + attachContextMenuHandler(group, index); + + this.deps.annotationLayer.add(group); + } + + /** + * Render simple annotation (no holes) + */ + private renderSimpleAnnotation( + annotation: ImageAnnotationAnswer, + index: number, + color: string, + canvasPoints: number[][], + attachContextMenuHandler: ( + element: Konva.Node, + annotationIndex: number, + holeIndex?: number + ) => void + ) { + if (!this.deps.annotationLayer) return; + + const shape = this.createShape( + annotation.shape_type, + canvasPoints, + color, + index, + true + ); + + if (shape) { + this.attachHoverHandlers(shape, index); + attachContextMenuHandler(shape, index); + this.deps.annotationLayer.add(shape); + } + } + + /** + * Render all annotations on the canvas + */ + renderAnnotations( + annotations: ImageAnnotationAnswer[], + attachContextMenuHandler: ( + element: Konva.Node, + annotationIndex: number, + holeIndex?: number + ) => void + ) { + if (!this.deps.annotationLayer) return; + + // Remove existing annotation shapes and groups + this.deps.annotationLayer + .find(".annotation-shape") + .forEach((shape) => shape.destroy()); + this.deps.annotationLayer + .find(".annotation-group") + .forEach((group) => group.destroy()); + + // Render each annotation + annotations.forEach((annotation, index) => { + const color = this.deps.getAnnotationColor(annotation.label); + const canvasPoints = getCanvasCoordinates( + annotation.points, + this.deps.imageNode + ); + const hasHoles = annotation.holes && annotation.holes.length > 0; + + if (hasHoles) { + this.renderAnnotationWithHoles( + annotation, + index, + color, + canvasPoints, + attachContextMenuHandler + ); + } else { + this.renderSimpleAnnotation( + annotation, + index, + color, + canvasPoints, + attachContextMenuHandler + ); + } + }); + + this.deps.annotationLayer.batchDraw(); + } + + /** + * Apply highlight styling to an annotation shape + */ + highlightAnnotation( + index: number, + highlight: boolean, + isEditing: boolean, + color: string + ) { + const { element, parentShape, holeShapes } = getAnnotationNodes( + this.deps.annotationLayer, + index + ); + if (!element || !parentShape) return; + + if (isEditing) { + (parentShape as any).strokeWidth(4); + (parentShape as any).shadowColor(color); + (parentShape as any).shadowBlur(8); + (parentShape as any).shadowOpacity(0.8); + (parentShape as any).opacity(0.5); + } else { + (parentShape as any).strokeWidth(highlight ? 4 : 2); + (parentShape as any).shadowBlur(0); + (parentShape as any).opacity(0.3); + } + + holeShapes.forEach((holeShape) => { + (holeShape as any).strokeWidth(isEditing ? 4 : highlight ? 4 : 2); + }); + + const stage = element.getStage(); + if (stage && !isEditing) { + stage.container().style.cursor = highlight ? "pointer" : "default"; + } + + this.deps.annotationLayer?.batchDraw(); + this.deps.imageLayer?.batchDraw(); + } + + /** + * Fade all annotations except the one being edited + */ + fadeNonEditedAnnotations( + editingIndex: number, + annotations: ImageAnnotationAnswer[] + ) { + if (!this.deps.annotationLayer) return; + + annotations.forEach((_, index) => { + if (index !== editingIndex) { + const { parentShape } = getAnnotationNodes( + this.deps.annotationLayer, + index + ); + if (parentShape) { + (parentShape as any).opacity(0.2); + } + } + }); + + this.deps.annotationLayer.batchDraw(); + } + + /** + * Restore opacity for all annotations + */ + restoreAllAnnotations(annotations: ImageAnnotationAnswer[]) { + if (!this.deps.annotationLayer) return; + + annotations.forEach((_, index) => { + const { parentShape } = getAnnotationNodes( + this.deps.annotationLayer, + index + ); + if (parentShape) { + (parentShape as any).opacity(0.3); + } + }); + + this.deps.annotationLayer.batchDraw(); + } + + /** + * Render a visual guide showing the parent shape boundary when editing holes + */ + renderParentBoundaryGuide( + _annotationIndex: number, + annotation: ImageAnnotationAnswer, + color: string + ) { + if (!this.deps.annotationLayer) return; + + const canvasPoints = getCanvasCoordinates( + annotation.points, + this.deps.imageNode + ); + + // Create a dashed boundary line to show the constraint + let boundaryShape: Konva.Shape | null = null; + + if (annotation.shape_type === "rectangle" && canvasPoints.length === 2) { + const [p1, p2] = canvasPoints; + boundaryShape = new Konva.Rect({ + x: Math.min(p1[0], p2[0]), + y: Math.min(p1[1], p2[1]), + width: Math.abs(p2[0] - p1[0]), + height: Math.abs(p2[1] - p1[1]), + stroke: color, + strokeWidth: 2, + dash: [8, 4], + opacity: 0.5, + name: "parent-boundary-guide", + listening: false, // Don't interfere with interactions + }); + } else if (annotation.shape_type === "polygon") { + const points = canvasPoints.flat(); + boundaryShape = new Konva.Line({ + points, + stroke: color, + strokeWidth: 2, + dash: [8, 4], + opacity: 0.5, + closed: true, + name: "parent-boundary-guide", + listening: false, + }); + } + + if (boundaryShape) { + this.deps.annotationLayer.add(boundaryShape); + boundaryShape.moveToBottom(); // Keep it behind anchor points + } + } + + /** + * Render anchor points for editing an annotation + */ + renderAnchorPoints( + annotation: ImageAnnotationAnswer, + annotationIndex: number, + color: string, + anchorConfig: AnchorConfig + ) { + if (!this.deps.annotationLayer || !this.deps.toolFactory) return; + + this.removeAnchorPoints(); + + const tool = this.deps.toolFactory.getTool(annotation.shape_type); + if (!tool) return; + + // Show parent boundary guide if editing a hole + if (anchorConfig.holeIndex !== null) { + this.renderParentBoundaryGuide(annotationIndex, annotation, color); + } + + // Render parent shape anchors + tool.renderAnchorPoints(annotation, annotationIndex, color, anchorConfig); + + this.deps.annotationLayer.batchDraw(); + } + + /** + * Remove all anchor points from the canvas + */ + removeAnchorPoints() { + if (!this.deps.annotationLayer || !this.deps.toolFactory) return; + const tool = this.deps.toolFactory.getTool("rectangle"); // Any tool will do for this common operation + if (tool) { + tool.removeAnchorPoints(); + } + } + + /** + * Update rectangle anchor point positions during drag + * This ensures all corner anchors move correctly when dragging any corner + */ + updateRectangleAnchorPositions( + annotationIndex: number, + holeIndex: number | null, + annotation: ImageAnnotationAnswer + ) { + if (!this.deps.annotationLayer) return; + + // Determine which shape we're updating (hole or parent) + const targetShape = + holeIndex !== null ? annotation.holes?.[holeIndex] : annotation; + if (!targetShape || targetShape.shape_type !== "rectangle") return; + + // Get updated canvas coordinates for the rectangle + const canvasPoints = getCanvasCoordinates( + targetShape.points, + this.deps.imageNode + ); + + if (canvasPoints.length !== 2) return; + + const [p1, p2] = canvasPoints; + const corners = [ + { x: p1[0], y: p1[1] }, // top-left + { x: p2[0], y: p1[1] }, // top-right + { x: p2[0], y: p2[1] }, // bottom-right + { x: p1[0], y: p2[1] }, // bottom-left + ]; + + // Update each anchor point position + corners.forEach((corner, pointIndex) => { + const anchorId = + holeIndex !== null + ? `anchor-${annotationIndex}-hole-${holeIndex}-${pointIndex}` + : `anchor-${annotationIndex}-${pointIndex}`; + + const anchor = this.deps.annotationLayer!.findOne(`#${anchorId}`); + if (anchor) { + anchor.position({ x: corner.x, y: corner.y }); + } + }); + + this.deps.annotationLayer.batchDraw(); + } + + /** + * Highlight parent shape for hole drawing mode + */ + highlightParentForHoleDrawing( + parentIndex: number, + annotation: ImageAnnotationAnswer + ) { + const { parentShape } = getAnnotationNodes( + this.deps.annotationLayer, + parentIndex + ); + if (!parentShape) return; + + const color = this.deps.getAnnotationColor(annotation.label); + + (parentShape as any).strokeWidth(3); + (parentShape as any).stroke(color); + (parentShape as any).dash([10, 5]); // Dashed stroke to indicate hole drawing mode + (parentShape as any).opacity(0.4); + + this.deps.annotationLayer?.batchDraw(); + this.deps.imageLayer?.batchDraw(); + } +} diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/AnnotationToolFactory.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/AnnotationToolFactory.ts new file mode 100644 index 0000000000..c334bde59c --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/AnnotationToolFactory.ts @@ -0,0 +1,38 @@ +import { IAnnotationTool, ToolContext } from "./IAnnotationTool"; +import { RectangleTool } from "./RectangleTool"; +import { PolygonTool } from "./PolygonTool"; + +/** + * Factory for creating annotation tools + */ +export class AnnotationToolFactory { + private tools: Map = new Map(); + private context: ToolContext; + + constructor(context: ToolContext) { + this.context = context; + this.initializeTools(); + } + + private initializeTools(): void { + this.tools.set("rectangle", new RectangleTool(this.context)); + this.tools.set("polygon", new PolygonTool(this.context)); + } + + /** + * Get a tool by its type (e.g., "rectangle", "polygon") + * Works for both selected tool types and existing annotation shape types + */ + getTool(toolType: string): IAnnotationTool | undefined { + return this.tools.get(toolType); + } + + /** + * Update the context for all tools (e.g., when layers change) + */ + updateContext(context: ToolContext): void { + this.context = context; + this.tools.clear(); + this.initializeTools(); + } +} diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/BaseAnnotationTool.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/BaseAnnotationTool.ts new file mode 100644 index 0000000000..162c57db9a --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/BaseAnnotationTool.ts @@ -0,0 +1,283 @@ +import Konva from "konva"; +import { getAnnotationNodes, updateKonvaShape } from "../utils/konvaShapes"; +import { + IAnnotationTool, + ToolContext, + AnchorPointConfig, + DrawingState, +} from "./IAnnotationTool"; +import { ToolInteraction, InteractionContext } from "./IToolInteraction"; +import { ImageAnnotationAnswer } from "~/v1/domain/entities/IAnswer"; + +/** + * Base class for annotation tools providing common functionality + */ +export abstract class BaseAnnotationTool implements IAnnotationTool { + protected context: ToolContext; + protected drawingShape: Konva.Shape | null = null; + + constructor(context: ToolContext) { + this.context = context; + } + + abstract readonly shapeType: string; + + abstract createInteraction( + context: InteractionContext, + startPos: { x: number; y: number }, + color: string, + isHole: boolean, + parentIndex?: number, + selectedLabel?: { value: string; color: string } + ): ToolInteraction; + + abstract startDrawing( + pos: { x: number; y: number }, + color: string, + isHole: boolean, + parentIndex?: number + ): DrawingState; + + abstract updateDrawing( + state: DrawingState, + pos: { x: number; y: number } + ): void; + + abstract completeDrawing( + state: DrawingState, + annotations: ImageAnnotationAnswer[], + selectedLabel: { value: string; color: string } | undefined + ): ImageAnnotationAnswer | null; + + abstract cancelDrawing(state: DrawingState): void; + + abstract cleanupDrawing(state: DrawingState): void; + + abstract renderAnchorPoints( + annotation: ImageAnnotationAnswer, + annotationIndex: number, + color: string, + config: AnchorPointConfig + ): void; + + abstract updateAnnotationFromDrag( + annotation: ImageAnnotationAnswer, + pointIndex: number, + newPos: { x: number; y: number }, + holeIndex: number | null + ): void; + + /** + * Common implementation for creating anchor points + */ + createAnchorPoint( + x: number, + y: number, + color: string, + config: AnchorPointConfig + ): Konva.Circle { + const { annotationIndex, pointIndex, holeIndex } = config; + const anchorId = + holeIndex !== null + ? `anchor-${annotationIndex}-hole-${holeIndex}-${pointIndex}` + : `anchor-${annotationIndex}-${pointIndex}`; + + const anchor = new Konva.Circle({ + x, + y, + radius: 6, + fill: "white", + stroke: color, + strokeWidth: 2, + draggable: true, + name: "anchor-point", + id: anchorId, + }); + + // Change cursor on hover + anchor.on("mouseenter", () => { + const stage = anchor.getStage(); + if (stage) { + stage.container().style.cursor = "move"; + } + anchor.radius(8); + this.context.annotationLayer?.batchDraw(); + }); + + anchor.on("mouseleave", () => { + const stage = anchor.getStage(); + if (stage) { + stage.container().style.cursor = "default"; + } + anchor.radius(6); + this.context.annotationLayer?.batchDraw(); + }); + + // Handle dragging + if (config.onDragStart) { + anchor.on("dragstart", () => { + config.onDragStart!(annotationIndex, pointIndex, holeIndex); + }); + } + + if (config.onDragMove) { + anchor.on("dragmove", () => { + config.onDragMove!( + annotationIndex, + pointIndex, + anchor.position(), + holeIndex + ); + this.context.annotationLayer?.batchDraw(); + }); + } + + if (config.onDragEnd) { + anchor.on("dragend", () => { + config.onDragEnd!(annotationIndex); + const stage = anchor.getStage(); + if (stage) { + stage.container().style.cursor = "default"; + } + }); + } + + // Allow context menu on anchor points + if (config.attachContextMenuHandler) { + config.attachContextMenuHandler( + anchor, + annotationIndex, + holeIndex ?? undefined + ); + } + + this.context.annotationLayer?.add(anchor); + return anchor; + } + + /** + * Common implementation for removing anchor points + */ + removeAnchorPoints(): void { + if (!this.context.annotationLayer) return; + this.context.annotationLayer + .find(".anchor-point") + .forEach((anchor) => anchor.destroy()); + this.context.annotationLayer + .find(".edge-handle") + .forEach((edge) => edge.destroy()); + this.context.annotationLayer + .find(".parent-boundary-guide") + .forEach((guide) => guide.destroy()); + this.context.annotationLayer.batchDraw(); + } + + /** + * Common implementation for updating annotation shape + */ + updateAnnotationShape( + annotation: ImageAnnotationAnswer, + annotationIndex: number + ): void { + if (!this.context.annotationLayer) return; + + const { element, parentShape } = getAnnotationNodes( + this.context.annotationLayer, + annotationIndex + ); + if (!element || !parentShape) return; + + // Update parent shape + updateKonvaShape( + parentShape, + annotation.shape_type, + this.context.getCanvasCoordinates( + annotation.points, + this.context.imageNode + ) + ); + + // Update hole shapes if they exist + if (element instanceof Konva.Group && annotation.holes) { + annotation.holes.forEach((hole, holeIndex) => { + const holeShape = element.findOne( + `#annotation-${annotationIndex}-hole-${holeIndex}` + ); + if (holeShape) { + updateKonvaShape( + holeShape as Konva.Shape, + hole.shape_type, + this.context.getCanvasCoordinates( + hole.points, + this.context.imageNode + ) + ); + } + }); + } + + this.context.annotationLayer.batchDraw(); + } + + /** + * Default implementation - no constraint + */ + constrainPointToParentShape( + _parentAnnotation: ImageAnnotationAnswer, + stagePoint: Konva.Vector2d, + _holeIndex: number | null + ): Konva.Vector2d { + return stagePoint; + } + + /** + * Render parent boundary guide when editing holes + */ + protected renderParentBoundaryGuide( + annotation: ImageAnnotationAnswer, + _annotationIndex: number, + color: string + ): void { + if (!this.context.annotationLayer) return; + + const canvasPoints = this.context.getCanvasCoordinates( + annotation.points, + this.context.imageNode + ); + + let boundaryShape: Konva.Shape | null = null; + + if (annotation.shape_type === "rectangle" && canvasPoints.length === 2) { + const [p1, p2] = canvasPoints; + boundaryShape = new Konva.Rect({ + x: Math.min(p1[0], p2[0]), + y: Math.min(p1[1], p2[1]), + width: Math.abs(p2[0] - p1[0]), + height: Math.abs(p2[1] - p1[1]), + stroke: color, + strokeWidth: 2, + dash: [8, 4], + opacity: 0.5, + name: "parent-boundary-guide", + listening: false, + }); + } else if (annotation.shape_type === "polygon") { + const points = canvasPoints.flat(); + boundaryShape = new Konva.Line({ + points, + stroke: color, + strokeWidth: 2, + dash: [8, 4], + opacity: 0.5, + closed: true, + name: "parent-boundary-guide", + listening: false, + }); + } + + if (boundaryShape) { + this.context.annotationLayer.add(boundaryShape); + boundaryShape.moveToBottom(); + } + } +} diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/IAnnotationTool.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/IAnnotationTool.ts new file mode 100644 index 0000000000..f761624b8d --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/IAnnotationTool.ts @@ -0,0 +1,256 @@ +import Konva from "konva"; +import { ToolInteraction, InteractionContext } from "./IToolInteraction"; +import { ImageAnnotationAnswer } from "~/v1/domain/entities/IAnswer"; + +export interface DrawingState { + kind: string; + color: string; + [key: string]: any; +} + +export interface AnchorPointConfig { + annotationIndex: number; + pointIndex: number; + holeIndex: number | null; + onDragStart?: ( + annotationIndex: number, + pointIndex: number, + holeIndex: number | null + ) => void; + onDragMove?: ( + annotationIndex: number, + pointIndex: number, + position: { x: number; y: number }, + holeIndex: number | null + ) => void; + onDragEnd?: (annotationIndex: number) => void; + attachContextMenuHandler?: ( + element: Konva.Node, + annotationIndex: number, + holeIndex?: number + ) => void; +} + +/** + * Interface for annotation tools (Rectangle, Polygon, etc.) + * Each tool implements the complete lifecycle of drawing and editing shapes + */ +export interface IAnnotationTool { + /** + * The type of shape this tool creates (e.g., "rectangle", "polygon") + */ + readonly shapeType: string; + + /** + * Create a new interaction for drawing a shape. + * This is the preferred method for initiating drawing operations. + * @param context - Interaction context with canvas layers and utilities + * @param startPos - Starting position for the drawing + * @param color - Color for the shape + * @param isHole - Whether this is a hole being drawn inside a parent shape + * @param parentIndex - Index of parent annotation if drawing a hole + * @param selectedLabel - Currently selected label for the annotation + * @returns ToolInteraction instance that owns the drawing state and behavior + */ + createInteraction( + context: InteractionContext, + startPos: { x: number; y: number }, + color: string, + isHole: boolean, + parentIndex?: number, + selectedLabel?: { value: string; color: string } + ): ToolInteraction; + + /** + * Start drawing a new shape + * @deprecated Use createInteraction instead + * @param pos - Starting position + * @param color - Color for the shape + * @param isHole - Whether this is a hole being drawn inside a parent shape + * @param parentIndex - Index of parent annotation if drawing a hole + * @returns DrawingState for tracking the drawing process + */ + startDrawing( + pos: { x: number; y: number }, + color: string, + isHole: boolean, + parentIndex?: number + ): DrawingState; + + /** + * Update the drawing preview as the mouse moves + * @param state - Current drawing state + * @param pos - Current mouse position + */ + updateDrawing(state: DrawingState, pos: { x: number; y: number }): void; + + /** + * Add a point to the shape (for polygon-like tools) + * @param state - Current drawing state + * @param pos - Position to add + * @returns Updated state and whether drawing should complete + */ + addPoint?( + state: DrawingState, + pos: { x: number; y: number } + ): { state: DrawingState; shouldComplete: boolean }; + + /** + * Complete the drawing and create the annotation + * @param state - Current drawing state + * @param annotations - Array of existing annotations + * @param selectedLabel - Currently selected label + * @returns The created annotation or null if creation failed + */ + completeDrawing( + state: DrawingState, + annotations: ImageAnnotationAnswer[], + selectedLabel: { value: string; color: string } | undefined + ): ImageAnnotationAnswer | null; + + /** + * Cancel the current drawing and cleanup + * @param state - Current drawing state + */ + cancelDrawing(state: DrawingState): void; + + /** + * Cleanup drawing artifacts (temporary shapes, circles, etc.) + * @param state - Current drawing state + */ + cleanupDrawing(state: DrawingState): void; + + /** + * Render anchor points for editing a shape + * @param annotation - The annotation to edit + * @param annotationIndex - Index of the annotation + * @param color - Color for the anchor points + * @param config - Configuration for anchor point behavior + */ + renderAnchorPoints( + annotation: ImageAnnotationAnswer, + annotationIndex: number, + color: string, + config: AnchorPointConfig + ): void; + + /** + * Create a single anchor point + * @param x - X position + * @param y - Y position + * @param color - Color for the anchor point + * @param config - Configuration for the anchor point + */ + createAnchorPoint( + x: number, + y: number, + color: string, + config: AnchorPointConfig + ): Konva.Circle; + + /** + * Render edge handles for inserting new points (polygon-like tools) + * @param annotation - The annotation + * @param annotationIndex - Index of the annotation + * @param canvasPoints - Points in canvas coordinates + * @param color - Color for the handles + * @param holeIndex - Index of hole if editing a hole + * @param onInsertPoint - Callback when a point is inserted + * @param attachContextMenuHandler - Optional callback to attach context menu + */ + renderEdgeHandles?( + annotation: ImageAnnotationAnswer, + annotationIndex: number, + canvasPoints: number[][], + color: string, + holeIndex: number | null, + onInsertPoint: ( + annotationIndex: number, + edgeIndex: number, + position: { x: number; y: number }, + holeIndex: number | null + ) => void, + attachContextMenuHandler?: ( + element: Konva.Node, + annotationIndex: number, + holeIndex?: number + ) => void + ): void; + + /** + * Remove all anchor points and edge handles + */ + removeAnchorPoints(): void; + + /** + * Constrain a point to stay within the parent shape (for holes) + * @param parentAnnotation - The parent annotation + * @param stagePoint - Point to constrain + * @param holeIndex - Index of the hole being edited + * @returns Constrained position + */ + constrainPointToParentShape( + parentAnnotation: ImageAnnotationAnswer, + stagePoint: Konva.Vector2d, + holeIndex: number | null + ): Konva.Vector2d; + + /** + * Insert a new point on an edge (for polygon-like tools) + * @param annotation - The annotation + * @param annotationIndex - Index of the annotation + * @param edgeIndex - Index of the edge where point should be inserted + * @param position - Position for the new point + * @param holeIndex - Index of hole if inserting in a hole + */ + insertPointOnEdge?( + annotation: ImageAnnotationAnswer, + annotationIndex: number, + edgeIndex: number, + position: { x: number; y: number }, + holeIndex: number | null + ): void; + + /** + * Update annotation data from dragging an anchor point + * @param annotation - The annotation to update + * @param pointIndex - Index of the point being dragged + * @param newPos - New position for the point + * @param holeIndex - Index of hole if editing a hole + */ + updateAnnotationFromDrag( + annotation: ImageAnnotationAnswer, + pointIndex: number, + newPos: { x: number; y: number }, + holeIndex: number | null + ): void; + + /** + * Update the visual representation of the annotation shape + * @param annotation - The annotation + * @param annotationIndex - Index of the annotation + */ + updateAnnotationShape( + annotation: ImageAnnotationAnswer, + annotationIndex: number + ): void; +} + +export interface ToolContext { + annotationLayer: Konva.Layer | null; + imageLayer: Konva.Layer | null; + imageNode: Konva.Image | null; + getAnnotationColor: (labelValue: string) => string; + getImageCoordinates: ( + points: number[][], + imageNode: Konva.Image | null + ) => number[][]; + getCanvasCoordinates: ( + points: number[][], + imageNode: Konva.Image | null + ) => number[][]; + updateAnswer: () => void; + renderAnnotations: () => void; + renderAnchorPoints?: (annotationIndex: number) => void; + getTool?: (toolType: string) => IAnnotationTool | null; +} diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/IToolInteraction.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/IToolInteraction.ts new file mode 100644 index 0000000000..334597ef83 --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/IToolInteraction.ts @@ -0,0 +1,109 @@ +import Konva from "konva"; +import { ImageAnnotationAnswer } from "~/v1/domain/entities/IAnswer"; + +/** + * Context provided to tool interactions for accessing canvas layers and utilities + */ +export interface InteractionContext { + annotationLayer: Konva.Layer | null; + imageLayer: Konva.Layer | null; + imageNode: Konva.Image | null; + getAnnotationColor: (labelValue: string) => string; + getImageCoordinates: ( + points: number[][], + imageNode: Konva.Image | null + ) => number[][]; + getCanvasCoordinates: ( + points: number[][], + imageNode: Konva.Image | null + ) => number[][]; + updateAnswer: () => void; + renderAnnotations: () => void; +} + +/** + * Result returned by interaction event handlers to signal state transitions + */ +export interface InteractionResult { + /** Signal that the interaction should complete and create the annotation */ + shouldComplete?: boolean; + /** Signal that the interaction should be cancelled */ + shouldCancel?: boolean; + /** Signal that the interaction should continue (default) */ + shouldContinue?: boolean; +} + +/** + * Interface for tool interactions that encapsulate drawing state and behavior. + * Each tool creates an interaction instance when drawing begins, which owns + * all state and handles all events until the drawing is complete or cancelled. + */ +export interface ToolInteraction { + // Metadata + /** The kind of interaction - currently only "drawing" */ + readonly kind: "drawing"; + /** The type of tool creating this interaction (e.g., "rectangle", "polygon") */ + readonly toolType: string; + /** Whether this is drawing a hole inside a parent shape */ + readonly isHole: boolean; + /** Index of the parent annotation if drawing a hole */ + readonly parentIndex?: number; + /** Color for the shape being drawn */ + readonly color: string; + + // Event handlers + /** + * Handle pointer down event + * @param pos - Pointer position in stage coordinates + * @returns Result indicating whether to complete, cancel, or continue + */ + onPointerDown(pos: { x: number; y: number }): InteractionResult; + + /** + * Handle pointer move event + * @param pos - Pointer position in stage coordinates + */ + onPointerMove(pos: { x: number; y: number }): void; + + /** + * Handle pointer up event + * @param pos - Pointer position in stage coordinates + * @returns Result indicating whether to complete, cancel, or continue + */ + onPointerUp(pos: { x: number; y: number }): InteractionResult; + + /** + * Handle keyboard event + * @param e - Keyboard event + * @returns Result indicating whether to complete, cancel, or continue + */ + onKeyDown(e: KeyboardEvent): InteractionResult; + + // Lifecycle methods + /** + * Complete the interaction and return the created annotation + * @returns The created annotation or null if creation failed + */ + complete(): ImageAnnotationAnswer | null; + + /** + * Cancel the interaction without creating an annotation + */ + cancel(): void; + + /** + * Cleanup any temporary visual elements (shapes, circles, etc.) + */ + cleanup(): void; + + // Optional callbacks for controller to execute + /** + * Optional callback to highlight the parent shape when drawing holes + */ + onParentHighlight?: () => void; + + /** + * Optional callback to remove parent shape highlight + */ + onParentUnhighlight?: () => void; +} diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/PolygonTool.test.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/PolygonTool.test.ts new file mode 100644 index 0000000000..d37691a427 --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/PolygonTool.test.ts @@ -0,0 +1,142 @@ +import { PolygonTool } from "./PolygonTool"; +import { InteractionContext } from "./IToolInteraction"; + +const createMockContext = (): InteractionContext => ({ + annotationLayer: null, + imageLayer: null, + imageNode: null, + getAnnotationColor: (_label: string) => "#ff0000", + getImageCoordinates: (points: number[][]) => points, + getCanvasCoordinates: (points: number[][]) => points, + updateAnswer: jest.fn(), + renderAnnotations: jest.fn(), +}); + +describe("PolygonTool", () => { + describe("createInteraction", () => { + test("should create polygon interaction with correct properties", () => { + const mockContext = createMockContext(); + const tool = new PolygonTool(mockContext as any); + + const interaction = tool.createInteraction( + mockContext, + { x: 100, y: 100 }, + "#ff0000", + false, + undefined, + { value: "test-label", color: "#ff0000" } + ); + + expect(interaction.kind).toBe("drawing"); + expect(interaction.toolType).toBe("polygon"); + expect(interaction.isHole).toBe(false); + expect(interaction.color).toBe("#ff0000"); + }); + + test("should create hole interaction with parent index", () => { + const mockContext = createMockContext(); + const tool = new PolygonTool(mockContext as any); + + const holeInteraction = tool.createInteraction( + mockContext, + { x: 200, y: 200 }, + "#00ff00", + true, + 0, + undefined + ); + + expect(holeInteraction.isHole).toBe(true); + expect(holeInteraction.parentIndex).toBe(0); + expect(holeInteraction.color).toBe("#00ff00"); + }); + }); + + describe("polygon drawing", () => { + test("should not complete polygon with less than 3 points", () => { + const mockContext = createMockContext(); + const tool = new PolygonTool(mockContext as any); + + const interaction = tool.createInteraction( + mockContext, + { x: 100, y: 100 }, + "#ff0000", + false, + undefined, + { value: "test-label", color: "#ff0000" } + ); + + let result = interaction.onPointerDown({ x: 150, y: 100 }); + expect(result.shouldComplete).toBeFalsy(); + + result = interaction.onPointerDown({ x: 150, y: 150 }); + expect(result.shouldComplete).toBeFalsy(); + }); + + test("should handle pointer move without errors", () => { + const mockContext = createMockContext(); + const tool = new PolygonTool(mockContext as any); + + const interaction = tool.createInteraction( + mockContext, + { x: 100, y: 100 }, + "#ff0000", + false, + undefined, + { value: "test-label", color: "#ff0000" } + ); + + expect(() => { + interaction.onPointerMove({ x: 150, y: 150 }); + }).not.toThrow(); + }); + }); + + describe("keyboard shortcuts", () => { + test("should cancel on Escape key", () => { + const mockContext = createMockContext(); + const tool = new PolygonTool(mockContext as any); + + const interaction = tool.createInteraction( + mockContext, + { x: 100, y: 100 }, + "#ff0000", + false, + undefined, + { value: "test-label", color: "#ff0000" } + ); + + const escapeEvent = new KeyboardEvent("keydown", { key: "Escape" }); + const result = interaction.onKeyDown(escapeEvent); + + expect(result.shouldCancel).toBe(true); + }); + }); + + describe("complete", () => { + test("should create annotation with correct properties", () => { + const mockContext = createMockContext(); + const tool = new PolygonTool(mockContext as any); + + const interaction = tool.createInteraction( + mockContext, + { x: 50, y: 50 }, + "#ff0000", + false, + undefined, + { value: "my-label", color: "#ff0000" } + ); + + interaction.onPointerDown({ x: 100, y: 50 }); + interaction.onPointerDown({ x: 100, y: 100 }); + interaction.onPointerDown({ x: 50, y: 100 }); + + const annotation = interaction.complete(); + + expect(annotation).not.toBeNull(); + expect(annotation?.label).toBe("my-label"); + expect(annotation?.shape_type).toBe("polygon"); + expect(annotation?.points.length).toBeGreaterThanOrEqual(4); + }); + }); +}); diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/PolygonTool.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/PolygonTool.ts new file mode 100644 index 0000000000..c0b3dee931 --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/PolygonTool.ts @@ -0,0 +1,775 @@ +import Konva from "konva"; +import { flatPointsToCoordinatePairs } from "../utils/coordinates"; +import { + getParentShapeBounds, + clampToParentBounds, + getClosestPointOnPolygon, + isPointWithinParent as checkPointWithinParent, +} from "../utils/geometry"; +import { ANNOTATION_SHORTCUTS, matchesKey } from "../utils/keyboardShortcuts"; +import { BaseAnnotationTool } from "./BaseAnnotationTool"; +import { DrawingState, AnchorPointConfig } from "./IAnnotationTool"; +import { + ToolInteraction, + InteractionContext, + InteractionResult, +} from "./IToolInteraction"; +import { ImageAnnotationAnswer } from "~/v1/domain/entities/IAnswer"; + +interface PolygonDrawingState extends DrawingState { + kind: "draw-poly" | "draw-hole-poly"; + color: string; + points: number[]; + circles: Konva.Circle[]; + previewLine: Konva.Line | null; + parentIndex?: number; +} + +/** + * Interaction implementation for polygon drawing. + * Encapsulates all state and behavior for drawing a polygon annotation. + */ +class PolygonInteraction implements ToolInteraction { + readonly kind = "drawing" as const; + readonly toolType = "polygon"; + readonly isHole: boolean; + readonly parentIndex?: number; + readonly color: string; + + private points: number[] = []; + private circles: Konva.Circle[] = []; + private previewLine: Konva.Line | null = null; + private drawingShape: Konva.Line | null = null; + private readonly CLOSE_THRESHOLD = 10; + + constructor( + private context: InteractionContext, + private selectedLabel: { value: string; color: string } | undefined, + startPos: { x: number; y: number }, + color: string, + isHole: boolean, + parentIndex?: number + ) { + this.color = color; + this.isHole = isHole; + this.parentIndex = parentIndex; + + // Initialize first point + const result = this.initPolyDrawing(startPos, color); + this.points = [startPos.x, startPos.y]; + this.circles = [result.circle]; + this.previewLine = result.previewLine; + this.drawingShape = result.drawingShape; + } + + /** + * Create a visual point circle for polygon drawing + */ + private createPointCircle(x: number, y: number, color: string): Konva.Circle { + return new Konva.Circle({ + x, + y, + radius: 5, + fill: color, + stroke: "white", + strokeWidth: 2, + }); + } + + /** + * Initialize polygon drawing with first point + */ + private initPolyDrawing( + pos: { x: number; y: number }, + color: string + ): { + drawingShape: Konva.Line; + circle: Konva.Circle; + previewLine: Konva.Line; + } { + const circle = this.createPointCircle(pos.x, pos.y, color); + + const previewLine = new Konva.Line({ + points: [pos.x, pos.y, pos.x, pos.y], + stroke: color, + strokeWidth: 2, + dash: [5, 5], + }); + + const drawingShape = new Konva.Line({ + points: [pos.x, pos.y], + stroke: color, + strokeWidth: 2, + fill: color, + opacity: 0.3, + closed: false, + }); + + this.context.annotationLayer?.add(previewLine); + this.context.annotationLayer?.add(drawingShape); + this.context.annotationLayer?.add(circle); + this.context.annotationLayer?.batchDraw(); + + return { drawingShape, circle, previewLine }; + } + + /** + * Add a point to the polygon + */ + private addPolyPoint(pos: { x: number; y: number }): { + shouldComplete: boolean; + updatedCircles: Konva.Circle[]; + } { + const firstPoint = { x: this.points[0], y: this.points[1] }; + const distance = Math.hypot(pos.x - firstPoint.x, pos.y - firstPoint.y); + + if (distance < this.CLOSE_THRESHOLD && this.points.length >= 6) { + return { shouldComplete: true, updatedCircles: this.circles }; + } + + this.points.push(pos.x, pos.y); + (this.drawingShape as Konva.Line).points(this.points); + + const circle = this.createPointCircle(pos.x, pos.y, this.color); + const updatedCircles = [...this.circles, circle]; + this.context.annotationLayer?.add(circle); + this.context.annotationLayer?.batchDraw(); + + return { shouldComplete: false, updatedCircles }; + } + + /** + * Update polygon preview line + */ + private updatePolygonPreview(pos: { x: number; y: number }): void { + if (!this.previewLine) return; + + const lastX = this.points[this.points.length - 2]; + const lastY = this.points[this.points.length - 1]; + this.previewLine.points([lastX, lastY, pos.x, pos.y]); + + if (this.points.length >= 6 && this.circles.length > 0) { + const firstPoint = { x: this.points[0], y: this.points[1] }; + const distance = Math.hypot(pos.x - firstPoint.x, pos.y - firstPoint.y); + + if (distance < this.CLOSE_THRESHOLD) { + this.circles[0].radius(8); + this.circles[0].fill("white"); + this.circles[0].stroke(this.color); + } else { + this.circles[0].radius(5); + this.circles[0].fill(this.color); + this.circles[0].stroke("white"); + } + } + + this.context.annotationLayer?.batchDraw(); + } + + onPointerDown(pos: { x: number; y: number }): InteractionResult { + const result = this.addPolyPoint(pos); + this.circles = result.updatedCircles; + return { shouldComplete: result.shouldComplete }; + } + + onPointerMove(pos: { x: number; y: number }): void { + this.updatePolygonPreview(pos); + } + + onPointerUp(_pos: { x: number; y: number }): InteractionResult { + return { shouldContinue: true }; + } + + onKeyDown(e: KeyboardEvent): InteractionResult { + if (matchesKey(e, ANNOTATION_SHORTCUTS.CANCEL)) { + e.preventDefault(); + return { shouldCancel: true }; + } + if ( + matchesKey(e, ANNOTATION_SHORTCUTS.COMPLETE) && + this.points.length >= 6 + ) { + e.preventDefault(); + return { shouldComplete: true }; + } + return { shouldContinue: true }; + } + + complete(): ImageAnnotationAnswer | null { + if (this.points.length < 6) return null; + + const pointPairs = flatPointsToCoordinatePairs(this.points); + const imageCoords = this.context.getImageCoordinates( + pointPairs, + this.context.imageNode + ); + + if (this.isHole) { + // Hole creation is handled by the controller + // Return null to signal that the controller should handle it + return null; + } + + if (!this.selectedLabel) return null; + + return { + label: this.selectedLabel.value, + points: imageCoords, + shape_type: "polygon", + flags: {}, + }; + } + + cancel(): void { + this.cleanup(); + } + + cleanup(): void { + this.drawingShape?.destroy(); + this.circles.forEach((circle) => circle.destroy()); + this.previewLine?.destroy(); + this.context.annotationLayer?.batchDraw(); + } + + /** + * Get the current points for hole creation (used by controller) + */ + getPoints(): number[] { + return this.points; + } +} + +/** + * Tool for drawing and editing polygon annotations + */ +export class PolygonTool extends BaseAnnotationTool { + readonly shapeType = "polygon"; + private readonly CLOSE_THRESHOLD = 10; + + /** + * Create a visual point circle for polygon drawing + */ + private createPointCircle(x: number, y: number, color: string): Konva.Circle { + return new Konva.Circle({ + x, + y, + radius: 5, + fill: color, + stroke: "white", + strokeWidth: 2, + }); + } + + /** + * Initialize polygon drawing with first point + */ + private initPolyDrawing( + pos: { x: number; y: number }, + color: string + ): { + drawingShape: Konva.Line; + circle: Konva.Circle; + previewLine: Konva.Line; + } { + const circle = this.createPointCircle(pos.x, pos.y, color); + + const previewLine = new Konva.Line({ + points: [pos.x, pos.y, pos.x, pos.y], + stroke: color, + strokeWidth: 2, + dash: [5, 5], + }); + + const drawingShape = new Konva.Line({ + points: [pos.x, pos.y], + stroke: color, + strokeWidth: 2, + fill: color, + opacity: 0.3, + closed: false, + }); + + this.context.annotationLayer?.add(previewLine); + this.context.annotationLayer?.add(drawingShape); + this.context.annotationLayer?.add(circle); + this.context.annotationLayer?.batchDraw(); + + return { drawingShape, circle, previewLine }; + } + + /** + * Add a point to the polygon + */ + private addPolyPoint( + pos: { x: number; y: number }, + color: string, + points: number[], + drawingShape: Konva.Line, + circles: Konva.Circle[] + ): { shouldComplete: boolean; updatedCircles: Konva.Circle[] } { + const firstPoint = { x: points[0], y: points[1] }; + const distance = Math.hypot(pos.x - firstPoint.x, pos.y - firstPoint.y); + + if (distance < this.CLOSE_THRESHOLD && points.length >= 6) { + return { shouldComplete: true, updatedCircles: circles }; + } + + points.push(pos.x, pos.y); + drawingShape.points(points); + + const circle = this.createPointCircle(pos.x, pos.y, color); + const updatedCircles = [...circles, circle]; + this.context.annotationLayer?.add(circle); + this.context.annotationLayer?.batchDraw(); + + return { shouldComplete: false, updatedCircles }; + } + + /** + * Update polygon preview line + */ + private updatePolygonPreview( + previewLine: Konva.Line | null, + points: number[], + pos: { x: number; y: number }, + circles: Konva.Circle[], + color: string + ): void { + if (!previewLine) return; + + const lastX = points[points.length - 2]; + const lastY = points[points.length - 1]; + previewLine.points([lastX, lastY, pos.x, pos.y]); + + if (points.length >= 6 && circles.length > 0) { + const firstPoint = { x: points[0], y: points[1] }; + const distance = Math.hypot(pos.x - firstPoint.x, pos.y - firstPoint.y); + + if (distance < this.CLOSE_THRESHOLD) { + circles[0].radius(8); + circles[0].fill("white"); + circles[0].stroke(color); + } else { + circles[0].radius(5); + circles[0].fill(color); + circles[0].stroke("white"); + } + } + + this.context.annotationLayer?.batchDraw(); + } + + createInteraction( + context: InteractionContext, + startPos: { x: number; y: number }, + color: string, + isHole: boolean, + parentIndex?: number, + selectedLabel?: { value: string; color: string } + ): ToolInteraction { + return new PolygonInteraction( + context, + selectedLabel, + startPos, + color, + isHole, + parentIndex + ); + } + + startDrawing( + pos: { x: number; y: number }, + color: string, + isHole: boolean, + parentIndex?: number + ): PolygonDrawingState { + const drawColor = color || "#cccccc"; + const result = this.initPolyDrawing(pos, drawColor); + + this.drawingShape = result.drawingShape; + + return isHole + ? { + kind: "draw-hole-poly", + parentIndex: parentIndex!, + color, + points: [pos.x, pos.y], + circles: [result.circle], + previewLine: result.previewLine, + } + : { + kind: "draw-poly", + color, + points: [pos.x, pos.y], + circles: [result.circle], + previewLine: result.previewLine, + }; + } + + updateDrawing(state: DrawingState, pos: { x: number; y: number }): void { + const polyState = state as PolygonDrawingState; + this.updatePolygonPreview( + polyState.previewLine, + polyState.points, + pos, + polyState.circles, + polyState.color + ); + } + + addPoint( + state: DrawingState, + pos: { x: number; y: number } + ): { state: DrawingState; shouldComplete: boolean } { + const polyState = state as PolygonDrawingState; + const drawColor = polyState.color || "#cccccc"; + + const result = this.addPolyPoint( + pos, + drawColor, + polyState.points, + this.drawingShape as Konva.Line, + polyState.circles + ); + + if (result.shouldComplete) { + return { state: polyState, shouldComplete: true }; + } + + // Note: addPolyPoint already pushes the point to polyState.points array + // We only need to update the circles reference + polyState.circles = result.updatedCircles; + return { state: polyState, shouldComplete: false }; + } + + completeDrawing( + state: DrawingState, + _annotations: ImageAnnotationAnswer[], + selectedLabel: { value: string; color: string } | undefined + ): ImageAnnotationAnswer | null { + const polyState = state as PolygonDrawingState; + + if (polyState.points.length < 6) return null; + + // Convert flat array to point pairs + const points = flatPointsToCoordinatePairs(polyState.points); + + // Convert to image coordinates + const imageCoords = this.context.getImageCoordinates( + points, + this.context.imageNode + ); + + if (polyState.kind === "draw-hole-poly") { + // Return null - hole creation is handled by the caller + return null; + } + + // Normal annotation creation + if (!selectedLabel) return null; + + return { + label: selectedLabel.value, + points: imageCoords, + shape_type: "polygon", + flags: {}, + }; + } + + cancelDrawing(state: DrawingState): void { + this.cleanupDrawing(state); + } + + cleanupDrawing(state: DrawingState): void { + const polyState = state as PolygonDrawingState; + this.drawingShape?.destroy(); + polyState.circles.forEach((circle) => circle.destroy()); + polyState.previewLine?.destroy(); + if (this.drawingShape) { + this.drawingShape = null; + } + this.context.annotationLayer?.batchDraw(); + } + + renderAnchorPoints( + annotation: ImageAnnotationAnswer, + annotationIndex: number, + color: string, + config: AnchorPointConfig + ): void { + const canvasPoints = this.context.getCanvasCoordinates( + annotation.points, + this.context.imageNode + ); + + // Render edge handles for inserting new points + this.renderEdgeHandles( + annotation, + annotationIndex, + canvasPoints, + color, + null, + (annIdx, edgeIdx, pos, holeIdx) => { + this.insertPointOnEdge(annotation, annIdx, edgeIdx, pos, holeIdx); + }, + config.attachContextMenuHandler + ); + + // Render vertex anchor points + canvasPoints.forEach((point, pointIndex) => { + this.createAnchorPoint(point[0], point[1], color, { + ...config, + pointIndex, + }); + }); + + // Render hole anchors - delegate to appropriate tool based on hole shape type + annotation.holes?.forEach((hole, holeIndex) => { + if (hole.shape_type === "polygon") { + // Render polygon holes directly + const holeCanvasPoints = this.context.getCanvasCoordinates( + hole.points, + this.context.imageNode + ); + + // Render edge handles for hole + this.renderEdgeHandles( + annotation, + annotationIndex, + holeCanvasPoints, + color, + holeIndex, + (annIdx, edgeIdx, pos, holeIdx) => { + this.insertPointOnEdge(annotation, annIdx, edgeIdx, pos, holeIdx); + }, + config.attachContextMenuHandler + ); + + // Render vertex anchor points for hole + holeCanvasPoints.forEach((point, pointIndex) => { + this.createAnchorPoint(point[0], point[1], color, { + ...config, + pointIndex, + holeIndex, + }); + }); + } else if (hole.shape_type === "rectangle" && this.context.getTool) { + // Delegate rectangle holes to RectangleTool + const rectTool = this.context.getTool("rectangle"); + if (rectTool) { + const holeCanvasPoints = this.context.getCanvasCoordinates( + hole.points, + this.context.imageNode + ); + if (holeCanvasPoints.length === 2) { + const [hp1, hp2] = holeCanvasPoints; + const holeCorners = [ + { x: hp1[0], y: hp1[1] }, + { x: hp2[0], y: hp1[1] }, + { x: hp2[0], y: hp2[1] }, + { x: hp1[0], y: hp2[1] }, + ]; + holeCorners.forEach((corner, pointIndex) => { + this.createAnchorPoint(corner.x, corner.y, color, { + ...config, + pointIndex, + holeIndex, + }); + }); + } + } + } + }); + + this.context.annotationLayer?.batchDraw(); + } + + renderEdgeHandles( + _annotation: ImageAnnotationAnswer, + annotationIndex: number, + canvasPoints: number[][], + color: string, + holeIndex: number | null, + onInsertPoint: ( + annotationIndex: number, + edgeIndex: number, + position: { x: number; y: number }, + holeIndex: number | null + ) => void, + attachContextMenuHandler?: ( + element: Konva.Node, + annotationIndex: number, + holeIndex?: number + ) => void + ): void { + if (!this.context.annotationLayer) return; + + // Create edge handles between consecutive points + for (let i = 0; i < canvasPoints.length; i++) { + const startPoint = canvasPoints[i]; + const endPoint = canvasPoints[(i + 1) % canvasPoints.length]; + + const edgeId = + holeIndex !== null + ? `edge-${annotationIndex}-hole-${holeIndex}-${i}` + : `edge-${annotationIndex}-${i}`; + + const edgeLine = new Konva.Line({ + points: [startPoint[0], startPoint[1], endPoint[0], endPoint[1]], + stroke: color, + strokeWidth: 16, + opacity: 0, + lineCap: "round", + lineJoin: "round", + name: "edge-handle", + id: edgeId, + }); + + // Hover effects + edgeLine.on("mouseenter", () => { + const stage = edgeLine.getStage(); + if (stage) { + stage.container().style.cursor = "copy"; + } + edgeLine.opacity(0.3); + edgeLine.strokeWidth(4); + this.context.annotationLayer?.batchDraw(); + }); + + edgeLine.on("mouseleave", () => { + const stage = edgeLine.getStage(); + if (stage) { + stage.container().style.cursor = "default"; + } + edgeLine.opacity(0); + edgeLine.strokeWidth(16); + this.context.annotationLayer?.batchDraw(); + }); + + // Click to insert point + edgeLine.on("click", () => { + const stage = edgeLine.getStage(); + if (!stage) return; + + const pointerPos = stage.getPointerPosition(); + if (!pointerPos) return; + + onInsertPoint(annotationIndex, i, pointerPos, holeIndex); + }); + + // Allow context menu on edge handles + if (attachContextMenuHandler) { + attachContextMenuHandler( + edgeLine, + annotationIndex, + holeIndex ?? undefined + ); + } + + this.context.annotationLayer.add(edgeLine); + } + } + + constrainPointToParentShape( + parentAnnotation: ImageAnnotationAnswer, + stagePoint: Konva.Vector2d, + holeIndex: number | null + ): Konva.Vector2d { + // Only constrain when moving a hole point + if (holeIndex === null || !parentAnnotation) { + return stagePoint; + } + + if (parentAnnotation.shape_type === "rectangle") { + const parentBounds = getParentShapeBounds(parentAnnotation.points); + const clampedImagePoint = clampToParentBounds( + this.context.getImageCoordinates( + [[stagePoint.x, stagePoint.y]], + this.context.imageNode + )[0], + parentBounds + ); + const [canvasX, canvasY] = this.context.getCanvasCoordinates( + [clampedImagePoint], + this.context.imageNode + )[0]; + return { x: canvasX, y: canvasY }; + } + + if (parentAnnotation.shape_type === "polygon") { + const polygonCanvasPoints = this.context.getCanvasCoordinates( + parentAnnotation.points, + this.context.imageNode + ); + + // Check if point is within parent polygon's bounding box + if (checkPointWithinParent(stagePoint, polygonCanvasPoints)) { + // Point is inside, allow free movement + return stagePoint; + } + + // Point is outside, snap to closest point on polygon boundary + const polygonPoints = polygonCanvasPoints.map(([x, y]) => ({ x, y })); + return getClosestPointOnPolygon(stagePoint, polygonPoints); + } + + return stagePoint; + } + + insertPointOnEdge( + annotation: ImageAnnotationAnswer, + annotationIndex: number, + edgeIndex: number, + position: { x: number; y: number }, + holeIndex: number | null + ): void { + const constrainedPos = this.constrainPointToParentShape( + annotation, + position, + holeIndex + ); + + // Convert canvas position to image coordinates + const imageCoords = this.context.getImageCoordinates( + [[constrainedPos.x, constrainedPos.y]], + this.context.imageNode + )[0]; + + if (holeIndex !== null) { + const hole = annotation.holes?.[holeIndex]; + if (hole && hole.shape_type === "polygon") { + hole.points.splice(edgeIndex + 1, 0, imageCoords); + } + } else if (annotation.shape_type === "polygon") { + annotation.points.splice(edgeIndex + 1, 0, imageCoords); + } + + // Update the annotation shape visually + this.updateAnnotationShape(annotation, annotationIndex); + this.context.updateAnswer(); + + // Re-render anchor points to show the new point + if (this.context.renderAnchorPoints) { + this.context.renderAnchorPoints(annotationIndex); + } + } + + updateAnnotationFromDrag( + annotation: ImageAnnotationAnswer, + pointIndex: number, + newPos: { x: number; y: number }, + holeIndex: number | null + ): void { + const imageCoords = this.context.getImageCoordinates( + [[newPos.x, newPos.y]], + this.context.imageNode + )[0]; + + const target = + holeIndex !== null ? annotation.holes?.[holeIndex] : annotation; + if (!target) return; + + // Update the point - assumes this tool is called for polygon shapes only + target.points[pointIndex] = imageCoords; + } +} diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/RectangleTool.test.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/RectangleTool.test.ts new file mode 100644 index 0000000000..0c695d55b1 --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/RectangleTool.test.ts @@ -0,0 +1,155 @@ +import { RectangleTool } from "./RectangleTool"; +import { InteractionContext } from "./IToolInteraction"; + +const createMockContext = (): InteractionContext => ({ + annotationLayer: null, + imageLayer: null, + imageNode: null, + getAnnotationColor: (_label: string) => "#0000ff", + getImageCoordinates: (points: number[][]) => points, + getCanvasCoordinates: (points: number[][]) => points, + updateAnswer: jest.fn(), + renderAnnotations: jest.fn(), +}); + +describe("RectangleTool", () => { + describe("createInteraction", () => { + test("should create rectangle interaction with correct properties", () => { + const mockContext = createMockContext(); + const tool = new RectangleTool(mockContext as any); + + const interaction = tool.createInteraction( + mockContext, + { x: 100, y: 100 }, + "#0000ff", + false, + undefined, + { value: "test-label", color: "#0000ff" } + ); + + expect(interaction.kind).toBe("drawing"); + expect(interaction.toolType).toBe("rectangle"); + expect(interaction.isHole).toBe(false); + expect(interaction.color).toBe("#0000ff"); + }); + + test("should create hole interaction with parent index", () => { + const mockContext = createMockContext(); + const tool = new RectangleTool(mockContext as any); + + const holeInteraction = tool.createInteraction( + mockContext, + { x: 300, y: 300 }, + "#00ff00", + true, + 0, + undefined + ); + + expect(holeInteraction.isHole).toBe(true); + expect(holeInteraction.parentIndex).toBe(0); + expect(holeInteraction.color).toBe("#00ff00"); + }); + }); + + describe("rectangle drawing", () => { + test("should handle pointer move without errors", () => { + const mockContext = createMockContext(); + const tool = new RectangleTool(mockContext as any); + + const interaction = tool.createInteraction( + mockContext, + { x: 100, y: 100 }, + "#0000ff", + false, + undefined, + { value: "test-label", color: "#0000ff" } + ); + + expect(() => { + interaction.onPointerMove({ x: 200, y: 200 }); + }).not.toThrow(); + }); + + test("should complete with valid size", () => { + const mockContext = createMockContext(); + const tool = new RectangleTool(mockContext as any); + + const interaction = tool.createInteraction( + mockContext, + { x: 100, y: 100 }, + "#0000ff", + false, + undefined, + { value: "test-label", color: "#0000ff" } + ); + + const result = interaction.onPointerUp({ x: 200, y: 200 }); + expect(result.shouldComplete).toBe(true); + }); + + test("should cancel with size below minimum", () => { + const mockContext = createMockContext(); + const tool = new RectangleTool(mockContext as any); + + const smallInteraction = tool.createInteraction( + mockContext, + { x: 100, y: 100 }, + "#0000ff", + false, + undefined, + { value: "test-label", color: "#0000ff" } + ); + + const result = smallInteraction.onPointerUp({ x: 102, y: 102 }); + expect(result.shouldCancel).toBe(true); + }); + }); + + describe("keyboard shortcuts", () => { + test("should cancel on Escape key", () => { + const mockContext = createMockContext(); + const tool = new RectangleTool(mockContext as any); + + const interaction = tool.createInteraction( + mockContext, + { x: 100, y: 100 }, + "#0000ff", + false, + undefined, + { value: "test-label", color: "#0000ff" } + ); + + const escapeEvent = new KeyboardEvent("keydown", { key: "Escape" }); + const result = interaction.onKeyDown(escapeEvent); + + expect(result.shouldCancel).toBe(true); + }); + }); + + describe("complete", () => { + test("should create annotation with correct properties", () => { + const mockContext = createMockContext(); + const tool = new RectangleTool(mockContext as any); + + const interaction = tool.createInteraction( + mockContext, + { x: 50, y: 50 }, + "#ff0000", + false, + undefined, + { value: "my-label", color: "#ff0000" } + ); + + interaction.onPointerMove({ x: 150, y: 150 }); + interaction.onPointerUp({ x: 150, y: 150 }); + + const annotation = interaction.complete(); + + expect(annotation).not.toBeNull(); + expect(annotation?.label).toBe("my-label"); + expect(annotation?.shape_type).toBe("rectangle"); + expect(annotation?.points.length).toBe(2); + }); + }); +}); diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/RectangleTool.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/RectangleTool.ts new file mode 100644 index 0000000000..21da2bd66a --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/RectangleTool.ts @@ -0,0 +1,449 @@ +import Konva from "konva"; +import { + getParentShapeBounds, + clampToParentBounds, + getClosestPointOnPolygon, + isPointWithinParent as checkPointWithinParent, +} from "../utils/geometry"; +import { updateRectanglePoint } from "../utils/konvaShapes"; +import { ANNOTATION_SHORTCUTS, matchesKey } from "../utils/keyboardShortcuts"; +import { BaseAnnotationTool } from "./BaseAnnotationTool"; +import { DrawingState, AnchorPointConfig } from "./IAnnotationTool"; +import { + ToolInteraction, + InteractionContext, + InteractionResult, +} from "./IToolInteraction"; +import { ImageAnnotationAnswer } from "~/v1/domain/entities/IAnswer"; + +interface RectangleDrawingState extends DrawingState { + kind: "draw-rect" | "draw-hole-rect"; + start: { x: number; y: number }; + color: string; + parentIndex?: number; +} + +/** + * Interaction implementation for rectangle drawing. + * Encapsulates all state and behavior for drawing a rectangle annotation. + */ +class RectangleInteraction implements ToolInteraction { + readonly kind = "drawing" as const; + readonly toolType = "rectangle"; + readonly isHole: boolean; + readonly parentIndex?: number; + readonly color: string; + + private startPos: { x: number; y: number }; + private currentPos: { x: number; y: number }; + private drawingShape: Konva.Rect | null = null; + private readonly MIN_SIZE = 5; + + constructor( + private context: InteractionContext, + private selectedLabel: { value: string; color: string } | undefined, + startPos: { x: number; y: number }, + color: string, + isHole: boolean, + parentIndex?: number + ) { + this.startPos = startPos; + this.currentPos = startPos; + this.color = color; + this.isHole = isHole; + this.parentIndex = parentIndex; + + // Initialize drawing shape + this.drawingShape = new Konva.Rect({ + x: startPos.x, + y: startPos.y, + width: 0, + height: 0, + stroke: color, + strokeWidth: 2, + dash: [5, 5], + }); + context.annotationLayer?.add(this.drawingShape); + context.annotationLayer?.batchDraw(); + } + + onPointerDown(_pos: { x: number; y: number }): InteractionResult { + // Rectangle doesn't handle additional pointer downs during drawing + return { shouldContinue: true }; + } + + onPointerMove(pos: { x: number; y: number }): void { + this.currentPos = pos; + if (this.drawingShape) { + const width = pos.x - this.startPos.x; + const height = pos.y - this.startPos.y; + this.drawingShape.width(width); + this.drawingShape.height(height); + this.context.annotationLayer?.batchDraw(); + } + } + + onPointerUp(pos: { x: number; y: number }): InteractionResult { + this.currentPos = pos; + + const width = Math.abs(pos.x - this.startPos.x); + const height = Math.abs(pos.y - this.startPos.y); + + // Check minimum size + if (width < this.MIN_SIZE || height < this.MIN_SIZE) { + return { shouldCancel: true }; + } + + return { shouldComplete: true }; + } + + onKeyDown(e: KeyboardEvent): InteractionResult { + if (matchesKey(e, ANNOTATION_SHORTCUTS.CANCEL)) { + e.preventDefault(); + return { shouldCancel: true }; + } + return { shouldContinue: true }; + } + + complete(): ImageAnnotationAnswer | null { + const width = Math.abs(this.currentPos.x - this.startPos.x); + const height = Math.abs(this.currentPos.y - this.startPos.y); + + // Verify minimum size + if (width < this.MIN_SIZE || height < this.MIN_SIZE) { + return null; + } + + // Convert to image coordinates + const imageCoords = this.context.getImageCoordinates( + [ + [this.startPos.x, this.startPos.y], + [this.currentPos.x, this.currentPos.y], + ], + this.context.imageNode + ); + + if (this.isHole) { + // Hole creation is handled by the controller + // Return null to signal that the controller should handle it + return null; + } + + if (!this.selectedLabel) { + return null; + } + + return { + label: this.selectedLabel.value, + points: imageCoords, + shape_type: "rectangle", + flags: {}, + }; + } + + cancel(): void { + this.cleanup(); + } + + cleanup(): void { + if (this.drawingShape) { + this.drawingShape.destroy(); + this.drawingShape = null; + } + this.context.annotationLayer?.batchDraw(); + } + + /** + * Get the start and current positions for hole creation (used by controller) + */ + getStartPos(): { x: number; y: number } { + return this.startPos; + } + + getCurrentPos(): { x: number; y: number } { + return this.currentPos; + } +} + +/** + * Tool for drawing and editing rectangle annotations + */ +export class RectangleTool extends BaseAnnotationTool { + readonly shapeType = "rectangle"; + + createInteraction( + context: InteractionContext, + startPos: { x: number; y: number }, + color: string, + isHole: boolean, + parentIndex?: number, + selectedLabel?: { value: string; color: string } + ): ToolInteraction { + return new RectangleInteraction( + context, + selectedLabel, + startPos, + color, + isHole, + parentIndex + ); + } + + startDrawing( + pos: { x: number; y: number }, + color: string, + isHole: boolean, + parentIndex?: number + ): RectangleDrawingState { + this.drawingShape = new Konva.Rect({ + x: pos.x, + y: pos.y, + width: 0, + height: 0, + stroke: color, + strokeWidth: 2, + dash: [5, 5], + }); + this.context.annotationLayer?.add(this.drawingShape); + this.context.annotationLayer?.batchDraw(); + + return isHole + ? { + kind: "draw-hole-rect", + start: pos, + color, + parentIndex: parentIndex!, + } + : { + kind: "draw-rect", + start: pos, + color, + }; + } + + updateDrawing(state: DrawingState, pos: { x: number; y: number }): void { + const rectState = state as RectangleDrawingState; + if (this.drawingShape) { + const rect = this.drawingShape as Konva.Rect; + const width = pos.x - rectState.start.x; + const height = pos.y - rectState.start.y; + rect.width(width); + rect.height(height); + this.context.annotationLayer?.batchDraw(); + } + } + + completeDrawing( + state: DrawingState, + _annotations: ImageAnnotationAnswer[], + selectedLabel: { value: string; color: string } | undefined + ): ImageAnnotationAnswer | null { + const rectState = state as RectangleDrawingState; + + // Convert to image coordinates + const imageCoords = this.context.getImageCoordinates( + [ + [rectState.start.x, rectState.start.y], + [rectState.start.x, rectState.start.y], // Will be updated by caller with actual end position + ], + this.context.imageNode + ); + + if (rectState.kind === "draw-hole-rect") { + // Return null - hole creation is handled by the caller + return null; + } + + // Normal annotation creation + if (!selectedLabel) { + return null; + } + + return { + label: selectedLabel.value, + points: imageCoords, + shape_type: "rectangle", + flags: {}, + }; + } + + cancelDrawing(state: DrawingState): void { + this.cleanupDrawing(state); + } + + cleanupDrawing(_state: DrawingState): void { + if (this.drawingShape) { + this.drawingShape.destroy(); + this.drawingShape = null; + } + this.context.annotationLayer?.batchDraw(); + } + + renderAnchorPoints( + annotation: ImageAnnotationAnswer, + annotationIndex: number, + color: string, + config: AnchorPointConfig + ): void { + const canvasPoints = this.context.getCanvasCoordinates( + annotation.points, + this.context.imageNode + ); + + if (canvasPoints.length !== 2) return; + + const [p1, p2] = canvasPoints; + const corners = [ + { x: p1[0], y: p1[1] }, // top-left + { x: p2[0], y: p1[1] }, // top-right + { x: p2[0], y: p2[1] }, // bottom-right + { x: p1[0], y: p2[1] }, // bottom-left + ]; + + corners.forEach((corner, pointIndex) => { + this.createAnchorPoint(corner.x, corner.y, color, { + ...config, + pointIndex, + }); + }); + + // Render hole anchors - delegate to appropriate tool based on hole shape type + annotation.holes?.forEach((hole, holeIndex) => { + if (hole.shape_type === "rectangle") { + // Render rectangle holes directly + const holeCanvasPoints = this.context.getCanvasCoordinates( + hole.points, + this.context.imageNode + ); + if (holeCanvasPoints.length === 2) { + const [hp1, hp2] = holeCanvasPoints; + const holeCorners = [ + { x: hp1[0], y: hp1[1] }, + { x: hp2[0], y: hp1[1] }, + { x: hp2[0], y: hp2[1] }, + { x: hp1[0], y: hp2[1] }, + ]; + holeCorners.forEach((corner, pointIndex) => { + this.createAnchorPoint(corner.x, corner.y, color, { + ...config, + pointIndex, + holeIndex, + }); + }); + } + } else if (hole.shape_type === "polygon" && this.context.getTool) { + // Delegate polygon holes to PolygonTool + const polyTool = this.context.getTool("polygon"); + if (polyTool) { + const holeCanvasPoints = this.context.getCanvasCoordinates( + hole.points, + this.context.imageNode + ); + + // Render edge handles FIRST (so they're behind anchor points) + if (polyTool.renderEdgeHandles) { + polyTool.renderEdgeHandles( + annotation, + annotationIndex, + holeCanvasPoints, + color, + holeIndex, + (annIdx, edgeIdx, pos, holeIdx) => { + if (polyTool.insertPointOnEdge) { + polyTool.insertPointOnEdge( + annotation, + annIdx, + edgeIdx, + pos, + holeIdx + ); + } + }, + config.attachContextMenuHandler + ); + } + + // Render vertex anchor points LAST (so they're on top and clickable) + holeCanvasPoints.forEach((point, pointIndex) => { + this.createAnchorPoint(point[0], point[1], color, { + ...config, + pointIndex, + holeIndex, + }); + }); + } + } + }); + + this.context.annotationLayer?.batchDraw(); + } + + constrainPointToParentShape( + parentAnnotation: ImageAnnotationAnswer, + stagePoint: Konva.Vector2d, + holeIndex: number | null + ): Konva.Vector2d { + // Only constrain when moving a hole point + if (holeIndex === null || !parentAnnotation) { + return stagePoint; + } + + if (parentAnnotation.shape_type === "rectangle") { + const parentBounds = getParentShapeBounds(parentAnnotation.points); + const clampedImagePoint = clampToParentBounds( + this.context.getImageCoordinates( + [[stagePoint.x, stagePoint.y]], + this.context.imageNode + )[0], + parentBounds + ); + const [canvasX, canvasY] = this.context.getCanvasCoordinates( + [clampedImagePoint], + this.context.imageNode + )[0]; + return { x: canvasX, y: canvasY }; + } + + if (parentAnnotation.shape_type === "polygon") { + const polygonCanvasPoints = this.context.getCanvasCoordinates( + parentAnnotation.points, + this.context.imageNode + ); + + // Check if point is within parent polygon's bounding box + if (checkPointWithinParent(stagePoint, polygonCanvasPoints)) { + // Point is inside, allow free movement + return stagePoint; + } + + // Point is outside, snap to closest point on polygon boundary + const polygonPoints = polygonCanvasPoints.map(([x, y]) => ({ x, y })); + return getClosestPointOnPolygon(stagePoint, polygonPoints); + } + + return stagePoint; + } + + updateAnnotationFromDrag( + annotation: ImageAnnotationAnswer, + pointIndex: number, + newPos: { x: number; y: number }, + holeIndex: number | null + ): void { + const imageCoords = this.context.getImageCoordinates( + [[newPos.x, newPos.y]], + this.context.imageNode + )[0]; + + const target = + holeIndex !== null ? annotation.holes?.[holeIndex] : annotation; + if (!target) return; + + // Update the rectangle point - assumes this tool is called for rectangle shapes only + target.points = updateRectanglePoint( + target.points, + pointIndex, + imageCoords + ); + } +} diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/index.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/index.ts new file mode 100644 index 0000000000..1ab557dbd2 --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/tools/index.ts @@ -0,0 +1,15 @@ +export { + IAnnotationTool, + ToolContext, + AnchorPointConfig, + DrawingState, +} from "./IAnnotationTool"; +export { + ToolInteraction, + InteractionContext, + InteractionResult, +} from "./IToolInteraction"; +export { BaseAnnotationTool } from "./BaseAnnotationTool"; +export { RectangleTool } from "./RectangleTool"; +export { PolygonTool } from "./PolygonTool"; +export { AnnotationToolFactory } from "./AnnotationToolFactory"; diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/useImageAnnotationFieldViewModel.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/useImageAnnotationFieldViewModel.ts new file mode 100644 index 0000000000..f6f121a656 --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/useImageAnnotationFieldViewModel.ts @@ -0,0 +1,934 @@ +import { ref, computed, onMounted, watch, onUnmounted } from "vue-demi"; +import Konva from "konva"; +import { useImageAnnotationSharedState } from "./useImageAnnotationSharedState"; +import { initKonvaStage } from "./composables/useKonvaStage"; +import { loadImageNode } from "./composables/useImageLoader"; +import { useResize } from "./composables/useResize"; +import { useKeyboardShortcuts } from "./composables/useKeyboardShortcuts"; +import { useContextMenu } from "./composables/useContextMenu"; +import { AnnotationRenderer } from "./rendering/AnnotationRenderer"; +import { getImageCoordinates, getCanvasCoordinates } from "./utils/coordinates"; +import { isPointWithinParent as checkPointWithinParent } from "./utils/geometry"; +import { completeHoleCreation } from "./utils/holeCreationUtils"; +import { AnnotationToolFactory } from "./tools/AnnotationToolFactory"; +import { ToolContext } from "./tools/IAnnotationTool"; +import { ToolInteraction, InteractionContext } from "./tools/IToolInteraction"; +import { Question } from "~/v1/domain/entities/question/Question"; +import { ImageAnnotationQuestionAnswer } from "~/v1/domain/entities/question/QuestionAnswer"; +import { useNotifications } from "~/v1/infrastructure/services/useNotifications"; + +/** + * Simplified mode type - only 3 conceptual states + * Drawing state is now owned by activeInteraction + */ +type Mode = + | { kind: "idle" } + | { kind: "drawing" } + | { kind: "edit"; annotationIndex: number }; + +export const useImageAnnotationFieldViewModel = (props: { + id: string; + name: string; + content: string; + imageAnnotationQuestion: Question; +}) => { + const { content, imageAnnotationQuestion } = props; + const answer = + imageAnnotationQuestion.answer as ImageAnnotationQuestionAnswer; + const sharedState = useImageAnnotationSharedState(answer); + const notification = useNotifications(); + + // Constants + const MAX_HOLES_PER_SHAPE = 10; + + // Konva objects (managed by composables) + let stage: Konva.Stage | null = null; + let imageLayer: Konva.Layer | null = null; + let annotationLayer: Konva.Layer | null = null; + let imageNode: Konva.Image | null = null; + let originalImageWidth = 0; + let originalImageHeight = 0; + let toolFactory: AnnotationToolFactory | null = null; + let renderer: AnnotationRenderer | null = null; + + const canvasContainer = ref(null); + const imageLoaded = ref(false); + const hasError = ref(false); + const hoveredAnnotation = ref(null); + const draggingPoint = ref<{ + annotationIndex: number; + pointIndex: number; + holeIndex: number | null; + } | null>(null); + const mode = ref({ kind: "idle" }); + const activeInteraction = ref(null); + + // Use resize composable to handle canvas resizing + useResize( + canvasContainer, + () => stage, + () => imageNode, + () => ({ width: originalImageWidth, height: originalImageHeight }), + () => annotationLayer, + { + debounceMs: 150, + onResize: () => { + renderAnnotations(); + if (editMode.value.active && editMode.value.annotationIndex !== null) { + renderAnchorPoints(editMode.value.annotationIndex); + } + }, + } + ); + + const editMode = computed(() => ({ + active: sharedState.editModeActive.value, + annotationIndex: sharedState.currentAnnotationIndex.value, + })); + + const selectedTool = computed(() => sharedState.selectedTool.value); + + const annotations = computed(() => answer.values); + + const selectedLabel = computed(() => { + return answer.options.find((opt) => opt.isSelected); + }); + + const getAnnotationColor = (labelValue: string) => + answer.getAnnotationColor(labelValue); + + /** + * Create base context with shared properties + */ + const getBaseContext = () => ({ + annotationLayer, + imageLayer, + imageNode, + getAnnotationColor, + getImageCoordinates: (points: number[][], _imageNode: Konva.Image | null) => + getImageCoordinates(points, imageNode), + getCanvasCoordinates: ( + points: number[][], + _imageNode: Konva.Image | null + ) => getCanvasCoordinates(points, imageNode), + updateAnswer, + renderAnnotations, + }); + + const getToolContext = (): ToolContext => ({ + ...getBaseContext(), + renderAnchorPoints, + getTool: (toolType: string) => toolFactory?.getTool(toolType) || null, + }); + + const initializeToolFactory = () => { + toolFactory = new AnnotationToolFactory(getToolContext()); + }; + + const initializeRenderer = () => { + renderer = new AnnotationRenderer({ + annotationLayer, + imageLayer, + imageNode, + toolFactory, + getAnnotationColor, + onHoverAnnotation: hoverAnnotation, + onUnhoverAnnotation: unhoverAnnotation, + }); + }; + + /** + * Create InteractionContext for tool interactions + */ + const getInteractionContext = (): InteractionContext => getBaseContext(); + + /** + * Handle interaction result from event handlers + */ + const handleInteractionResult = (result: { + shouldComplete?: boolean; + shouldCancel?: boolean; + shouldContinue?: boolean; + }) => { + if (result.shouldComplete) { + completeInteraction(); + } else if (result.shouldCancel) { + cancelInteraction(); + } + // shouldContinue or undefined - interaction continues + }; + + /** + * Start a new drawing interaction + */ + const startDrawingInteraction = (pos: { x: number; y: number }) => { + const tool = toolFactory?.getTool(selectedTool.value); + if (!tool) return; + + // Determine if we're in hole drawing mode + const isHole = sharedState.holeDrawingMode.value.active; + const parentIndex = + sharedState.holeDrawingMode.value.parentIndex ?? undefined; + + if (isHole && parentIndex !== undefined) { + // Drawing a hole - check if point is within parent + if (!isPointWithinParent(pos, parentIndex)) return; + + const color = getAnnotationColor(annotations.value[parentIndex].label); + activeInteraction.value = tool.createInteraction( + getInteractionContext(), + pos, + color, + true, + parentIndex + ); + } else { + // Drawing a normal annotation + if (!selectedLabel.value) { + notification.notify({ + message: "Please select a label first", + type: "warning", + }); + return; + } + + const color = selectedLabel.value.color || "#cccccc"; + activeInteraction.value = tool.createInteraction( + getInteractionContext(), + pos, + color, + false, + undefined, + selectedLabel.value + ); + } + + mode.value = { kind: "drawing" }; + }; + + const isPointWithinParent = ( + point: { x: number; y: number }, + parentIndex: number + ): boolean => { + const parent = annotations.value[parentIndex]; + if (!parent) return false; + + const canvasPoints = getCanvasCoordinates(parent.points, imageNode); + return checkPointWithinParent(point, canvasPoints); + }; + + /** + * Complete the current interaction and create annotation or hole + */ + const completeInteraction = () => { + if (!activeInteraction.value) return; + + const annotation = activeInteraction.value.complete(); + + if (annotation) { + // Normal annotation creation + answer.values.push(annotation); + updateAnswer(); + } else if ( + activeInteraction.value.isHole && + activeInteraction.value.parentIndex !== undefined + ) { + // Hole creation + const parentIndex = activeInteraction.value.parentIndex; + const parent = annotations.value[parentIndex]; + + // Use utility to complete hole creation + const success = completeHoleCreation( + activeInteraction.value, + parent, + getImageCoordinates, + imageNode + ); + + if (!success) { + // Failed to create hole - cleanup and exit + activeInteraction.value.cleanup(); + activeInteraction.value = null; + mode.value = { kind: "idle" }; + return; + } + + updateAnswer(); + + // Stay in hole drawing mode for adding more holes + highlightParentForHoleDrawing(parentIndex); + } + + activeInteraction.value.cleanup(); + activeInteraction.value = null; + mode.value = { kind: "idle" }; + renderAnnotations(); + }; + + /** + * Cancel the current interaction + */ + const cancelInteraction = () => { + if (!activeInteraction.value) return; + + activeInteraction.value.cancel(); + activeInteraction.value = null; + mode.value = { kind: "idle" }; + }; + + const highlightAnnotation = (index: number, highlight: boolean) => { + if (!renderer) return; + const isEditing = + editMode.value.active && editMode.value.annotationIndex === index; + const annotation = annotations.value[index]; + const color = getAnnotationColor(annotation.label); + + renderer.highlightAnnotation(index, highlight, isEditing, color); + }; + + const hoverAnnotation = (index: number) => { + hoveredAnnotation.value = index; + if (!editMode.value.active) { + highlightAnnotation(index, true); + } + }; + + const unhoverAnnotation = () => { + if (hoveredAnnotation.value !== null && !editMode.value.active) { + highlightAnnotation(hoveredAnnotation.value, false); + } + hoveredAnnotation.value = null; + }; + + const enterEditMode = (annotationIndex: number) => { + // Cancel any ongoing drawing + if (activeInteraction.value) { + cancelInteraction(); + } + + // Exit hole drawing mode if active + if (sharedState.holeDrawingMode.value.active) { + const parentIndex = sharedState.holeDrawingMode.value.parentIndex; + + // Restore all annotations + restoreAllAnnotations(); + + // Remove parent highlight + if (parentIndex !== null) { + highlightAnnotation(parentIndex, false); + } + + // Clear hole drawing mode + sharedState.holeDrawingMode.value = { + active: false, + parentIndex: null, + }; + } + + // Update mode state + mode.value = { kind: "edit", annotationIndex }; + + // Update sharedState (editMode computed will reflect this) + sharedState.editModeActive.value = true; + sharedState.currentAnnotationIndex.value = annotationIndex; + + // Broadcast edit mode state to question component + (answer as any).editModeState = true; + + // Signal to question component to select the annotation's label + const annotation = annotations.value[annotationIndex]; + if (annotation && annotation.label) { + sharedState.selectLabelData.value = { + labelValue: annotation.label, + }; + sharedState.selectLabelTrigger.value++; + } + + // Move edited shape to top of z-order (so it receives events first) + const shapeNode = annotationLayer?.findOne( + `#annotation-${annotationIndex}` + ); + const groupNode = annotationLayer?.findOne( + `.annotation-group#annotation-${annotationIndex}` + ); + if (shapeNode) { + shapeNode.moveToTop(); + } else if (groupNode) { + groupNode.moveToTop(); + } + + // Show anchor points for the selected annotation + renderAnchorPoints(annotationIndex); + + // Highlight the annotation + highlightAnnotation(annotationIndex, true); + + // Fade other annotations + fadeNonEditedAnnotations(annotationIndex); + + contextMenu.hide(); + + annotationLayer?.batchDraw(); + }; + + const exitEditMode = () => { + if (editMode.value.annotationIndex !== null) { + highlightAnnotation(editMode.value.annotationIndex, false); + } + + removeAnchorPoints(); + + // Restore original z-order by re-rendering all annotations + // This ensures consistent ordering based on array index + renderAnnotations(); + + restoreAllAnnotations(); + + // Update mode state + mode.value = { kind: "idle" }; + + // Update sharedState (editMode computed will reflect this) + sharedState.editModeActive.value = false; + sharedState.currentAnnotationIndex.value = null; + + // Broadcast edit mode state to question component + (answer as any).editModeState = false; + + annotationLayer?.batchDraw(); + }; + + const editNextAnnotation = () => { + if (!editMode.value.active || editMode.value.annotationIndex === null) + return; + + const currentIndex = editMode.value.annotationIndex; + const nextIndex = (currentIndex + 1) % annotations.value.length; + + enterEditMode(nextIndex); + }; + + const editPreviousAnnotation = () => { + if (!editMode.value.active || editMode.value.annotationIndex === null) + return; + + const currentIndex = editMode.value.annotationIndex; + const prevIndex = + currentIndex === 0 ? annotations.value.length - 1 : currentIndex - 1; + + enterEditMode(prevIndex); + }; + + /** + * Delete a shape (annotation) from the canvas and data. + * Called from: question list, context menu, keyboard shortcuts. + */ + const deleteShape = (index: number) => { + // If we're in edit mode and deleting the shape being edited, exit edit mode first + // This ensures anchor points and edge handles are properly removed + if (editMode.value.active && editMode.value.annotationIndex === index) { + exitEditMode(); + } + + answer.deleteAnnotation(index); + + // Update answer - this triggers the watch on answer.values.length which re-renders the canvas + updateAnswer(); + }; + + const enterHoleDrawingMode = (parentIndex: number) => { + // Cancel any ongoing drawing + if (activeInteraction.value) { + cancelInteraction(); + } + + // Exit edit mode if active + if (editMode.value.active) { + exitEditMode(); + } + + // Check if parent already has maximum holes + const parent = annotations.value[parentIndex]; + if (parent.holes && parent.holes.length >= MAX_HOLES_PER_SHAPE) { + notification.notify({ + message: `Maximum ${MAX_HOLES_PER_SHAPE} holes per shape reached`, + type: "warning", + }); + return; + } + + // Set hole drawing mode + sharedState.holeDrawingMode.value = { + active: true, + parentIndex, + }; + + // Highlight the parent shape + highlightParentForHoleDrawing(parentIndex); + + // Fade other annotations + fadeNonEditedAnnotations(parentIndex); + }; + + const exitHoleDrawingMode = () => { + if (!sharedState.holeDrawingMode.value.active) return; + + // Cancel any ongoing drawing + if (activeInteraction.value) { + cancelInteraction(); + } + + const parentIndex = sharedState.holeDrawingMode.value.parentIndex; + + // Restore all annotations + restoreAllAnnotations(); + + // Remove parent highlight + if (parentIndex !== null) { + highlightAnnotation(parentIndex, false); + } + + // Clear hole drawing mode + sharedState.holeDrawingMode.value = { + active: false, + parentIndex: null, + }; + }; + + const highlightParentForHoleDrawing = (parentIndex: number) => { + if (!renderer) return; + const annotation = annotations.value[parentIndex]; + renderer.highlightParentForHoleDrawing(parentIndex, annotation); + }; + + const fadeNonEditedAnnotations = (editingIndex: number) => { + if (!renderer) return; + renderer.fadeNonEditedAnnotations(editingIndex, annotations.value); + }; + + const restoreAllAnnotations = () => { + if (!renderer) return; + renderer.restoreAllAnnotations(annotations.value); + }; + + const renderAnchorPoints = (annotationIndex: number) => { + if (!renderer) return; + + const annotation = annotations.value[annotationIndex]; + if (!annotation) return; + + const color = getAnnotationColor(annotation.label); + + const anchorConfig = { + annotationIndex, + pointIndex: 0, // Will be overridden by tool + holeIndex: null, + onDragStart: (annIdx: number, ptIdx: number, holeIdx: number | null) => { + draggingPoint.value = { + annotationIndex: annIdx, + pointIndex: ptIdx, + holeIndex: holeIdx, + }; + }, + onDragMove: ( + annIdx: number, + ptIdx: number, + pos: { x: number; y: number }, + holeIdx: number | null + ) => { + // Constrain point if editing a hole + let constrainedPos = pos; + if (holeIdx !== null && toolFactory) { + // Get the tool for the hole being dragged (not the parent) + const holeShape = annotation.holes?.[holeIdx]; + if (holeShape) { + const holeTool = toolFactory.getTool(holeShape.shape_type); + if (holeTool) { + constrainedPos = holeTool.constrainPointToParentShape( + annotation, + pos, + holeIdx + ); + } + } + } + updateAnnotationFromDrag(annIdx, ptIdx, constrainedPos, holeIdx); + + // Update anchor point positions for rectangles during drag + // This ensures all corner anchors move correctly when dragging any corner + const targetAnnotation = annotations.value[annIdx]; + if (targetAnnotation && renderer) { + // Check if we're dragging a rectangle (parent or hole) + const targetShape = + holeIdx !== null + ? targetAnnotation.holes?.[holeIdx] + : targetAnnotation; + if (targetShape && targetShape.shape_type === "rectangle") { + renderer.updateRectangleAnchorPositions( + annIdx, + holeIdx, + targetAnnotation + ); + } + } + }, + onDragEnd: (annIdx: number) => { + finalizeAnnotationEdit(annIdx); + draggingPoint.value = null; + }, + attachContextMenuHandler: contextMenu.attachContextMenuHandler, + }; + + renderer.renderAnchorPoints( + annotation, + annotationIndex, + color, + anchorConfig + ); + }; + + const removeAnchorPoints = () => { + if (!renderer) return; + renderer.removeAnchorPoints(); + }; + + const updateAnnotationFromDrag = ( + annotationIndex: number, + pointIndex: number, + newPos: { x: number; y: number }, + holeIndex: number | null + ) => { + const annotation = annotations.value[annotationIndex]; + if (!annotation || !toolFactory) return; + + // Determine which shape is being dragged (hole or parent) + const targetShape = + holeIndex !== null ? annotation.holes?.[holeIndex] : annotation; + if (!targetShape) return; + + // Get the tool for the actual shape being dragged + const tool = toolFactory.getTool(targetShape.shape_type); + if (!tool) return; + + tool.updateAnnotationFromDrag(annotation, pointIndex, newPos, holeIndex); + updateAnnotationShape(annotationIndex); + }; + + const updateAnnotationShape = (annotationIndex: number) => { + const annotation = annotations.value[annotationIndex]; + if (!annotation || !toolFactory) return; + + const tool = toolFactory.getTool(annotation.shape_type); + if (!tool) return; + + tool.updateAnnotationShape(annotation, annotationIndex); + }; + + const finalizeAnnotationEdit = (annotationIndex: number) => { + // Save the changes + updateAnswer(); + + // Re-render anchor points at new positions + renderAnchorPoints(annotationIndex); + }; + + const initCanvas = () => { + if (!canvasContainer.value) return; + + // Use composable to initialize stage and layers + const stageRefs = initKonvaStage( + canvasContainer.value, + handleMouseDown, + handleMouseMove, + handleMouseUp + ); + + stage = stageRefs.stage; + imageLayer = stageRefs.imageLayer; + annotationLayer = stageRefs.annotationLayer; + + // Initialize tool factory after layers are created + initializeToolFactory(); + + // Use composable to load image + loadImageNode(content, stage, imageLayer) + .then(({ imageNode: node, originalWidth, originalHeight }) => { + imageNode = node; + originalImageWidth = originalWidth; + originalImageHeight = originalHeight; + + // Initialize renderer AFTER imageNode is available + // This ensures coordinate transformations work correctly + initializeRenderer(); + + imageLoaded.value = true; + renderAnnotations(); + }) + .catch(() => { + hasError.value = true; + }); + + // Keyboard listener now handled by useKeyboardShortcuts composable + }; + + // Handle mouse events + const handleMouseDown = () => { + const pos = stage?.getPointerPosition(); + if (!pos) return; + + if (activeInteraction.value) { + // Delegate to active interaction + const result = activeInteraction.value.onPointerDown(pos); + handleInteractionResult(result); + } else if (mode.value.kind === "idle") { + // Start new interaction + startDrawingInteraction(pos); + } + }; + + const handleMouseMove = () => { + const pos = stage?.getPointerPosition(); + if (!pos || !activeInteraction.value) return; + + activeInteraction.value.onPointerMove(pos); + }; + + const handleMouseUp = () => { + const pos = stage?.getPointerPosition(); + if (!pos || !activeInteraction.value) return; + + const result = activeInteraction.value.onPointerUp(pos); + handleInteractionResult(result); + }; + + const renderAnnotations = () => { + if (!renderer) return; + renderer.renderAnnotations( + annotations.value, + contextMenu.attachContextMenuHandler + ); + }; + + const updateAnswer = () => { + imageAnnotationQuestion.answer.response({ + value: answer.valuesAnswered, + }); + }; + + // Initialize composables after all functions are declared + // Context menu (consolidated state + handlers) + const contextMenu = useContextMenu({ + onDelete: deleteShape, + onEdit: enterEditMode, + onAddHole: enterHoleDrawingMode, + onDeleteHole: (annotationIndex, holeIndex) => { + const annotation = annotations.value[annotationIndex]; + if (annotation && annotation.holes && annotation.holes[holeIndex]) { + annotation.holes.splice(holeIndex, 1); + if (annotation.holes.length === 0) { + delete annotation.holes; + } + updateAnswer(); + renderAnnotations(); + if ( + editMode.value.active && + editMode.value.annotationIndex === annotationIndex + ) { + renderAnchorPoints(annotationIndex); + } + } + }, + }); + + // Keyboard shortcuts + useKeyboardShortcuts( + { + mode, + activeInteraction, + holeDrawingModeActive: computed( + () => sharedState.holeDrawingMode.value.active + ), + annotationCount: computed(() => annotations.value.length), + }, + { + onExitEditMode: exitEditMode, + onNextAnnotation: editNextAnnotation, + onPreviousAnnotation: editPreviousAnnotation, + onDeleteInEditMode: () => { + if (mode.value.kind === "edit" && mode.value.annotationIndex !== null) { + const indexToDelete = mode.value.annotationIndex; + if (annotations.value.length > 1) { + editNextAnnotation(); + } else { + exitEditMode(); + } + deleteShape(indexToDelete); + } + }, + onExitHoleDrawingMode: exitHoleDrawingMode, + onInteractionKeyDown: (e) => { + if (activeInteraction.value) { + const result = activeInteraction.value.onKeyDown(e); + handleInteractionResult(result); + } + }, + } + ); + + // Watch for changes in annotations from the question component + watch( + () => answer.values.length, + () => { + renderAnnotations(); + } + ); + + // Watch for cancel polygon signal from question component + watch(sharedState.cancelPolygonTrigger, () => { + if (activeInteraction.value) { + cancelInteraction(); + } + }); + + // Watch for enter edit mode signal from question component + watch(sharedState.enterEditModeTrigger, () => { + const editModeData = sharedState.enterEditModeData.value; + if ( + editModeData && + editModeData.index !== null && + editModeData.index !== undefined + ) { + enterEditMode(editModeData.index); + } + }); + + // Watch for exit edit mode signal from question component + watch(sharedState.exitEditModeTrigger, () => { + if (editMode.value.active) { + exitEditMode(); + } + }); + + // Watch for delete shape signal from question component + watch(sharedState.deleteShapeTrigger, () => { + const deleteData = sharedState.deleteShapeData.value; + if ( + deleteData && + deleteData.index !== null && + deleteData.index !== undefined + ) { + deleteShape(deleteData.index); + } + }); + + // Watch for delete hole signal from question component + watch(sharedState.deleteHoleTrigger, () => { + const deleteData = sharedState.deleteHoleData.value; + if ( + deleteData && + deleteData.annotationIndex !== null && + deleteData.holeIndex !== null + ) { + const annotation = annotations.value[deleteData.annotationIndex]; + if ( + annotation && + annotation.holes && + annotation.holes[deleteData.holeIndex] + ) { + // Remove the hole from the array + annotation.holes.splice(deleteData.holeIndex, 1); + + // If no holes left, remove the holes array + if (annotation.holes.length === 0) { + delete annotation.holes; + } + + // Update the answer + updateAnswer(); + + // Re-render the canvas + renderAnnotations(); + + // If in edit mode for this annotation, re-render anchor points + if ( + editMode.value.active && + editMode.value.annotationIndex === deleteData.annotationIndex + ) { + renderAnchorPoints(deleteData.annotationIndex); + } + } + } + }); + + // Watch for label reassignment in edit mode + watch(sharedState.reassignLabelTrigger, () => { + const reassignData = sharedState.reassignLabelData.value; + if ( + reassignData && + editMode.value.active && + editMode.value.annotationIndex !== null + ) { + const annotationIndex = editMode.value.annotationIndex; + const annotation = annotations.value[annotationIndex]; + + if (annotation) { + // Update the annotation's label + annotation.label = reassignData.labelValue; + + // Re-render the annotation with new color + renderAnnotations(); + + // Re-render anchor points if in edit mode + renderAnchorPoints(annotationIndex); + + // Update the answer + updateAnswer(); + } + } + }); + + // Watch for hole drawing mode signal from question component + watch(sharedState.holeDrawingMode, (holeMode, oldHoleMode) => { + // Enter hole drawing mode when activated from question component + if (holeMode.active && holeMode.parentIndex !== null) { + // Check if this is a new activation (not already in hole mode for this parent) + const isNewActivation = + !oldHoleMode?.active || + oldHoleMode.parentIndex !== holeMode.parentIndex; + if (isNewActivation) { + enterHoleDrawingMode(holeMode.parentIndex); + } + } + }); + + onMounted(() => { + initCanvas(); + + // Close context menu on click outside + document.addEventListener("click", contextMenu.hide); + }); + + onUnmounted(() => { + document.removeEventListener("click", contextMenu.hide); + }); + + stage?.destroy(); + + return { + canvasContainer, + imageLoaded, + hasError, + contextMenu: contextMenu.state, + editMode, + holeDrawingMode: computed(() => sharedState.holeDrawingMode.value), + deleteShape, + handleContextMenuDelete: contextMenu.handleDelete, + handleContextMenuEdit: contextMenu.handleEdit, + handleContextMenuAddHole: contextMenu.handleAddHole, + handleContextMenuDeleteHole: contextMenu.handleDeleteHole, + enterEditMode, + exitEditMode, + exitHoleDrawingMode, + editNextAnnotation, + editPreviousAnnotation, + }; +}; diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/useImageAnnotationSharedState.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/useImageAnnotationSharedState.ts new file mode 100644 index 0000000000..ef231e7552 --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/useImageAnnotationSharedState.ts @@ -0,0 +1,63 @@ +import { ref, type Ref } from "vue-demi"; +import { ImageAnnotationQuestionAnswer } from "~/v1/domain/entities/question/QuestionAnswer"; + +export type ImageAnnotationSharedState = { + editModeActive: Ref; + currentAnnotationIndex: Ref; + selectedTool: Ref; + // Counter-based signals - increment to trigger action + cancelPolygonTrigger: Ref; + reassignLabelTrigger: Ref; + reassignLabelData: Ref<{ labelValue: string } | null>; + deleteShapeTrigger: Ref; + deleteShapeData: Ref<{ index: number } | null>; + deleteHoleTrigger: Ref; + deleteHoleData: Ref<{ annotationIndex: number; holeIndex: number } | null>; + enterEditModeTrigger: Ref; + enterEditModeData: Ref<{ index: number } | null>; + exitEditModeTrigger: Ref; + selectLabelTrigger: Ref; + selectLabelData: Ref<{ labelValue: string } | null>; + holeDrawingMode: Ref<{ active: boolean; parentIndex: number | null }>; +}; + +const sharedStateMap = new WeakMap< + ImageAnnotationQuestionAnswer, + ImageAnnotationSharedState +>(); + +/** + * Get or create shared state for an ImageAnnotationQuestionAnswer instance. + * Uses WeakMap to avoid mutating the domain object and enable automatic garbage collection. + */ +export const useImageAnnotationSharedState = ( + answer: ImageAnnotationQuestionAnswer +): ImageAnnotationSharedState => { + let state = sharedStateMap.get(answer); + + if (!state) { + state = { + editModeActive: ref(false), + currentAnnotationIndex: ref(null), + selectedTool: ref("rectangle"), + // Counter-based signals + cancelPolygonTrigger: ref(0), + reassignLabelTrigger: ref(0), + reassignLabelData: ref(null), + deleteShapeTrigger: ref(0), + deleteShapeData: ref(null), + deleteHoleTrigger: ref(0), + deleteHoleData: ref(null), + enterEditModeTrigger: ref(0), + enterEditModeData: ref(null), + exitEditModeTrigger: ref(0), + selectLabelTrigger: ref(0), + selectLabelData: ref(null), + holeDrawingMode: ref({ active: false, parentIndex: null }), + }; + + sharedStateMap.set(answer, state); + } + + return state; +}; diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/utils/coordinates.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/utils/coordinates.ts new file mode 100644 index 0000000000..79d86c3cd3 --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/utils/coordinates.ts @@ -0,0 +1,72 @@ +import Konva from "konva"; + +/** + * Converts canvas coordinates to image coordinates. + * Takes into account the image's position, scale, and original dimensions. + * + * @param canvasPoints - Array of [x, y] coordinate pairs in canvas space + * @param imageNode - The Konva.Image node containing the image + * @returns Array of [x, y] coordinate pairs in image space + */ +export const getImageCoordinates = ( + canvasPoints: number[][], + imageNode: Konva.Image | null +): number[][] => { + if (!imageNode) return canvasPoints; + + const imageX = imageNode.x(); + const imageY = imageNode.y(); + const imageWidth = imageNode.width(); + const imageHeight = imageNode.height(); + const img = imageNode.image() as HTMLImageElement; + + return canvasPoints.map(([x, y]) => [ + ((x - imageX) / imageWidth) * (img?.width || 1), + ((y - imageY) / imageHeight) * (img?.height || 1), + ]); +}; + +/** + * Converts image coordinates to canvas coordinates. + * Takes into account the image's position, scale, and original dimensions. + * + * @param imagePoints - Array of [x, y] coordinate pairs in image space + * @param imageNode - The Konva.Image node containing the image + * @returns Array of [x, y] coordinate pairs in canvas space + */ +export const getCanvasCoordinates = ( + imagePoints: number[][], + imageNode: Konva.Image | null +): number[][] => { + if (!imageNode) return imagePoints; + + const imageX = imageNode.x(); + const imageY = imageNode.y(); + const imageWidth = imageNode.width(); + const imageHeight = imageNode.height(); + const img = imageNode.image() as HTMLImageElement; + const originalWidth = img?.width || 1; + const originalHeight = img?.height || 1; + + return imagePoints.map(([x, y]) => [ + (x / originalWidth) * imageWidth + imageX, + (y / originalHeight) * imageHeight + imageY, + ]); +}; + +/** + * Convert flat points array to coordinate pairs. + * Useful for converting Konva.Line points format to standard coordinate pairs. + * + * @param flatPoints - Flat array [x1, y1, x2, y2, ...] + * @returns Array of coordinate pairs [[x1, y1], [x2, y2], ...] + */ +export const flatPointsToCoordinatePairs = ( + flatPoints: number[] +): number[][] => { + const pairs: number[][] = []; + for (let i = 0; i < flatPoints.length; i += 2) { + pairs.push([flatPoints[i], flatPoints[i + 1]]); + } + return pairs; +}; diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/utils/geometry.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/utils/geometry.ts new file mode 100644 index 0000000000..a444810602 --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/utils/geometry.ts @@ -0,0 +1,156 @@ +import Konva from "konva"; + +/** + * Get the bounding box of a parent shape in image coordinates. + * + * @param parentPoints - Array of [x, y] coordinate pairs defining the parent shape + * @returns Object with minX, maxX, minY, maxY bounds + */ +export const getParentShapeBounds = (parentPoints: number[][]) => { + const xs = parentPoints.map((p) => p[0]); + const ys = parentPoints.map((p) => p[1]); + return { + minX: Math.min(...xs), + maxX: Math.max(...xs), + minY: Math.min(...ys), + maxY: Math.max(...ys), + }; +}; + +/** + * Clamp a point to stay within parent shape bounds. + * + * @param point - [x, y] coordinate pair to clamp + * @param parentBounds - Bounding box with minX, maxX, minY, maxY + * @returns Clamped [x, y] coordinate pair + */ +export const clampToParentBounds = ( + point: number[], + parentBounds: { minX: number; maxX: number; minY: number; maxY: number } +): number[] => { + return [ + Math.max(parentBounds.minX, Math.min(parentBounds.maxX, point[0])), + Math.max(parentBounds.minY, Math.min(parentBounds.maxY, point[1])), + ]; +}; + +/** + * Find the closest point on a polygon's perimeter to a given point. + * Uses perpendicular projection onto each edge segment. + * + * @param point - The point to find the closest polygon point to + * @param polygonPoints - Array of {x, y} points defining the polygon + * @returns The closest point on the polygon perimeter + */ +export const getClosestPointOnPolygon = ( + point: Konva.Vector2d, + polygonPoints: { x: number; y: number }[] +): Konva.Vector2d => { + let closestPoint: Konva.Vector2d = { x: point.x, y: point.y }; + let minDistance = Infinity; + + if (polygonPoints.length < 2) { + return closestPoint; + } + + for (let i = 0; i < polygonPoints.length; i++) { + const p1 = polygonPoints[i]; + const p2 = polygonPoints[(i + 1) % polygonPoints.length]; + + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + + if (dx === 0 && dy === 0) continue; + + const t = + ((point.x - p1.x) * dx + (point.y - p1.y) * dy) / (dx * dx + dy * dy); + const clampedT = Math.max(0, Math.min(1, t)); + + const candidate = { + x: p1.x + clampedT * dx, + y: p1.y + clampedT * dy, + }; + + const distance = Math.hypot(point.x - candidate.x, point.y - candidate.y); + + if (distance < minDistance) { + minDistance = distance; + closestPoint = candidate; + } + } + + return closestPoint; +}; + +/** + * Check if a point is within a parent shape's bounding box. + * + * @param point - The point to check + * @param canvasPoints - Array of [x, y] coordinate pairs defining the parent shape in canvas space + * @returns True if the point is within the bounding box + */ +export const isPointWithinParent = ( + point: { x: number; y: number }, + canvasPoints: number[][] +): boolean => { + // Get bounding box of parent + const xs = canvasPoints.map((p) => p[0]); + const ys = canvasPoints.map((p) => p[1]); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + + // Check if point is within bounding box + return ( + point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY + ); +}; + +/** + * Calculate the scale factor to fit an image within a container while maintaining aspect ratio. + * Will not scale up beyond 1:1 (original size). + * + * @param containerWidth - Width of the container + * @param containerHeight - Height of the container + * @param imageWidth - Original width of the image + * @param imageHeight - Original height of the image + * @returns Scale factor (between 0 and 1) + */ +export const calculateImageScale = ( + containerWidth: number, + containerHeight: number, + imageWidth: number, + imageHeight: number +): number => { + return Math.min( + containerWidth / imageWidth, + containerHeight / imageHeight, + 1 // Don't scale up beyond original size + ); +}; + +/** + * Calculate the position and dimensions to center an image within a container. + * + * @param containerWidth - Width of the container + * @param containerHeight - Height of the container + * @param imageWidth - Original width of the image + * @param imageHeight - Original height of the image + * @param scale - Scale factor to apply + * @returns Object with x, y, width, height for positioning the image + */ +export const centerImage = ( + containerWidth: number, + containerHeight: number, + imageWidth: number, + imageHeight: number, + scale: number +): { x: number; y: number; width: number; height: number } => { + return { + x: (containerWidth - imageWidth * scale) / 2, + y: (containerHeight - imageHeight * scale) / 2, + width: imageWidth * scale, + height: imageHeight * scale, + }; +}; diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/utils/holeCreationUtils.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/utils/holeCreationUtils.ts new file mode 100644 index 0000000000..634c1323d3 --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/utils/holeCreationUtils.ts @@ -0,0 +1,123 @@ +import Konva from "konva"; +import { ToolInteraction } from "../tools/IToolInteraction"; +import { flatPointsToCoordinatePairs } from "./coordinates"; +import { getParentShapeBounds, clampToParentBounds } from "./geometry"; +import { ImageAnnotationAnswer } from "~/v1/domain/entities/IAnswer"; + +/** + * Extract points from a tool interaction for hole creation + * Handles both polygon and rectangle tool types + * + * @param interaction - The active tool interaction + * @param getImageCoordinates - Function to convert canvas to image coordinates + * @param imageNode - The Konva image node + * @returns Image coordinates or null if extraction fails + */ +export const extractHolePointsFromInteraction = ( + interaction: ToolInteraction, + getImageCoordinates: ( + points: number[][], + imageNode: Konva.Image | null + ) => number[][], + imageNode: Konva.Image | null +): number[][] | null => { + if (interaction.toolType === "polygon") { + const polyInteraction = interaction as any; + if (polyInteraction.getPoints) { + const points = flatPointsToCoordinatePairs(polyInteraction.getPoints()); + return getImageCoordinates(points, imageNode); + } + } else if (interaction.toolType === "rectangle") { + const rectInteraction = interaction as any; + if (rectInteraction.getStartPos && rectInteraction.getCurrentPos) { + const startPos = rectInteraction.getStartPos(); + const currentPos = rectInteraction.getCurrentPos(); + return getImageCoordinates( + [ + [startPos.x, startPos.y], + [currentPos.x, currentPos.y], + ], + imageNode + ); + } + } + return null; +}; + +/** + * Create and add a hole to a parent annotation + * Clamps hole coordinates to parent bounds and updates the parent + * + * @param parent - The parent annotation to add the hole to + * @param imageCoords - The hole coordinates in image space + * @param shapeType - The shape type of the hole ("rectangle" | "polygon") + * @returns true if hole was added successfully + */ +export const addHoleToParent = ( + parent: ImageAnnotationAnswer, + imageCoords: number[][], + shapeType: "rectangle" | "polygon" +): boolean => { + try { + // Clamp hole coordinates to parent bounds + const parentBounds = getParentShapeBounds(parent.points); + const clampedCoords = imageCoords.map((point) => + clampToParentBounds(point, parentBounds) + ); + + // Initialize holes array if needed + if (!parent.holes) { + parent.holes = []; + } + + // Add hole to parent + parent.holes.push({ + points: clampedCoords, + shape_type: shapeType, + flags: {}, + }); + + return true; + } catch (error) { + console.error("Failed to add hole to parent:", error); + return false; + } +}; + +/** + * Complete hole creation workflow + * Extracts points, validates, and adds hole to parent annotation + * + * @param interaction - The active tool interaction + * @param parent - The parent annotation + * @param getImageCoordinates - Function to convert canvas to image coordinates + * @param imageNode - The Konva image node + * @returns true if hole was created successfully + */ +export const completeHoleCreation = ( + interaction: ToolInteraction, + parent: ImageAnnotationAnswer, + getImageCoordinates: ( + points: number[][], + imageNode: Konva.Image | null + ) => number[][], + imageNode: Konva.Image | null +): boolean => { + // Extract points from interaction + const imageCoords = extractHolePointsFromInteraction( + interaction, + getImageCoordinates, + imageNode + ); + + if (!imageCoords) { + return false; + } + + // Add hole to parent + return addHoleToParent( + parent, + imageCoords, + interaction.toolType as "rectangle" | "polygon" + ); +}; diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/utils/keyboardShortcuts.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/utils/keyboardShortcuts.ts new file mode 100644 index 0000000000..6feb97a126 --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/utils/keyboardShortcuts.ts @@ -0,0 +1,27 @@ +/** + * Keyboard shortcut definitions for image annotation. + */ +export const ANNOTATION_SHORTCUTS = { + CANCEL: ["Escape"], + COMPLETE: ["Enter"], + NEXT: ["ArrowRight", "n", "N"], + PREVIOUS: ["ArrowLeft", "p", "P"], + DELETE: ["Delete", "Backspace"], +} as const; + +/** + * Check if a keyboard event matches any of the specified keys. + * Handles case-insensitive matching for single character keys. + * + * @param e - The keyboard event + * @param keys - Array of key strings to match against + * @returns True if the event key matches any of the specified keys + */ +export const matchesKey = ( + e: KeyboardEvent, + keys: readonly string[] +): boolean => { + return keys.some((k) => + k.length === 1 ? e.key.toLowerCase() === k.toLowerCase() : e.key === k + ); +}; diff --git a/argilla-frontend/components/features/annotation/container/fields/image-annotation/utils/konvaShapes.ts b/argilla-frontend/components/features/annotation/container/fields/image-annotation/utils/konvaShapes.ts new file mode 100644 index 0000000000..2f085d7404 --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/fields/image-annotation/utils/konvaShapes.ts @@ -0,0 +1,96 @@ +import Konva from "konva"; + +/** + * Type definition for annotation nodes in the Konva layer. + */ +export type AnnotationNodes = { + element: Konva.Group | Konva.Shape | null; + parentShape: Konva.Shape | null; + holeShapes: Konva.Shape[]; +}; + +/** + * Get the Konva nodes associated with an annotation. + * + * @param layer - The Konva layer containing the annotations + * @param index - The index of the annotation + * @returns Object containing the element, parent shape, and hole shapes + */ +export const getAnnotationNodes = ( + layer: Konva.Layer | null, + index: number +): AnnotationNodes => { + const element = layer?.findOne(`#annotation-${index}`) as + | Konva.Group + | Konva.Shape + | null; + if (!element) return { element: null, parentShape: null, holeShapes: [] }; + + if (element instanceof Konva.Group) { + const parentShape = element.findOne( + ".annotation-shape" + ) as Konva.Shape | null; + const holeShapes = element.find(".annotation-hole") as Konva.Shape[]; + return { element, parentShape, holeShapes }; + } + return { element, parentShape: element as Konva.Shape, holeShapes: [] }; +}; + +/** + * Update a Konva shape's properties based on shape type and points. + * + * @param shape - The Konva shape to update + * @param shapeType - Type of shape ("rectangle" or "polygon") + * @param canvasPoints - Array of [x, y] coordinate pairs in canvas space + */ +export const updateKonvaShape = ( + shape: Konva.Shape, + shapeType: string, + canvasPoints: number[][] +) => { + if (shapeType === "rectangle" && canvasPoints.length === 2) { + const [p1, p2] = canvasPoints; + (shape as Konva.Rect).x(Math.min(p1[0], p2[0])); + (shape as Konva.Rect).y(Math.min(p1[1], p2[1])); + (shape as Konva.Rect).width(Math.abs(p2[0] - p1[0])); + (shape as Konva.Rect).height(Math.abs(p2[1] - p1[1])); + } else if (shapeType === "polygon") { + (shape as Konva.Line).points(canvasPoints.flat()); + } +}; + +/** + * Update rectangle corner points based on which corner is being dragged. + * Rectangles are stored as two diagonal corners [topLeft, bottomRight]. + * + * @param currentPoints - Current rectangle points [[x1, y1], [x2, y2]] + * @param pointIndex - Index of the corner being moved (0-3: TL, TR, BR, BL) + * @param imageCoords - New coordinates for the corner in image space + * @returns Updated rectangle points + */ +export const updateRectanglePoint = ( + currentPoints: number[][], + pointIndex: number, + imageCoords: number[] +): number[][] => { + if (pointIndex === 0) { + // Top-left corner + return [imageCoords, currentPoints[1]]; + } else if (pointIndex === 1) { + // Top-right corner + return [ + [currentPoints[0][0], imageCoords[1]], + [imageCoords[0], currentPoints[1][1]], + ]; + } else if (pointIndex === 2) { + // Bottom-right corner + return [currentPoints[0], imageCoords]; + } else if (pointIndex === 3) { + // Bottom-left corner + return [ + [imageCoords[0], currentPoints[0][1]], + [currentPoints[1][0], imageCoords[1]], + ]; + } + return currentPoints; +}; diff --git a/argilla-frontend/components/features/annotation/container/mode/FocusAnnotation.vue b/argilla-frontend/components/features/annotation/container/mode/FocusAnnotation.vue index 516150fe9f..a9b714f746 100644 --- a/argilla-frontend/components/features/annotation/container/mode/FocusAnnotation.vue +++ b/argilla-frontend/components/features/annotation/container/mode/FocusAnnotation.vue @@ -136,8 +136,13 @@ export default { }, methods: { async onSubmit() { - await this.submit(this.record); - this.$emit("on-submit-responses"); + try { + await this.submit(this.record); + this.$emit("on-submit-responses"); + } catch (error) { + // Error already handled in view model with toast notification + // Don't emit event to prevent view transition + } }, async onDiscard() { if (this.record.isDiscarded) return; diff --git a/argilla-frontend/components/features/annotation/container/mode/useFocusAnnotationViewModel.ts b/argilla-frontend/components/features/annotation/container/mode/useFocusAnnotationViewModel.ts index ba5fc77b19..c944716617 100644 --- a/argilla-frontend/components/features/annotation/container/mode/useFocusAnnotationViewModel.ts +++ b/argilla-frontend/components/features/annotation/container/mode/useFocusAnnotationViewModel.ts @@ -4,6 +4,7 @@ import { Record } from "~/v1/domain/entities/record/Record"; import { DiscardRecordUseCase } from "~/v1/domain/usecases/discard-record-use-case"; import { SubmitRecordUseCase } from "~/v1/domain/usecases/submit-record-use-case"; import { SaveDraftUseCase } from "~/v1/domain/usecases/save-draft-use-case"; +import { useNotifications } from "~/v1/infrastructure/services/useNotifications"; export const useFocusAnnotationViewModel = () => { const isDraftSaving = ref(false); @@ -12,13 +13,18 @@ export const useFocusAnnotationViewModel = () => { const discardUseCase = useResolve(DiscardRecordUseCase); const submitUseCase = useResolve(SubmitRecordUseCase); const saveDraftUseCase = useResolve(SaveDraftUseCase); + const { notify } = useNotifications(); const discard = async (record: Record) => { try { isDiscarding.value = true; await discardUseCase.execute(record); - } catch { + } catch (error) { + notify({ + message: "Failed to discard response. Please try again.", + type: "danger", + }); } finally { isDiscarding.value = false; } @@ -29,18 +35,103 @@ export const useFocusAnnotationViewModel = () => { isSubmitting.value = true; await submitUseCase.execute(record); - } catch { + } catch (error) { + // Reset image annotation states to idle mode on error + resetImageAnnotationStates(record); + + if (error.message === "VALIDATION_ERROR") { + notify({ + message: "Please complete all required fields correctly before submitting.", + type: "warning", + }); + } else if (error.response?.status === 422) { + // Backend validation error - data format is invalid + const detail = error.response?.data?.detail; + let errorMessage = "Invalid data format"; + + if (typeof detail === "string") { + errorMessage = detail; + } else if (detail && typeof detail === "object") { + // Handle structured error objects (e.g., validation errors from Argilla API) + if (detail.code && detail.params?.errors && Array.isArray(detail.params.errors)) { + // Collect all unique error messages + const uniqueMessages = new Set(); + detail.params.errors.forEach((err: any) => { + if (err.msg) { + uniqueMessages.add(err.msg); + } + }); + + if (uniqueMessages.size > 0) { + // Format as an HTML list + const messageList = Array.from(uniqueMessages) + .map((msg) => `• ${msg}`) + .join("
"); + errorMessage = `Validation errors:
${messageList}`; + } else { + // Fallback to error code + const errorCode = detail.code.split("::").pop() || detail.code; + errorMessage = errorCode; + } + } else { + errorMessage = detail.message || detail.msg || "Invalid data format"; + } + } + + notify({ + message: errorMessage, + type: "danger", + }); + } else { + notify({ + message: "Failed to submit response. Please check your data and try again.", + type: "danger", + }); + } + throw error; // Re-throw to prevent view transition } finally { isSubmitting.value = false; } }; + const resetImageAnnotationStates = (record: Record) => { + // Reset all image annotation questions to idle mode + record.questions.forEach((question) => { + if (question.isImageAnnotationType) { + const answer = question.answer as any; + if (answer && typeof answer.getAnnotationColor === "function") { + // This is an ImageAnnotationQuestionAnswer + // Import and use the shared state to reset + const { useImageAnnotationSharedState } = require("~/components/features/annotation/container/fields/image-annotation/useImageAnnotationSharedState"); + const sharedState = useImageAnnotationSharedState(answer); + + // Exit edit mode + if (sharedState.editModeActive.value) { + sharedState.exitEditModeTrigger.value++; + } + + // Cancel any ongoing polygon drawing + sharedState.cancelPolygonTrigger.value++; + + // Exit hole drawing mode + if (sharedState.holeDrawingMode.value.active) { + sharedState.holeDrawingMode.value = { active: false, parentIndex: null }; + } + } + } + }); + }; + const saveAsDraft = async (record: Record) => { try { isDraftSaving.value = true; await saveDraftUseCase.execute(record); - } catch { + } catch (error) { + notify({ + message: "Failed to save draft. Please try again.", + type: "danger", + }); } finally { isDraftSaving.value = false; } diff --git a/argilla-frontend/components/features/annotation/container/questions/form/Questions.component.vue b/argilla-frontend/components/features/annotation/container/questions/form/Questions.component.vue index b1ea862522..3177e09d5c 100644 --- a/argilla-frontend/components/features/annotation/container/questions/form/Questions.component.vue +++ b/argilla-frontend/components/features/annotation/container/questions/form/Questions.component.vue @@ -74,6 +74,14 @@ :enableSpanQuestionShortcutsGlobal="enableSpanQuestionShortcutsGlobal" @on-focus="updateQuestionAutofocus(index)" /> + + @@ -105,7 +113,7 @@ export default { }, computed: { questionsWithLoopMovement() { - return ["singleLabel", "multiLabel", "rating", "ranking", "span"] + return ["singleLabel", "multiLabel", "rating", "ranking", "span", "imageAnnotation"] .filter((componentType) => this.$refs[componentType]) .map((componentType) => this.$refs[componentType][0].$el); }, diff --git a/argilla-frontend/components/features/annotation/container/questions/form/image-annotation/ImageAnnotationComponent.vue b/argilla-frontend/components/features/annotation/container/questions/form/image-annotation/ImageAnnotationComponent.vue new file mode 100644 index 0000000000..cc0dd013a4 --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/questions/form/image-annotation/ImageAnnotationComponent.vue @@ -0,0 +1,530 @@ + + + + + diff --git a/argilla-frontend/components/features/annotation/container/questions/form/image-annotation/useImageAnnotationQuestionViewModel.ts b/argilla-frontend/components/features/annotation/container/questions/form/image-annotation/useImageAnnotationQuestionViewModel.ts new file mode 100644 index 0000000000..52675684e3 --- /dev/null +++ b/argilla-frontend/components/features/annotation/container/questions/form/image-annotation/useImageAnnotationQuestionViewModel.ts @@ -0,0 +1,224 @@ +import { ref, computed, watch } from "vue-demi"; +import { useImageAnnotationSharedState } from "../../../fields/image-annotation/useImageAnnotationSharedState"; +import { Question } from "~/v1/domain/entities/question/Question"; +import { ImageAnnotationQuestionAnswer } from "~/v1/domain/entities/question/QuestionAnswer"; + +type Tool = "rectangle" | "polygon"; + +export const useImageAnnotationQuestionViewModel = (props: { + question: Question; +}) => { + const { question } = props; + + const hoveredAnnotation = ref(null); + const expandedAnnotations = ref>({}); + + const answer = question.answer as ImageAnnotationQuestionAnswer; + + const sharedState = useImageAnnotationSharedState(answer); + const editModeActive = sharedState.editModeActive; + const selectedTool = computed({ + get: () => sharedState.selectedTool.value as Tool, + set: (value: Tool) => { + sharedState.selectedTool.value = value; + }, + }); + + watch(sharedState.editModeActive, (state) => { + if (state) { + // keep local state aligned when external edit mode starts + sharedState.currentAnnotationIndex.value ??= 0; + } + }); + + // Watch for label selection signal from field component + watch(sharedState.selectLabelTrigger, () => { + const selectData = sharedState.selectLabelData.value; + if (selectData && selectData.labelValue) { + // Find the option with this label value + const option = answer.options.find( + (opt) => opt.value === selectData.labelValue + ); + if (option) { + // Deselect all options + answer.options.forEach((opt) => (opt.isSelected = false)); + // Select the matching option + option.isSelected = true; + } + } + }); + + const annotations = computed(() => answer.values); + + // Memoize annotation colors to avoid repeated function calls during rendering + const annotationColorsCache = computed(() => { + const cache = new Map(); + annotations.value.forEach((annotation) => { + if (!cache.has(annotation.label)) { + cache.set( + annotation.label, + answer.getAnnotationColor(annotation.label) + ); + } + }); + return cache; + }); + + const getAnnotationColorMemoized = (labelValue: string) => { + return ( + annotationColorsCache.value.get(labelValue) || + answer.getAnnotationColor(labelValue) + ); + }; + + const selectTool = (tool: Tool) => { + // Signal to field component to cancel any ongoing polygon drawing + if (selectedTool.value === "polygon" && tool !== "polygon") { + sharedState.cancelPolygonTrigger.value++; + } + + selectedTool.value = tool; + }; + + // Called when a label is selected via EntityLabelSelection component + const onLabelSelected = () => { + // If in edit mode, reassign the current annotation to the new label + if (editModeActive.value) { + // Find the currently selected label + const selectedOption = answer.options.find((opt) => opt.isSelected); + + if (selectedOption) { + // Set data and increment trigger - watcher will react immediately + sharedState.reassignLabelData.value = { + labelValue: selectedOption.value, + }; + sharedState.reassignLabelTrigger.value++; + } else { + sharedState.reassignLabelData.value = null; + } + } + }; + + const onFocus = () => { + // Handle focus events if needed + }; + + const hoverAnnotation = (index: number) => { + hoveredAnnotation.value = index; + // Note: Canvas hover highlighting is handled by the field component's own hover handlers + // attached directly to Konva shapes. List hover only updates local UI state. + }; + + const unhoverAnnotation = () => { + hoveredAnnotation.value = null; + }; + + const onEditAnnotation = (index: number) => { + // If already in edit mode with this annotation, do nothing + if ( + editModeActive.value && + sharedState.currentAnnotationIndex.value === index + ) { + return; + } + + // Signal to field component to enter edit mode via sharedState + sharedState.enterEditModeData.value = { index }; + sharedState.editModeActive.value = true; + sharedState.currentAnnotationIndex.value = index; + sharedState.enterEditModeTrigger.value++; + }; + + const toggleEditMode = () => { + if (editModeActive.value) { + // Exit edit mode - signal to field component + // Don't change editModeActive here - let the field component handle it + sharedState.exitEditModeTrigger.value++; + } else if (annotations.value.length > 0) { + // Enter edit mode with first annotation + onEditAnnotation(0); + } + }; + + /** + * Delete a shape from the question list UI. + * This signals the Field component to handle the actual deletion. + */ + const deleteAnnotation = (index: number) => { + // Signal to field component to delete this shape via sharedState + // The field component will handle: + // 1. Exiting edit mode if needed + // 2. Removing the shape from the array + // 3. Re-rendering the canvas + sharedState.deleteShapeData.value = { index }; + sharedState.deleteShapeTrigger.value++; + }; + + const toggleExpanded = (index: number) => { + // Use Vue.set for Vue 2 reactivity + const currentState = expandedAnnotations.value[index] || false; + expandedAnnotations.value = { + ...expandedAnnotations.value, + [index]: !currentState, + }; + }; + + const isExpanded = (index: number) => { + return expandedAnnotations.value[index] || false; + }; + + const onAnnotationItemClick = (index: number) => { + const annotation = annotations.value[index]; + if (!annotation) return; + + if (annotation.holes && annotation.holes.length > 0) { + toggleExpanded(index); + } + }; + + const onAddHole = (index: number) => { + // Signal to field component to enter hole drawing mode + sharedState.holeDrawingMode.value = { + active: true, + parentIndex: index, + }; + }; + + const deleteHole = (annotationIndex: number, holeIndex: number) => { + const annotation = annotations.value[annotationIndex]; + if (annotation?.holes?.[holeIndex]) { + // Signal to field component to delete this hole via sharedState + // The field component will handle: + // 1. Removing the hole from the array + // 2. Re-rendering the canvas + // 3. Updating anchor points if in edit mode + sharedState.deleteHoleData.value = { + annotationIndex, + holeIndex, + }; + sharedState.deleteHoleTrigger.value++; + } + }; + + return { + selectedTool, + hoveredAnnotation, + expandedAnnotations, + annotations, + editModeActive, + selectTool, + onLabelSelected, + onFocus, + getAnnotationColor: getAnnotationColorMemoized, + hoverAnnotation, + unhoverAnnotation, + deleteAnnotation, + onEditAnnotation, + onAnnotationItemClick, + toggleEditMode, + toggleExpanded, + isExpanded, + onAddHole, + deleteHole, + }; +}; diff --git a/argilla-frontend/components/features/dataset-creation/configuration/DatasetConfigurationForm.vue b/argilla-frontend/components/features/dataset-creation/configuration/DatasetConfigurationForm.vue index cf2f2f16c3..8c3e385fca 100644 --- a/argilla-frontend/components/features/dataset-creation/configuration/DatasetConfigurationForm.vue +++ b/argilla-frontend/components/features/dataset-creation/configuration/DatasetConfigurationForm.vue @@ -69,6 +69,7 @@ 'rating', 'ranking', 'span', + 'image_annotation', ]" @add-question="addQuestion($event)" /> diff --git a/argilla-frontend/components/features/dataset-creation/configuration/questions/DatasetConfigurationImageAnnotation.vue b/argilla-frontend/components/features/dataset-creation/configuration/questions/DatasetConfigurationImageAnnotation.vue new file mode 100644 index 0000000000..188d18f7ea --- /dev/null +++ b/argilla-frontend/components/features/dataset-creation/configuration/questions/DatasetConfigurationImageAnnotation.vue @@ -0,0 +1,263 @@ + + + + + diff --git a/argilla-frontend/components/features/dataset-creation/configuration/questions/DatasetConfigurationQuestion.vue b/argilla-frontend/components/features/dataset-creation/configuration/questions/DatasetConfigurationQuestion.vue index f51f44b079..36e3745a9c 100644 --- a/argilla-frontend/components/features/dataset-creation/configuration/questions/DatasetConfigurationQuestion.vue +++ b/argilla-frontend/components/features/dataset-creation/configuration/questions/DatasetConfigurationQuestion.vue @@ -28,6 +28,12 @@ :textFields="selectedSubset.textFields" @is-focused="$emit('is-focused', $event)" /> + =6.9.0" @@ -342,17 +344,19 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -379,23 +383,25 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", - "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "license": "MIT", "dependencies": { - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.0" + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", - "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.26.3" + "@babel/types": "^7.28.4" }, "bin": { "parser": "bin/babel-parser.js" @@ -1758,24 +1764,23 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", - "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1799,12 +1804,13 @@ } }, "node_modules/@babel/types": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", - "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1816,51 +1822,6 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, - "node_modules/@bundled-es-modules/cookie": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", - "integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "cookie": "^0.7.2" - } - }, - "node_modules/@bundled-es-modules/cookie/node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@bundled-es-modules/statuses": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz", - "integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "statuses": "^2.0.1" - } - }, - "node_modules/@bundled-es-modules/tough-cookie": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/tough-cookie/-/tough-cookie-0.1.6.tgz", - "integrity": "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/tough-cookie": "^4.0.5", - "tough-cookie": "^4.1.4" - } - }, "node_modules/@codescouts/events": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@codescouts/events/-/events-1.0.10.tgz", @@ -4008,394 +3969,471 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "aix" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", "cpu": [ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -4536,44 +4574,70 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, + "node_modules/@inquirer/ansi": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.0.tgz", + "integrity": "sha512-JWaTfCxI1eTmJ1BIv86vUfjVatOdxwD0DAVKYevY8SazeUUZtW+tNbsdejVO1GYE0GXJW1N1ahmiC3TFd+7wZA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@inquirer/confirm": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.0.tgz", - "integrity": "sha512-osaBbIMEqVFjTX5exoqPXs6PilWQdjaLhGtMDXMXg/yxkHXNq43GlxGyTA35lK2HpzUgDN+Cjh/2AmqCN0QJpw==", + "version": "5.1.18", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.18.tgz", + "integrity": "sha512-MilmWOzHa3Ks11tzvuAmFoAd/wRuaP3SwlT1IZhyMke31FKLxPiuDWcGXhU+PKveNOpAc4axzAgrgxuIJJRmLw==", "dev": true, + "license": "MIT", "optional": true, "peer": true, "dependencies": { - "@inquirer/core": "^10.1.1", - "@inquirer/type": "^3.0.1" + "@inquirer/core": "^10.2.2", + "@inquirer/type": "^3.0.8" }, "engines": { "node": ">=18" }, "peerDependencies": { "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@inquirer/core": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.1.tgz", - "integrity": "sha512-rmZVXy9iZvO3ZStEe/ayuuwIJ23LSF13aPMlLMTQARX6lGUBDHGV8UB5i9MRrfy0+mZwt5/9bdy8llszSD3NQA==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.2.2.tgz", + "integrity": "sha512-yXq/4QUnk4sHMtmbd7irwiepjB8jXU0kkFRL4nr/aDBA2mDz13cMakEWdDwX3eSCTkk03kwcndD1zfRAIlELxA==", "dev": true, + "license": "MIT", "optional": true, "peer": true, "dependencies": { - "@inquirer/figures": "^1.0.8", - "@inquirer/type": "^3.0.1", - "ansi-escapes": "^4.3.2", + "@inquirer/ansi": "^1.0.0", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", - "strip-ansi": "^6.0.1", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.2" }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@inquirer/core/node_modules/cli-width": { @@ -4581,6 +4645,7 @@ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "dev": true, + "license": "ISC", "optional": true, "peer": true, "engines": { @@ -4592,17 +4657,65 @@ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", "dev": true, + "license": "ISC", "optional": true, "peer": true, "engines": { "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.2.tgz", + "integrity": "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.0", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/chardet": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz", + "integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@inquirer/figures": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.8.tgz", - "integrity": "sha512-tKd+jsmhq21AP1LhexC0pPwsCxEhGgAkg28byjJAd+xhmIs8LUX8JbUc3vBf3PhLxWiB5EvyBE5X7JSPAqMAqg==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.13.tgz", + "integrity": "sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==", "dev": true, + "license": "MIT", "optional": true, "peer": true, "engines": { @@ -4610,10 +4723,11 @@ } }, "node_modules/@inquirer/type": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.1.tgz", - "integrity": "sha512-+ksJMIy92sOAiAccGpcKZUc3bYO07cADnscIxHBknEm3uNts3movSmBofc1908BNy5edKscxYeAdaX1NXkHS6A==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.8.tgz", + "integrity": "sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==", "dev": true, + "license": "MIT", "optional": true, "peer": true, "engines": { @@ -4621,16 +4735,22 @@ }, "peerDependencies": { "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@intlify/core-base": { - "version": "9.14.2", - "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.2.tgz", - "integrity": "sha512-DZyQ4Hk22sC81MP4qiCDuU+LdaYW91A6lCjq8AWPvY3+mGMzhGDfOCzvyR6YBQxtlPjFqMoFk9ylnNYRAQwXtQ==", + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.5.tgz", + "integrity": "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==", "dev": true, + "license": "MIT", "dependencies": { - "@intlify/message-compiler": "9.14.2", - "@intlify/shared": "9.14.2" + "@intlify/message-compiler": "9.14.5", + "@intlify/shared": "9.14.5" }, "engines": { "node": ">= 16" @@ -4681,12 +4801,13 @@ } }, "node_modules/@intlify/message-compiler": { - "version": "9.14.2", - "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.2.tgz", - "integrity": "sha512-YsKKuV4Qv4wrLNsvgWbTf0E40uRv+Qiw1BeLQ0LAxifQuhiMe+hfTIzOMdWj/ZpnTDj4RSZtkXjJM7JDiiB5LQ==", + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.5.tgz", + "integrity": "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==", "dev": true, + "license": "MIT", "dependencies": { - "@intlify/shared": "9.14.2", + "@intlify/shared": "9.14.5", "source-map-js": "^1.0.2" }, "engines": { @@ -4697,9 +4818,10 @@ } }, "node_modules/@intlify/shared": { - "version": "9.14.2", - "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.2.tgz", - "integrity": "sha512-uRAHAxYPeF+G5DBIboKpPgC/Waecd4Jz8ihtkpJQD5ycb5PwXp0k/+hBGl5dAjwF7w+l74kz/PKA8r8OK//RUw==", + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.5.tgz", + "integrity": "sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==", + "license": "MIT", "engines": { "node": ">= 16" }, @@ -5618,9 +5740,10 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -7141,10 +7264,11 @@ } }, "node_modules/@nuxtjs/eslint-config-typescript/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -7411,6 +7535,7 @@ "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", "dev": true, + "license": "MIT", "optional": true, "peer": true }, @@ -7419,6 +7544,7 @@ "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", "dev": true, + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -7477,13 +7603,14 @@ "integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.28.1.tgz", - "integrity": "sha512-2aZp8AES04KI2dy3Ss6/MDjXbwBzj+i0GqKtWXgw2/Ma6E4jJvujryO6gJAghIRVz7Vwr9Gtl/8na3nDUKpraQ==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", + "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -7491,13 +7618,14 @@ "peer": true }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.28.1.tgz", - "integrity": "sha512-EbkK285O+1YMrg57xVA+Dp0tDBRB93/BZKph9XhMjezf6F4TpYjaUSuPt5J0fZXlSag0LmZAsTmdGGqPp4pQFA==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", + "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -7505,13 +7633,14 @@ "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.1.tgz", - "integrity": "sha512-prduvrMKU6NzMq6nxzQw445zXgaDBbMQvmKSJaxpaZ5R1QDM8w+eGxo6Y/jhT/cLoCvnZI42oEqf9KQNYz1fqQ==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", + "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -7519,13 +7648,14 @@ "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.28.1.tgz", - "integrity": "sha512-WsvbOunsUk0wccO/TV4o7IKgloJ942hVFK1CLatwv6TJspcCZb9umQkPdvB7FihmdxgaKR5JyxDjWpCOp4uZlQ==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", + "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -7533,13 +7663,14 @@ "peer": true }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.28.1.tgz", - "integrity": "sha512-HTDPdY1caUcU4qK23FeeGxCdJF64cKkqajU0iBnTVxS8F7H/7BewvYoG+va1KPSL63kQ1PGNyiwKOfReavzvNA==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", + "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -7547,13 +7678,14 @@ "peer": true }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.28.1.tgz", - "integrity": "sha512-m/uYasxkUevcFTeRSM9TeLyPe2QDuqtjkeoTpP9SW0XxUWfcYrGDMkO/m2tTw+4NMAF9P2fU3Mw4ahNvo7QmsQ==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", + "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -7561,13 +7693,14 @@ "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.28.1.tgz", - "integrity": "sha512-QAg11ZIt6mcmzpNE6JZBpKfJaKkqTm1A9+y9O+frdZJEuhQxiugM05gnCWiANHj4RmbgeVJpTdmKRmH/a+0QbA==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", + "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -7575,13 +7708,14 @@ "peer": true }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.28.1.tgz", - "integrity": "sha512-dRP9PEBfolq1dmMcFqbEPSd9VlRuVWEGSmbxVEfiq2cs2jlZAl0YNxFzAQS2OrQmsLBLAATDMb3Z6MFv5vOcXg==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", + "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -7589,13 +7723,14 @@ "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.28.1.tgz", - "integrity": "sha512-uGr8khxO+CKT4XU8ZUH1TTEUtlktK6Kgtv0+6bIFSeiSlnGJHG1tSFSjm41uQ9sAO/5ULx9mWOz70jYLyv1QkA==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", + "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -7603,41 +7738,44 @@ "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.28.1.tgz", - "integrity": "sha512-QF54q8MYGAqMLrX2t7tNpi01nvq5RI59UBNx+3+37zoKX5KViPo/gk2QLhsuqok05sSCRluj0D00LzCwBikb0A==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", + "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "peer": true }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.28.1.tgz", - "integrity": "sha512-vPul4uodvWvLhRco2w0GcyZcdyBfpfDRgNKU+p35AWEbJ/HPs1tOUrkSueVbBS0RQHAf/A+nNtDpvw95PeVKOA==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", + "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "peer": true }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.28.1.tgz", - "integrity": "sha512-pTnTdBuC2+pt1Rmm2SV7JWRqzhYpEILML4PKODqLz+C7Ou2apEV52h19CR7es+u04KlqplggmN9sqZlekg3R1A==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", + "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -7645,13 +7783,29 @@ "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.28.1.tgz", - "integrity": "sha512-vWXy1Nfg7TPBSuAncfInmAI/WZDd5vOklyLJDdIRKABcZWojNDY0NJwruY2AcnCLnRJKSaBgf/GiJfauu8cQZA==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", + "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", + "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -7659,13 +7813,14 @@ "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.28.1.tgz", - "integrity": "sha512-/yqC2Y53oZjb0yz8PVuGOQQNOTwxcizudunl/tFs1aLvObTclTwZ0JhXF2XcPT/zuaymemCDSuuUPXJJyqeDOg==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", + "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -7673,13 +7828,14 @@ "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.28.1.tgz", - "integrity": "sha512-fzgeABz7rrAlKYB0y2kSEiURrI0691CSL0+KXwKwhxvj92VULEDQLpBYLHpF49MSiPG4sq5CK3qHMnb9tlCjBw==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", + "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -7687,27 +7843,44 @@ "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.28.1.tgz", - "integrity": "sha512-xQTDVzSGiMlSshpJCtudbWyRfLaNiVPXt1WgdWTwWz9n0U12cI2ZVtWe/Jgwyv/6wjL7b66uu61Vg0POWVfz4g==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", + "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "peer": true }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", + "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.28.1.tgz", - "integrity": "sha512-wSXmDRVupJstFP7elGMgv+2HqXelQhuNf+IS4V+nUpNVi/GUiBgDmfwD0UGN3pcAnWsgKG3I52wMOBnk1VHr/A==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", + "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -7715,13 +7888,29 @@ "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.28.1.tgz", - "integrity": "sha512-ZkyTJ/9vkgrE/Rk9vhMXhf8l9D+eAhbAVbsGsXKy2ohmJaWg0LPQLnIxRdRp/bKyr8tXuPlXhIoGlEB5XpJnGA==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", + "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", + "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -7729,13 +7918,14 @@ "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.28.1.tgz", - "integrity": "sha512-ZvK2jBafvttJjoIdKm/Q/Bh7IJ1Ose9IBOwpOXcOvW3ikGTQGmKDgxTC6oCAzW6PynbkKP8+um1du81XJHZ0JA==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", + "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -7840,6 +8030,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/deep-eql": "*" + } + }, "node_modules/@types/compression": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.7.5.tgz", @@ -7873,11 +8074,20 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, + "license": "MIT", "peer": true }, "node_modules/@types/etag": { @@ -8026,11 +8236,12 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.10.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", - "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.1.tgz", + "integrity": "sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q==", + "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~7.14.0" } }, "node_modules/@types/normalize-package-data": { @@ -8130,10 +8341,11 @@ "dev": true }, "node_modules/@types/statuses": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.5.tgz", - "integrity": "sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "dev": true, + "license": "MIT", "optional": true, "peer": true }, @@ -8164,14 +8376,6 @@ "terser": "^4.6.13" } }, - "node_modules/@types/tough-cookie": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", - "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/@types/uglify-js": { "version": "3.17.5", "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.17.5.tgz", @@ -8488,110 +8692,121 @@ "dev": true }, "node_modules/@vitest/expect": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.8.tgz", - "integrity": "sha512-8ytZ/fFHq2g4PJVAtDX57mayemKgDR6X3Oa2Foro+EygiOJHUXhCqBAAKQYYajZpFoIfvBCF1j6R6IYRSIUFuw==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "@vitest/spy": "2.1.8", - "@vitest/utils": "2.1.8", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/pretty-format": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.8.tgz", - "integrity": "sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "tinyrainbow": "^1.2.0" + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.8.tgz", - "integrity": "sha512-17ub8vQstRnRlIU5k50bG+QOMLHRhYPAna5tw8tYbj+jzjcspnwnwtPtiOlkuKC4+ixDPTuLZiqiWWQ2PSXHVg==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "@vitest/utils": "2.1.8", - "pathe": "^1.1.2" + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, + "license": "MIT", "peer": true }, "node_modules/@vitest/snapshot": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.8.tgz", - "integrity": "sha512-20T7xRFbmnkfcmgVEz+z3AU/3b0cEzZOt/zmnvZEctg64/QZbSDJEVm9fLnnlSi74KibmRsO9/Qabi+t0vCRPg==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "@vitest/pretty-format": "2.1.8", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot/node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/@vitest/snapshot/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, + "license": "MIT", "peer": true }, "node_modules/@vitest/spy": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.8.tgz", - "integrity": "sha512-5swjf2q95gXeYPevtW0BLk6H8+bPlMb4Vw/9Em4hFxDcaOxS+e0LOX4yqNxoHzMR2akEB2xfpnWUzkZokmgWDg==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "tinyspy": "^3.0.2" + "tinyspy": "^4.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.8.tgz", - "integrity": "sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "@vitest/pretty-format": "2.1.8", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -9006,9 +9221,10 @@ } }, "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -9453,6 +9669,7 @@ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">=12" @@ -10050,8 +10267,33 @@ "readable-stream": "^3.4.0" } }, - "node_modules/bluebird": { - "version": "3.7.2", + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" }, @@ -10161,9 +10403,10 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -10344,10 +10587,9 @@ } }, "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "devOptional": true, + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "funding": [ { "type": "github", @@ -10362,9 +10604,12 @@ "url": "https://feross.org/support" } ], + "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "ieee754": "^1.2.1" } }, "node_modules/buffer-from": { @@ -10433,6 +10678,7 @@ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">=8" @@ -10675,10 +10921,11 @@ ] }, "node_modules/chai": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", - "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "assertion-error": "^2.0.1", @@ -10688,7 +10935,7 @@ "pathval": "^2.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/chalk": { @@ -10725,6 +10972,7 @@ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">= 16" @@ -11142,15 +11390,16 @@ } }, "node_modules/compression": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.5.tgz", - "integrity": "sha512-bQJ0YRck5ak3LgtnpKkiabX5pNF7tMUh1BSy2ZBOTh0Dim0BUu6aPPwByIns6/A5Prh8PufSPerMDUklpzes2Q==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", - "on-headers": "~1.0.2", + "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" }, @@ -12888,9 +13137,10 @@ "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==" }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { "ms": "^2.1.3" }, @@ -12936,6 +13186,7 @@ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">=6" @@ -13351,10 +13602,11 @@ } }, "node_modules/editorconfig/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -13596,10 +13848,11 @@ } }, "node_modules/es-module-lexer": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", - "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, + "license": "MIT", "peer": true }, "node_modules/es-object-atoms": { @@ -13614,13 +13867,15 @@ } }, "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.4", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -13652,42 +13907,46 @@ } }, "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", "dev": true, "hasInstallScript": true, + "license": "MIT", "peer": true, "bin": { "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" } }, "node_modules/escalade": { @@ -14743,10 +15002,11 @@ } }, "node_modules/expect-type": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz", - "integrity": "sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", "dev": true, + "license": "Apache-2.0", "peer": true, "engines": { "node": ">=12.0.0" @@ -15334,12 +15594,15 @@ } }, "node_modules/form-data": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", - "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -15832,9 +16095,10 @@ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -16910,16 +17174,17 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, "node_modules/inquirer": { - "version": "8.2.6", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", - "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", + "version": "8.2.7", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz", + "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", "dev": true, + "license": "MIT", "dependencies": { + "@inquirer/external-editor": "^1.0.0", "ansi-escapes": "^4.2.1", "chalk": "^4.1.1", "cli-cursor": "^3.1.0", "cli-width": "^3.0.0", - "external-editor": "^3.0.3", "figures": "^3.0.0", "lodash": "^4.17.21", "mute-stream": "0.0.8", @@ -18463,10 +18728,11 @@ } }, "node_modules/js-beautify/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -18596,14 +18862,17 @@ } }, "node_modules/jsdom/node_modules/form-data": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.2.tgz", - "integrity": "sha512-sJe+TQb2vIaIyO783qN6BlMYWMw3WBOHA1Ay2qxsnjuafEOQFJ2JakedOQirT6D5XPRxDvS7AHYyem9fTpb4LQ==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", + "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", "dev": true, + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -18723,13 +18992,14 @@ "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" }, "node_modules/katex": { - "version": "0.16.17", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.17.tgz", - "integrity": "sha512-OyzSrXBllz+Jdc9Auiw0kt21gbZ4hkz8Q5srVAb2U9INcYIfGKbxe+bvNvEz1bQ/NrDeRRho5eLCyk/L03maAw==", + "version": "0.16.23", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.23.tgz", + "integrity": "sha512-7VlC1hsEEolL9xNO05v9VjrvWZePkCVBJqj8ruICxYjZfHaHbaU53AlP+PODyFIXEnaEIEWi3wJy7FPZ95JAVg==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" ], + "license": "MIT", "peer": true, "dependencies": { "commander": "^8.3.0" @@ -18775,6 +19045,26 @@ "node": ">= 8" } }, + "node_modules/konva": { + "version": "9.3.6", + "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.6.tgz", + "integrity": "sha512-dqR8EbcM0hjuilZCBP6xauQ5V3kH3m9kBcsDkqPypQuRgsXbcXUrxqYxhNbdvKZpYNW8Amq94jAD/C0NY3qfBQ==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT" + }, "node_modules/last-call-webpack-plugin": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/last-call-webpack-plugin/-/last-call-webpack-plugin-3.0.0.tgz", @@ -18987,10 +19277,11 @@ } }, "node_modules/loupe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", - "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "dev": true, + "license": "MIT", "peer": true }, "node_modules/lower-case": { @@ -19698,15 +19989,16 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -20331,9 +20623,10 @@ } }, "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -21522,28 +21815,31 @@ "integrity": "sha512-6Y6s0vT112P3jD8dGfuS6r+lpa0qqNrLyHPOwvXMnyNTQaYiwgau2DP3aNDsR13xqtGj7rrPo+jFUATpU6/s+g==" }, "node_modules/pathval": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", - "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">= 14.16" } }, "node_modules/pbkdf2": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz", - "integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", + "integrity": "sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==", + "license": "MIT", "dependencies": { - "create-hash": "^1.1.2", - "create-hmac": "^1.1.4", - "ripemd160": "^2.0.1", - "safe-buffer": "^5.0.1", - "sha.js": "^2.4.8" + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "ripemd160": "^2.0.3", + "safe-buffer": "^5.2.1", + "sha.js": "^2.4.12", + "to-buffer": "^1.2.1" }, "engines": { - "node": ">=0.12" + "node": ">= 0.10" } }, "node_modules/picocolors": { @@ -21747,9 +22043,9 @@ } }, "node_modules/postcss": { - "version": "8.4.49", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", - "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", @@ -21764,8 +22060,9 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -24046,6 +24343,15 @@ "node": ">= 4" } }, + "node_modules/rettime": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", + "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -24091,22 +24397,84 @@ } }, "node_modules/ripemd160": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", - "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", + "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==", + "license": "MIT", "dependencies": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1" + "hash-base": "^3.1.2", + "inherits": "^2.0.4" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ripemd160/node_modules/hash-base": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz", + "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.8" } }, + "node_modules/ripemd160/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/ripemd160/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/ripemd160/node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/ripemd160/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/ripemd160/node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/rollup": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.28.1.tgz", - "integrity": "sha512-61fXYl/qNVinKmGSTHAZ6Yy8I3YIJC/r2m9feHo6SwVAVcLT5MPwOUFe7EuURA/4m0NR8lXG4BBXuo/IZEsjMg==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", + "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "@types/estree": "1.0.6" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -24116,25 +24484,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.28.1", - "@rollup/rollup-android-arm64": "4.28.1", - "@rollup/rollup-darwin-arm64": "4.28.1", - "@rollup/rollup-darwin-x64": "4.28.1", - "@rollup/rollup-freebsd-arm64": "4.28.1", - "@rollup/rollup-freebsd-x64": "4.28.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.28.1", - "@rollup/rollup-linux-arm-musleabihf": "4.28.1", - "@rollup/rollup-linux-arm64-gnu": "4.28.1", - "@rollup/rollup-linux-arm64-musl": "4.28.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.28.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.28.1", - "@rollup/rollup-linux-riscv64-gnu": "4.28.1", - "@rollup/rollup-linux-s390x-gnu": "4.28.1", - "@rollup/rollup-linux-x64-gnu": "4.28.1", - "@rollup/rollup-linux-x64-musl": "4.28.1", - "@rollup/rollup-win32-arm64-msvc": "4.28.1", - "@rollup/rollup-win32-ia32-msvc": "4.28.1", - "@rollup/rollup-win32-x64-msvc": "4.28.1", + "@rollup/rollup-android-arm-eabi": "4.52.4", + "@rollup/rollup-android-arm64": "4.52.4", + "@rollup/rollup-darwin-arm64": "4.52.4", + "@rollup/rollup-darwin-x64": "4.52.4", + "@rollup/rollup-freebsd-arm64": "4.52.4", + "@rollup/rollup-freebsd-x64": "4.52.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", + "@rollup/rollup-linux-arm-musleabihf": "4.52.4", + "@rollup/rollup-linux-arm64-gnu": "4.52.4", + "@rollup/rollup-linux-arm64-musl": "4.52.4", + "@rollup/rollup-linux-loong64-gnu": "4.52.4", + "@rollup/rollup-linux-ppc64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-musl": "4.52.4", + "@rollup/rollup-linux-s390x-gnu": "4.52.4", + "@rollup/rollup-linux-x64-gnu": "4.52.4", + "@rollup/rollup-linux-x64-musl": "4.52.4", + "@rollup/rollup-openharmony-arm64": "4.52.4", + "@rollup/rollup-win32-arm64-msvc": "4.52.4", + "@rollup/rollup-win32-ia32-msvc": "4.52.4", + "@rollup/rollup-win32-x64-gnu": "4.52.4", + "@rollup/rollup-win32-x64-msvc": "4.52.4", "fsevents": "~2.3.2" } }, @@ -24574,15 +24945,23 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, "node_modules/sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", "dependencies": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" }, "bin": { "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/shebang-command": { @@ -25083,9 +25462,10 @@ } }, "node_modules/std-env": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.0.tgz", - "integrity": "sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==" + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "license": "MIT" }, "node_modules/stream-browserify": { "version": "2.0.2", @@ -25376,6 +25756,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/style-resources-loader": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/style-resources-loader/-/style-resources-loader-1.5.0.tgz", @@ -25848,42 +26250,121 @@ "peer": true }, "node_modules/tinyexec": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.1.tgz", - "integrity": "sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "dev": true, + "license": "MIT", "peer": true }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinypool": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", - "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": "^18.0.0 || >=20.0.0" } }, "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">=14.0.0" } }, "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz", + "integrity": "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tldts-core": "^7.0.17" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.17.tgz", + "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -25906,6 +26387,20 @@ "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", "integrity": "sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==" }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/to-fast-properties": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz", @@ -26333,13 +26828,14 @@ } }, "node_modules/typed-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", - "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "is-typed-array": "^1.1.13" + "is-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -26495,9 +26991,10 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", + "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", + "license": "MIT" }, "node_modules/unfetch": { "version": "5.0.0", @@ -26640,6 +27137,18 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/upath": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", @@ -26866,23 +27375,24 @@ } }, "node_modules/vite-node": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.8.tgz", - "integrity": "sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -26893,26 +27403,62 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, + "license": "MIT", "optional": true, "peer": true }, + "node_modules/vite-node/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/vite-node/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, + "license": "MIT", "peer": true }, + "node_modules/vite-node/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vite-node/node_modules/terser": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.37.0.tgz", - "integrity": "sha512-B8wRRkmre4ERucLM/uXx4MOV5cbnOlVAqUst+1+iLKPI0dOgFO28f84ptoQt9HEI537PMzfYa/d+GEPKTRXmYA==", + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", + "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", "dev": true, + "license": "BSD-2-Clause", "optional": true, "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", + "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -26924,21 +27470,25 @@ } }, "node_modules/vite-node/node_modules/vite": { - "version": "5.4.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", - "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", + "version": "7.1.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", + "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -26947,19 +27497,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -26980,51 +27536,77 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, + "node_modules/vite-node/node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/vitest": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.8.tgz", - "integrity": "sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "@vitest/expect": "2.1.8", - "@vitest/mocker": "2.1.8", - "@vitest/pretty-format": "^2.1.8", - "@vitest/runner": "2.1.8", - "@vitest/snapshot": "2.1.8", - "@vitest/spy": "2.1.8", - "@vitest/utils": "2.1.8", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.8", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.8", - "@vitest/ui": "2.1.8", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, @@ -27032,6 +27614,9 @@ "@edge-runtime/vm": { "optional": true }, + "@types/debug": { + "optional": true + }, "@types/node": { "optional": true }, @@ -27063,10 +27648,11 @@ } }, "node_modules/vitest/node_modules/@mswjs/interceptors": { - "version": "0.37.3", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.3.tgz", - "integrity": "sha512-USvgCL/uOGFtVa6SVyRrC8kIAedzRohxIXN5LISlg5C5vLZCn7dgMFVSNhSF9cuBEFrm/O2spDWEZeMnw4ZXYg==", + "version": "0.39.7", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.7.tgz", + "integrity": "sha512-sURvQbbKsq5f8INV54YJgJEdk8oxBanqkTiXXd33rKmofFCwZLhLRszPduMZ9TA9b8/1CHc/IJmOlBHJk2Q5AQ==", "dev": true, + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -27086,34 +27672,28 @@ "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", "dev": true, - "optional": true, - "peer": true - }, - "node_modules/vitest/node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true, + "license": "MIT", "optional": true, "peer": true }, "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.8.tgz", - "integrity": "sha512-7guJ/47I6uqfttp33mgo6ga5Gr1VnL58rcqYKyShoRK9ebu8T5Rs6HN3s1NABiBeVTdWNrwUMcHH54uXZBN4zA==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "@vitest/spy": "2.1.8", + "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" + "magic-string": "^0.30.17" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0" + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -27129,6 +27709,7 @@ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, + "license": "ISC", "optional": true, "peer": true, "dependencies": { @@ -27145,63 +27726,99 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, + "license": "MIT", "optional": true, "peer": true }, + "node_modules/vitest/node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/vitest/node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "@types/estree": "^1.0.0" } }, + "node_modules/vitest/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/vitest/node_modules/headers-polyfill": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", "dev": true, + "license": "MIT", "optional": true, "peer": true }, "node_modules/vitest/node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/vitest/node_modules/msw": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.7.0.tgz", - "integrity": "sha512-BIodwZ19RWfCbYTxWTUfTXc+sg4OwjCAgxU1ZsgmggX/7S3LdUifsbUPJs61j0rWb19CZRGY5if77duhc0uXzw==", + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.11.5.tgz", + "integrity": "sha512-atFI4GjKSJComxcigz273honh8h4j5zzpk5kwG4tGm0TPcYne6bqmVrufeRll6auBeouIkXqZYXxVbWSWxM3RA==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "peer": true, "dependencies": { - "@bundled-es-modules/cookie": "^2.0.1", - "@bundled-es-modules/statuses": "^1.0.1", - "@bundled-es-modules/tough-cookie": "^0.1.6", "@inquirer/confirm": "^5.0.0", - "@mswjs/interceptors": "^0.37.0", + "@mswjs/interceptors": "^0.39.1", "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/until": "^2.1.0", - "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", + "cookie": "^1.0.2", "graphql": "^16.8.1", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", + "rettime": "^0.7.0", + "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", "type-fest": "^4.26.1", + "until-async": "^3.0.2", "yargs": "^17.7.2" }, "bin": { @@ -27223,30 +27840,59 @@ } }, "node_modules/vitest/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, + "license": "MIT", "peer": true }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vitest/node_modules/strict-event-emitter": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", "dev": true, + "license": "MIT", "optional": true, "peer": true }, "node_modules/vitest/node_modules/terser": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.37.0.tgz", - "integrity": "sha512-B8wRRkmre4ERucLM/uXx4MOV5cbnOlVAqUst+1+iLKPI0dOgFO28f84ptoQt9HEI537PMzfYa/d+GEPKTRXmYA==", + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", + "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", "dev": true, + "license": "BSD-2-Clause", "optional": true, "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", + "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -27257,11 +27903,27 @@ "node": ">=10" } }, + "node_modules/vitest/node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/vitest/node_modules/type-fest": { - "version": "4.30.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.30.2.tgz", - "integrity": "sha512-UJShLPYi1aWqCdq9HycOL/gwsuqda1OISdBO3t8RlXQC4QvtuIz4b5FCfe2dQIWEpmlRExKmcTBfP1r9bhY7ig==", + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "dev": true, + "license": "(MIT OR CC0-1.0)", "optional": true, "peer": true, "engines": { @@ -27272,21 +27934,25 @@ } }, "node_modules/vitest/node_modules/vite": { - "version": "5.4.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", - "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", + "version": "7.1.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", + "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -27295,19 +27961,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -27328,6 +28000,12 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, @@ -27336,6 +28014,7 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -27355,17 +28034,34 @@ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, + "license": "ISC", "optional": true, "peer": true, "engines": { "node": ">=10" } }, + "node_modules/vitest/node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/vitest/node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, + "license": "MIT", "optional": true, "peer": true, "dependencies": { @@ -27386,6 +28082,7 @@ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, + "license": "ISC", "optional": true, "peer": true, "engines": { @@ -29815,10 +30512,11 @@ } }, "node_modules/yoctocolors-cjs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", - "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", "dev": true, + "license": "MIT", "optional": true, "peer": true, "engines": { diff --git a/argilla-frontend/package.json b/argilla-frontend/package.json index a7f6ecafd5..b4a96d7389 100644 --- a/argilla-frontend/package.json +++ b/argilla-frontend/package.json @@ -32,6 +32,7 @@ "core-js": "3.37.1", "dompurify": "3.1.3", "frontmatter-markdown-loader": "3.7.0", + "konva": "9.3.6", "marked": "5.1.2", "marked-highlight": "2.1.1", "marked-katex-extension": "5.0.2", diff --git a/argilla-frontend/translation/de.js b/argilla-frontend/translation/de.js index a27b6f0566..0d35242ab5 100644 --- a/argilla-frontend/translation/de.js +++ b/argilla-frontend/translation/de.js @@ -357,8 +357,18 @@ export default { ranking: "Ranking", multi_label_selection: "Multi-Label", span: "Bereichsannotation", + image_annotation: "Bildannotation", "no mapping": "Keine Zuordnung", }, + questionId: { + text: "text", + rating: "bewertung", + label_selection: "label", + ranking: "ranking", + multi_label_selection: "multi-label", + span: "bereich", + image_annotation: "bild-annotation", + }, }, persistentStorage: { adminOrOwner: diff --git a/argilla-frontend/translation/en.js b/argilla-frontend/translation/en.js index 68cc64e55a..c8eaea14a8 100644 --- a/argilla-frontend/translation/en.js +++ b/argilla-frontend/translation/en.js @@ -63,6 +63,33 @@ export default { noDraftRecordsToReview: "You have no draft records to review", }, couldNotLoadImage: "Could not load image", + imageAnnotation: { + shapesCount: "Shapes: {count}", + editShapes: "Edit Shapes", + editMode: { + previous: "Previous", + next: "Next", + exitEditMode: "Exit Edit Mode", + shapeCounter: "Shape {current}/{total}", + }, + contextMenu: { + edit: "Edit Annotation", + delete: "Delete Annotation", + addHole: "Edit Holes", + deleteHole: "Delete Hole", + }, + buttons: { + addHole: "Add Hole", + edit: "Edit", + delete: "Delete", + deleteHole: "Delete Hole", + expand: "Expand", + collapse: "Collapse", + }, + tooltips: { + holesCount: "{count} hole | {count} holes", + }, + }, breadcrumbs: { home: "Home", datasetSettings: "settings", @@ -299,6 +326,10 @@ export default { span: { fieldRelated: "One text field is required", }, + imageAnnotation: { + shapeTypes: "Annotation Types", + allowMultiple: "Allow multiple annotations", + }, }, atLeastOneQuestion: "At least one question is required.", atLeastOneRequired: "At least one required question is needed.", @@ -358,6 +389,7 @@ export default { ranking: "Ranking", multi_label_selection: "Multi-label", span: "Span", + image_annotation: "Image annotation", "no mapping": "No mapping", }, questionId: { @@ -367,6 +399,7 @@ export default { ranking: "ranking", multi_label_selection: "multi-label", span: "span", + image_annotation: "image-annotation", }, }, persistentStorage: { diff --git a/argilla-frontend/translation/es.js b/argilla-frontend/translation/es.js index 8b12ff173b..3322d9f048 100644 --- a/argilla-frontend/translation/es.js +++ b/argilla-frontend/translation/es.js @@ -361,8 +361,18 @@ export default { ranking: "Ranking", multi_label_selection: "Selección de múltiples etiquetas", span: "Span", + image_annotation: "Anotación de imagen", "no mapping": "Sin mapeo", }, + questionId: { + text: "texto", + rating: "calificación", + label_selection: "etiqueta", + ranking: "ranking", + multi_label_selection: "multi-etiqueta", + span: "span", + image_annotation: "anotación-imagen", + }, }, persistentStorage: { adminOrOwner: diff --git a/argilla-frontend/translation/ja.js b/argilla-frontend/translation/ja.js index 21959c209a..bf78d22d02 100644 --- a/argilla-frontend/translation/ja.js +++ b/argilla-frontend/translation/ja.js @@ -363,6 +363,7 @@ export default { ranking: "ランキング", multi_label_selection: "マルチラベル", span: "範囲選択", + image_annotation: "画像アノテーション", "no mapping": "マッピングなし", }, questionId: { @@ -372,6 +373,7 @@ export default { ranking: "ランキング", multi_label_selection: "マルチラベル", span: "範囲選択", + image_annotation: "画像アノテーション", }, }, persistentStorage: { diff --git a/argilla-frontend/v1/domain/entities/IAnswer.ts b/argilla-frontend/v1/domain/entities/IAnswer.ts index 7df654831a..88b8eb2a33 100644 --- a/argilla-frontend/v1/domain/entities/IAnswer.ts +++ b/argilla-frontend/v1/domain/entities/IAnswer.ts @@ -6,12 +6,28 @@ export type SpanAnswer = { label: string; }; +export type ImageAnnotationHole = { + points: number[][]; + shape_type: string; + flags?: Record; +}; + +export type ImageAnnotationAnswer = { + label: string; + points: number[][]; + shape_type: string; + group_id?: number; + flags?: Record; + holes?: ImageAnnotationHole[]; +}; + export type AnswerCombinations = | string | string[] | number | RankingAnswer[] - | SpanAnswer[]; + | SpanAnswer[] + | ImageAnnotationAnswer[]; export interface Answer { value: AnswerCombinations; diff --git a/argilla-frontend/v1/domain/entities/hub/QuestionCreation.ts b/argilla-frontend/v1/domain/entities/hub/QuestionCreation.ts index 6f9e62c686..066b673746 100644 --- a/argilla-frontend/v1/domain/entities/hub/QuestionCreation.ts +++ b/argilla-frontend/v1/domain/entities/hub/QuestionCreation.ts @@ -8,6 +8,7 @@ import { SingleLabelQuestionAnswer, SpanQuestionAnswer, TextQuestionAnswer, + ImageAnnotationQuestionAnswer, } from "../question/QuestionAnswer"; import { QuestionSetting, @@ -23,6 +24,7 @@ export const availableQuestionTypes = [ QuestionType.from("text"), QuestionType.from("span"), QuestionType.from("rating"), + QuestionType.from("image_annotation"), ]; export class QuestionCreation { @@ -94,6 +96,10 @@ export class QuestionCreation { return this.type.isRankingType; } + get isImageAnnotationType(): boolean { + return this.type.isImageAnnotationType; + } + get answer(): QuestionAnswer { return this.createInitialAnswers(); } @@ -121,7 +127,18 @@ export class QuestionCreation { } } - if (this.isMultiLabelType || this.isSingleLabelType || this.isSpanType) { + if (this.isImageAnnotationType) { + if ( + !this.subset.imageFields.some( + (field) => field.name === this.settings.field + ) || + !this.settings.field + ) { + validation.field.push("datasetCreation.questions.imageAnnotation.fieldRelated"); + } + } + + if (this.isMultiLabelType || this.isSingleLabelType || this.isSpanType || this.isImageAnnotationType) { if (this.options.length < 2) { validation.options.push( "datasetCreation.questions.labelSelection.atLeastTwoOptions" @@ -152,7 +169,7 @@ export class QuestionCreation { } private initialize() { - if (this.isSpanType) { + if (this.isSpanType || this.isImageAnnotationType) { this.settings.options = this.settings.options.map((option) => { return { ...option, @@ -211,6 +228,14 @@ export class QuestionCreation { ); } + if (this.isImageAnnotationType) { + return new ImageAnnotationQuestionAnswer( + this.type, + this.name, + this.settings.options + ); + } + Guard.throw( `Question answer for type ${this.type} is not implemented yet.` ); diff --git a/argilla-frontend/v1/domain/entities/hub/Subset.ts b/argilla-frontend/v1/domain/entities/hub/Subset.ts index 2cf15099e1..dfe6c71079 100644 --- a/argilla-frontend/v1/domain/entities/hub/Subset.ts +++ b/argilla-frontend/v1/domain/entities/hub/Subset.ts @@ -89,6 +89,10 @@ export class Subset { return this.fields.filter((f) => f.settings.type.isTextType); } + get imageFields() { + return this.fields.filter((f) => f.settings.type.isImageType); + } + private setDefaultValues() { if (this.questions.length === 1) { this.questions[0].markAsRequired(); @@ -267,6 +271,20 @@ export class Subset { } } + if (type === "image_annotation") { + settings.options = [ + { text: "person", id: "1", value: "person" }, + { text: "car", id: "2", value: "car" }, + { text: "bicycle", id: "3", value: "bicycle" }, + ]; + settings.allow_multiple = true; + settings.shape_types = ["rectangle", "polygon"]; + + if (this.imageFields.length > 0) { + settings.field = this.imageFields[0].name; + } + } + if (type === "text") { settings.options = []; } diff --git a/argilla-frontend/v1/domain/entities/question/Question.ts b/argilla-frontend/v1/domain/entities/question/Question.ts index 53758321b3..26740b792f 100644 --- a/argilla-frontend/v1/domain/entities/question/Question.ts +++ b/argilla-frontend/v1/domain/entities/question/Question.ts @@ -9,6 +9,7 @@ import { MultiLabelQuestionAnswer, RankingQuestionAnswer, SpanQuestionAnswer, + ImageAnnotationQuestionAnswer, } from "./QuestionAnswer"; import { QuestionSetting } from "./QuestionSetting"; import { QuestionType } from "./QuestionType"; @@ -97,6 +98,10 @@ export class Question { return this.type.isRatingType; } + public get isImageAnnotationType(): boolean { + return this.type.isImageAnnotationType; + } + public get isAnswerModified(): boolean { return !this.answer.isEqual(this.original.answer); } @@ -196,6 +201,14 @@ export class Question { ); } + if (this.isImageAnnotationType) { + return new ImageAnnotationQuestionAnswer( + this.type, + this.name, + this.settings.options + ); + } + if (this.isRatingType) { return new RatingLabelQuestionAnswer( this.type, @@ -238,7 +251,7 @@ export class Question { this.settings.visible_options = this.settings.options.length; } - if (this.isSpanType) { + if (this.isSpanType || this.isImageAnnotationType) { this.settings.options = this.settings.options.map((option) => { return { ...option, diff --git a/argilla-frontend/v1/domain/entities/question/QuestionAnswer.ts b/argilla-frontend/v1/domain/entities/question/QuestionAnswer.ts index 0d7b4c99d8..b0912332cd 100644 --- a/argilla-frontend/v1/domain/entities/question/QuestionAnswer.ts +++ b/argilla-frontend/v1/domain/entities/question/QuestionAnswer.ts @@ -1,4 +1,4 @@ -import { Answer, RankingAnswer, SpanAnswer } from "../IAnswer"; +import { Answer, RankingAnswer, SpanAnswer, ImageAnnotationAnswer } from "../IAnswer"; import { QuestionType } from "./QuestionType"; export abstract class QuestionAnswer { @@ -307,3 +307,136 @@ export class RankingQuestionAnswer extends QuestionAnswer { return this.values; } } + +type ImageAnnotationValue = { + id: string; + text: string; + value: string; + color: string; + isSelected: boolean; +}; + +export class ImageAnnotationQuestionAnswer extends QuestionAnswer { + public readonly options: ImageAnnotationValue[] = []; + public values: ImageAnnotationAnswer[] = []; + + constructor( + public readonly type: QuestionType, + questionName: string, + options: Omit[] + ) { + super(type); + + const makeSafeForCSS = (str: string) => { + return str.replace(/[^a-z0-9]/g, (s) => { + const c = s.charCodeAt(0); + if (c === 32) return "-"; + if (c >= 65 && c <= 90) return `-${s.toLowerCase()}`; + return `-${c.toString(16)}`; + }); + }; + + this.options = options.map((e) => ({ + ...e, + id: makeSafeForCSS(`${questionName}-${e.value}`), + isSelected: false, + })); + this.clear(); + } + + protected fill(answer: Answer) { + this.values = answer.value as ImageAnnotationAnswer[]; + } + + clear() { + this.values = []; + } + + get isValid(): boolean { + return true; + } + + get hasValidValues(): boolean { + // Validate that all annotations have valid data + return this.values.every((annotation) => { + // Check that points array exists and has valid coordinates + if (!annotation.points || !Array.isArray(annotation.points)) { + return false; + } + + // Check that all points are valid numbers + const allPointsValid = annotation.points.every((point) => { + return ( + Array.isArray(point) && + point.length === 2 && + typeof point[0] === "number" && + typeof point[1] === "number" && + !isNaN(point[0]) && + !isNaN(point[1]) && + isFinite(point[0]) && + isFinite(point[1]) + ); + }); + + if (!allPointsValid) { + return false; + } + + // Check that shape_type is valid + if (!annotation.shape_type || typeof annotation.shape_type !== "string") { + return false; + } + + // Check that label exists + if (!annotation.label || typeof annotation.label !== "string") { + return false; + } + + // Validate holes if present + if (annotation.holes && Array.isArray(annotation.holes)) { + const allHolesValid = annotation.holes.every((hole) => { + return ( + hole.points && + Array.isArray(hole.points) && + hole.points.every( + (point) => + Array.isArray(point) && + point.length === 2 && + typeof point[0] === "number" && + typeof point[1] === "number" && + !isNaN(point[0]) && + !isNaN(point[1]) && + isFinite(point[0]) && + isFinite(point[1]) + ) + ); + }); + + if (!allHolesValid) { + return false; + } + } + + return true; + }); + } + + get valuesAnswered(): ImageAnnotationAnswer[] { + return this.values.map((value) => ({ + label: value.label, + points: value.points, + shape_type: value.shape_type, + group_id: value.group_id, + flags: value.flags, + holes: value.holes, + })); + } + + getAnnotationColor(labelValue: string): string { + const option = this.options.find((opt) => opt.value === labelValue); + return option?.color || "#cccccc"; + } + deleteAnnotation(index: number): void { + this.values.splice(index, 1); + } +} diff --git a/argilla-frontend/v1/domain/entities/question/QuestionSetting.ts b/argilla-frontend/v1/domain/entities/question/QuestionSetting.ts index 24235ee5f2..d46fddad99 100644 --- a/argilla-frontend/v1/domain/entities/question/QuestionSetting.ts +++ b/argilla-frontend/v1/domain/entities/question/QuestionSetting.ts @@ -9,6 +9,8 @@ export interface QuestionPrototype { allow_overlapping?: boolean; allow_character_annotation?: boolean; field?: string; + allow_multiple?: boolean; + shape_types?: string[]; } export class QuestionSetting { @@ -20,6 +22,8 @@ export class QuestionSetting { field: string; options: any; options_order: "natural" | "suggestion"; + allow_multiple: boolean; + shape_types: string[]; constructor(settings: QuestionPrototype) { this.type = QuestionType.from(settings.type); @@ -31,6 +35,8 @@ export class QuestionSetting { this.allow_overlapping = settings.allow_overlapping; this.allow_character_annotation = settings.allow_character_annotation; this.field = settings.field; + this.allow_multiple = settings.allow_multiple; + this.shape_types = settings.shape_types; } get suggestionFirst() { diff --git a/argilla-frontend/v1/domain/entities/question/QuestionType.ts b/argilla-frontend/v1/domain/entities/question/QuestionType.ts index bf8316e773..c740477a1f 100644 --- a/argilla-frontend/v1/domain/entities/question/QuestionType.ts +++ b/argilla-frontend/v1/domain/entities/question/QuestionType.ts @@ -5,6 +5,7 @@ const availableQuestionTypes = [ "text", "span", "rating", + "image_annotation", ]; export type QuestionTypes = @@ -13,7 +14,8 @@ export type QuestionTypes = | "ranking" | "text" | "span" - | "rating"; + | "rating" + | "image_annotation"; export class QuestionType extends String { private constructor(value: string) { @@ -55,4 +57,8 @@ export class QuestionType extends String { public get isRatingType(): boolean { return this.value === "rating"; } + + public get isImageAnnotationType(): boolean { + return this.value === "image_annotation"; + } } diff --git a/argilla-frontend/v1/domain/entities/record/Record.ts b/argilla-frontend/v1/domain/entities/record/Record.ts index ddfbeff231..b622972dd8 100644 --- a/argilla-frontend/v1/domain/entities/record/Record.ts +++ b/argilla-frontend/v1/domain/entities/record/Record.ts @@ -56,7 +56,45 @@ export class Record { get isModified() { const { original, ...rest } = this; - return !!original && !isEqual(original, rest); + if (!original) return false; + + // Clean up UI-only properties from questions before comparison + const cleanedRest = this.removeUIOnlyProperties(rest); + const cleanedOriginal = this.removeUIOnlyProperties(original); + + return !isEqual(cleanedOriginal, cleanedRest); + } + + private removeUIOnlyProperties(obj: any): any { + if (!obj) return obj; + + // Deep clone to avoid mutating the original + const cleaned = cloneDeep(obj); + + // Remove UI-only properties from image annotation questions + if (cleaned.questions) { + cleaned.questions.forEach((question: any) => { + // Only clean up image annotation questions + if (question.isImageAnnotationType && question.answer) { + // Remove UI-only properties that are added during rendering + delete question.answer.__imageAnnotationSync; + delete question.answer.selectedTool; + delete question.answer.cancelPolygon; + delete question.answer.enterEditMode; + delete question.answer.exitEditMode; + delete question.answer.editModeState; + + // Compare only the actual values array, not the entire answer object + // This avoids issues with valuesAnswered creating new objects each time + if (question.answer.values) { + const values = question.answer.values; + question.answer = { values }; + } + } + }); + } + + return cleaned; } discard(answer: RecordAnswer) { diff --git a/argilla-frontend/v1/domain/usecases/submit-record-use-case.ts b/argilla-frontend/v1/domain/usecases/submit-record-use-case.ts index 1f3cabeda7..ba36a192a6 100644 --- a/argilla-frontend/v1/domain/usecases/submit-record-use-case.ts +++ b/argilla-frontend/v1/domain/usecases/submit-record-use-case.ts @@ -10,6 +10,11 @@ export class SubmitRecordUseCase { ) {} async execute(record: Record) { + // Validate before submitting + if (!record.questionAreCompletedCorrectly()) { + throw new Error("VALIDATION_ERROR"); + } + const response = await this.recordRepository.submitRecordResponse(record); record.submit(response); diff --git a/argilla-frontend/v1/infrastructure/repositories/RecordRepository.ts b/argilla-frontend/v1/infrastructure/repositories/RecordRepository.ts index 356d0547ae..9811cb0f75 100644 --- a/argilla-frontend/v1/infrastructure/repositories/RecordRepository.ts +++ b/argilla-frontend/v1/infrastructure/repositories/RecordRepository.ts @@ -151,8 +151,13 @@ export class RecordRepository { return new RecordAnswer(data.id, status, data.values, data.updated_at); } catch (error) { + // Pass through the original error for better error handling + if (error.response?.status === 422) { + throw error; + } throw { response: RECORD_API_ERRORS.ERROR_UPDATING_RECORD_RESPONSE, + originalError: error, }; } } @@ -179,8 +184,13 @@ export class RecordRepository { data.updated_at ); } catch (error) { + // Pass through the original error for better error handling + if (error.response?.status === 422) { + throw error; + } throw { response: RECORD_API_ERRORS.ERROR_CREATING_RECORD_RESPONSE, + originalError: error, }; } } diff --git a/argilla-server/src/argilla_server/api/schemas/v1/questions.py b/argilla-server/src/argilla_server/api/schemas/v1/questions.py index 90755c947a..e189c87877 100644 --- a/argilla-server/src/argilla_server/api/schemas/v1/questions.py +++ b/argilla-server/src/argilla_server/api/schemas/v1/questions.py @@ -57,6 +57,9 @@ SPAN_OPTIONS_MIN_ITEMS = 1 SPAN_MIN_VISIBLE_OPTIONS = 3 +IMAGE_ANNOTATION_OPTIONS_MIN_ITEMS = 1 +IMAGE_ANNOTATION_MIN_VISIBLE_OPTIONS = 3 + class UniqueValuesCheckerMixin(BaseModel): @model_validator(mode="after") @@ -275,6 +278,71 @@ class SpanQuestionSettingsUpdate(UpdateSchema): allow_overlapping: Optional[bool] = None +# Image annotation question (labelme format) +class ImageAnnotationQuestionSettings(BaseModel): + type: Literal[QuestionType.image_annotation] + field: str + options: List[OptionSettings] + visible_options: Optional[int] = None + # Allow multiple shapes per annotation + allow_multiple: bool = Field(default=True, description="Allow multiple annotations") + # Supported shape types: rectangle (bbox), polygon, circle, line, point + shape_types: List[str] = Field(default=["rectangle", "polygon"], description="Allowed shape types") + + +class ImageAnnotationQuestionSettingsCreate(UniqueValuesCheckerMixin): + type: Literal[QuestionType.image_annotation] + field: FieldName + options: conlist( + item_type=OptionSettingsCreate, + min_length=IMAGE_ANNOTATION_OPTIONS_MIN_ITEMS, + max_length=settings.label_selection_options_max_items, + ) + visible_options: Optional[int] = Field(None, ge=IMAGE_ANNOTATION_MIN_VISIBLE_OPTIONS) + allow_multiple: bool = True + shape_types: List[str] = Field(default=["rectangle", "polygon"]) + + @model_validator(mode="after") + @classmethod + def check_visible_options_value( + cls, instance: "ImageAnnotationQuestionSettingsCreate" + ) -> "ImageAnnotationQuestionSettingsCreate": + visible_options = instance.visible_options + if visible_options is not None: + num_options = len(instance.options) + if visible_options > num_options: + raise ValueError( + "the value for 'visible_options' must be less or equal to the number of items in 'options'" + f" ({num_options})" + ) + return instance + + @model_validator(mode="after") + @classmethod + def check_shape_types( + cls, instance: "ImageAnnotationQuestionSettingsCreate" + ) -> "ImageAnnotationQuestionSettingsCreate": + allowed_shapes = {"rectangle", "polygon", "circle", "line", "point"} + for shape in instance.shape_types: + if shape not in allowed_shapes: + raise ValueError(f"Invalid shape type '{shape}'. Allowed types: {allowed_shapes}") + return instance + + +class ImageAnnotationQuestionSettingsUpdate(UpdateSchema): + type: Literal[QuestionType.image_annotation] + options: Optional[ + conlist( + item_type=OptionSettings, + min_length=IMAGE_ANNOTATION_OPTIONS_MIN_ITEMS, + max_length=settings.label_selection_options_max_items, + ) + ] = None + visible_options: Optional[int] = Field(None, ge=IMAGE_ANNOTATION_MIN_VISIBLE_OPTIONS) + allow_multiple: Optional[bool] = None + shape_types: Optional[List[str]] = None + + QuestionSettings = Annotated[ Union[ TextQuestionSettings, @@ -283,6 +351,7 @@ class SpanQuestionSettingsUpdate(UpdateSchema): MultiLabelSelectionQuestionSettings, RankingQuestionSettings, SpanQuestionSettings, + ImageAnnotationQuestionSettings, ], Field(..., discriminator="type"), ] @@ -319,6 +388,7 @@ class SpanQuestionSettingsUpdate(UpdateSchema): MultiLabelSelectionQuestionSettingsCreate, RankingQuestionSettingsCreate, SpanQuestionSettingsCreate, + ImageAnnotationQuestionSettingsCreate, ], Field(discriminator="type"), ] @@ -331,6 +401,7 @@ class SpanQuestionSettingsUpdate(UpdateSchema): MultiLabelSelectionQuestionSettingsUpdate, RankingQuestionSettingsUpdate, SpanQuestionSettingsUpdate, + ImageAnnotationQuestionSettingsUpdate, ], Field(..., discriminator="type"), ] diff --git a/argilla-server/src/argilla_server/api/schemas/v1/responses.py b/argilla-server/src/argilla_server/api/schemas/v1/responses.py index 5577f1a64c..25078a76b1 100644 --- a/argilla-server/src/argilla_server/api/schemas/v1/responses.py +++ b/argilla-server/src/argilla_server/api/schemas/v1/responses.py @@ -30,6 +30,9 @@ SPAN_QUESTION_RESPONSE_VALUE_ITEM_START_GREATER_THAN_OR_EQUAL = 0 SPAN_QUESTION_RESPONSE_VALUE_ITEM_END_GREATER_THAN_OR_EQUAL = 1 +IMAGE_ANNOTATION_QUESTION_RESPONSE_VALUE_MAX_ITEMS = 10_000 +IMAGE_ANNOTATION_MAX_HOLES_PER_SHAPE = 10 + class RankingQuestionResponseValueItem(BaseModel): value: str @@ -52,16 +55,141 @@ def check_start_and_end(cls, instance: "SpanQuestionResponseValueItem") -> "Span return instance +class ImageAnnotationHole(BaseModel): + """Hole/exclusion within an image annotation shape""" + + points: List[List[float]] = Field(..., description="Coordinates in [[x1,y1], [x2,y2], ...] format") + shape_type: str = Field(..., description="Shape type: rectangle, polygon, circle, line, point") + flags: Optional[Dict[str, Any]] = Field(default_factory=dict) + + @model_validator(mode="after") + @classmethod + def check_hole_points_format(cls, instance: "ImageAnnotationHole") -> "ImageAnnotationHole": + points = instance.points + shape_type = instance.shape_type + + if not points: + raise ValueError("hole points cannot be empty") + + # Validate each point has exactly 2 coordinates [x, y] + for point in points: + if len(point) != 2: + raise ValueError(f"Each hole point must have exactly 2 coordinates [x, y], got {len(point)}") + + # Shape-specific validations for holes + if shape_type == "rectangle" and len(points) != 2: + raise ValueError("rectangle hole requires exactly 2 points (top-left and bottom-right)") + elif shape_type == "point" and len(points) != 1: + raise ValueError("point hole requires exactly 1 point") + elif shape_type == "line" and len(points) < 2: + raise ValueError("line hole requires at least 2 points") + elif shape_type == "polygon" and len(points) < 3: + raise ValueError("polygon hole requires at least 3 points") + elif shape_type == "circle" and len(points) != 2: + raise ValueError("circle hole requires exactly 2 points (center and edge)") + + return instance + + +class ImageAnnotationQuestionResponseValueItem(BaseModel): + """Image annotation in labelme format""" + + label: str + points: List[List[float]] = Field(..., description="Coordinates in [[x1,y1], [x2,y2], ...] format") + shape_type: str = Field(..., description="Shape type: rectangle, polygon, circle, line, point") + group_id: Optional[int] = None + flags: Optional[Dict[str, Any]] = Field(default_factory=dict) + holes: Optional[List[ImageAnnotationHole]] = Field(default=None, description="Holes/exclusions within the shape") + + @model_validator(mode="after") + @classmethod + def check_points_format( + cls, instance: "ImageAnnotationQuestionResponseValueItem" + ) -> "ImageAnnotationQuestionResponseValueItem": + points = instance.points + shape_type = instance.shape_type + holes = instance.holes + + if not points: + raise ValueError("points cannot be empty") + + # Validate each point has exactly 2 coordinates [x, y] + for point in points: + if len(point) != 2: + raise ValueError(f"Each point must have exactly 2 coordinates [x, y], got {len(point)}") + + # Shape-specific validations + if shape_type == "rectangle" and len(points) != 2: + raise ValueError("rectangle shape requires exactly 2 points (top-left and bottom-right)") + elif shape_type == "point" and len(points) != 1: + raise ValueError("point shape requires exactly 1 point") + elif shape_type == "line" and len(points) < 2: + raise ValueError("line shape requires at least 2 points") + elif shape_type == "polygon" and len(points) < 3: + raise ValueError("polygon shape requires at least 3 points") + elif shape_type == "circle" and len(points) != 2: + raise ValueError("circle shape requires exactly 2 points (center and edge)") + + # Validate holes if present + if holes is not None: + if len(holes) > IMAGE_ANNOTATION_MAX_HOLES_PER_SHAPE: + raise ValueError( + f"Maximum {IMAGE_ANNOTATION_MAX_HOLES_PER_SHAPE} holes allowed per shape, got {len(holes)}" + ) + + # Validate geometric containment of holes within parent shape + cls._validate_holes_containment(points, shape_type, holes) + + return instance + + @staticmethod + def _validate_holes_containment( + parent_points: List[List[float]], parent_shape_type: str, holes: List[ImageAnnotationHole] + ) -> None: + """Validate that all holes are geometrically contained within the parent shape""" + # Get parent bounding box + parent_xs = [p[0] for p in parent_points] + parent_ys = [p[1] for p in parent_points] + parent_min_x, parent_max_x = min(parent_xs), max(parent_xs) + parent_min_y, parent_max_y = min(parent_ys), max(parent_ys) + + # Check each hole + for i, hole in enumerate(holes): + hole_xs = [p[0] for p in hole.points] + hole_ys = [p[1] for p in hole.points] + hole_min_x, hole_max_x = min(hole_xs), max(hole_xs) + hole_min_y, hole_max_y = min(hole_ys), max(hole_ys) + + # Bounding box containment check + if not ( + parent_min_x <= hole_min_x + and hole_max_x <= parent_max_x + and parent_min_y <= hole_min_y + and hole_max_y <= parent_max_y + ): + raise ValueError(f"Hole {i+1} is not fully contained within the parent shape bounds") + + # Additional check: all hole points must be within parent bounds + for point in hole.points: + if not (parent_min_x <= point[0] <= parent_max_x and parent_min_y <= point[1] <= parent_max_y): + raise ValueError(f"Hole {i+1} has points outside the parent shape bounds") + + RankingQuestionResponseValue = List[RankingQuestionResponseValueItem] SpanQuestionResponseValue = Annotated[ List[SpanQuestionResponseValueItem], Field(..., max_length=SPAN_QUESTION_RESPONSE_VALUE_MAX_ITEMS) ] +ImageAnnotationQuestionResponseValue = Annotated[ + List[ImageAnnotationQuestionResponseValueItem], + Field(..., max_length=IMAGE_ANNOTATION_QUESTION_RESPONSE_VALUE_MAX_ITEMS), +] MultiLabelSelectionQuestionResponseValue = List[str] RatingQuestionResponseValue = StrictInt TextAndLabelSelectionQuestionResponseValue = StrictStr ResponseValueTypes = Union[ SpanQuestionResponseValue, + ImageAnnotationQuestionResponseValue, RankingQuestionResponseValue, MultiLabelSelectionQuestionResponseValue, RatingQuestionResponseValue, diff --git a/argilla-server/src/argilla_server/enums.py b/argilla-server/src/argilla_server/enums.py index 03acc8b1b1..993992e6ca 100644 --- a/argilla-server/src/argilla_server/enums.py +++ b/argilla-server/src/argilla_server/enums.py @@ -77,6 +77,7 @@ class QuestionType(StrEnum): multi_label_selection = "multi_label_selection" ranking = "ranking" span = "span" + image_annotation = "image_annotation" class MetadataPropertyType(StrEnum): diff --git a/argilla-server/src/argilla_server/validators/response_values.py b/argilla-server/src/argilla_server/validators/response_values.py index 07a333959e..6a6275569d 100644 --- a/argilla-server/src/argilla_server/validators/response_values.py +++ b/argilla-server/src/argilla_server/validators/response_values.py @@ -15,6 +15,7 @@ from typing import Optional from argilla_server.api.schemas.v1.questions import ( + ImageAnnotationQuestionSettings, LabelSelectionQuestionSettings, MultiLabelSelectionQuestionSettings, QuestionSettings, @@ -23,6 +24,7 @@ SpanQuestionSettings, ) from argilla_server.api.schemas.v1.responses import ( + ImageAnnotationQuestionResponseValue, MultiLabelSelectionQuestionResponseValue, RankingQuestionResponseValue, RatingQuestionResponseValue, @@ -56,6 +58,8 @@ def validate( RankingQuestionResponseValueValidator(response_value).validate_for(question_settings, response_status) elif question_settings.type == QuestionType.span: SpanQuestionResponseValueValidator(response_value).validate_for(question_settings, record) + elif question_settings.type == QuestionType.image_annotation: + ImageAnnotationQuestionResponseValueValidator(response_value).validate_for(question_settings, record) else: raise UnprocessableEntityError(f"unknown question type f{question_settings.type!r}") @@ -281,3 +285,50 @@ def _validate_values_are_not_overlapped(self, span_question_settings: SpanQuesti raise UnprocessableEntityError( f"overlapping values found between spans at index idx={span_i} and idx={span_j}" ) + + +class ImageAnnotationQuestionResponseValueValidator: + def __init__(self, response_value: ImageAnnotationQuestionResponseValue): + self._response_value = response_value + + def validate_for(self, image_annotation_question_settings: ImageAnnotationQuestionSettings, record: Record) -> None: + self._validate_value_type() + self._validate_question_settings_field_is_present_at_record(image_annotation_question_settings, record) + self._validate_labels_are_available_at_question_settings(image_annotation_question_settings) + self._validate_shape_types_are_allowed(image_annotation_question_settings) + + def _validate_value_type(self) -> None: + if not isinstance(self._response_value, list): + raise UnprocessableEntityError( + f"image annotation question expects a list of values, found {type(self._response_value)}" + ) + + def _validate_question_settings_field_is_present_at_record( + self, image_annotation_question_settings: ImageAnnotationQuestionSettings, record: Record + ) -> None: + if image_annotation_question_settings.field not in record.fields: + raise UnprocessableEntityError( + f"image annotation question requires record to have field `{image_annotation_question_settings.field}`" + ) + + def _validate_labels_are_available_at_question_settings( + self, image_annotation_question_settings: ImageAnnotationQuestionSettings + ) -> None: + available_labels = [option.value for option in image_annotation_question_settings.options] + + for value_item in self._response_value: + if value_item.label not in available_labels: + raise UnprocessableEntityError( + f"undefined label '{value_item.label}' for image annotation question.\nValid labels are: {available_labels!r}" + ) + + def _validate_shape_types_are_allowed( + self, image_annotation_question_settings: ImageAnnotationQuestionSettings + ) -> None: + allowed_shapes = image_annotation_question_settings.shape_types + + for value_item in self._response_value: + if value_item.shape_type not in allowed_shapes: + raise UnprocessableEntityError( + f"shape type '{value_item.shape_type}' is not allowed for this question.\nAllowed shapes are: {allowed_shapes!r}" + ) diff --git a/argilla/src/argilla/_models/_settings/_questions.py b/argilla/src/argilla/_models/_settings/_questions.py index 558b351f23..a001472fcd 100644 --- a/argilla/src/argilla/_models/_settings/_questions.py +++ b/argilla/src/argilla/_models/_settings/_questions.py @@ -124,6 +124,35 @@ class TextQuestionSettings(BaseModel): use_markdown: bool = False +class ImageAnnotationQuestionSettings(BaseModel): + type: Literal["image_annotation"] = "image_annotation" + + _MIN_VISIBLE_OPTIONS: ClassVar[int] = 3 + + field: Optional[str] = None + options: List[Dict[str, Optional[str]]] = Field(default_factory=list, validate_default=True) + visible_options: Optional[int] = Field(None, validate_default=True, ge=_MIN_VISIBLE_OPTIONS) + allow_multiple: bool = True + shape_types: List[str] = Field(default=["rectangle", "polygon"]) + + @field_validator("options", mode="before") + @classmethod + def __values_are_unique(cls, options: List[Dict[str, Optional[str]]]) -> List[Dict[str, Optional[str]]]: + """Ensure that values are unique""" + + unique_values = list(set([option["value"] for option in options])) + if len(unique_values) != len(options): + raise ValueError("All values must be unique") + + return options + + @model_validator(mode="after") + def __validate_visible_options(self) -> "Self": + if self.visible_options is None and self.options and len(self.options) >= self._MIN_VISIBLE_OPTIONS: + self.visible_options = len(self.options) + return self + + QuestionSettings = Annotated[ Union[ LabelQuestionSettings, @@ -132,6 +161,7 @@ class TextQuestionSettings(BaseModel): RatingQuestionSettings, SpanQuestionSettings, TextQuestionSettings, + ImageAnnotationQuestionSettings, ], Field(..., discriminator="type"), ] diff --git a/argilla/src/argilla/settings/_question.py b/argilla/src/argilla/settings/_question.py index 63fb19f208..166ef70927 100644 --- a/argilla/src/argilla/settings/_question.py +++ b/argilla/src/argilla/settings/_question.py @@ -25,6 +25,7 @@ RatingQuestionSettings, RankingQuestionSettings, SpanQuestionSettings, + ImageAnnotationQuestionSettings, ) from argilla.settings._common import SettingsPropertyBase @@ -43,6 +44,7 @@ "TextQuestion", "RatingQuestion", "SpanQuestion", + "ImageAnnotationQuestion", "QuestionType", ] @@ -469,6 +471,124 @@ def from_model(cls, model: QuestionModel) -> "Self": return instance +class ImageAnnotationQuestion(QuestionBase): + def __init__( + self, + name: str, + field: str, + labels: Union[List[str], Dict[str, str]], + title: Optional[str] = None, + description: Optional[str] = None, + required: bool = True, + allow_multiple: bool = True, + shape_types: Optional[List[str]] = None, + visible_labels: Optional[int] = None, + client: Optional[Argilla] = None, + ) -> None: + """Define a new image annotation question for `Settings` of a `Dataset`. \ + An image annotation question allows users to draw bounding boxes and polygons \ + on images and assign labels to them, using the labelme format. + + Parameters: + name (str): The name of the question to be used as a reference. + field (str): The name of the image field where the annotation question will be applied. + labels (Union[List[str], Dict[str, str]]): The list of available labels for the question, or a \ + dictionary of key-value pairs where the key is the label and the value is the label name displayed in the UI. + allow_multiple (bool): Whether multiple annotations are allowed. Default is True. + shape_types (Optional[List[str]]): The types of shapes allowed for annotation. \ + Options: "rectangle", "polygon", "circle", "line", "point". Default is ["rectangle", "polygon"]. + visible_labels (Optional[int]): The number of visible labels for the question to be shown in the UI. \ + Setting it to None shows all options. + title (Optional[str]): The title of the question to be shown in the UI. + description (Optional[str]): The description of the question to be shown in the UI. + required (bool): If the question is required for a record to be valid. At least one question must be required. + + Example: + ```python + import argilla as rg + + settings = rg.Settings( + fields=[ + rg.ImageField(name="image"), + ], + questions=[ + rg.ImageAnnotationQuestion( + name="objects", + field="image", + labels=["person", "car", "tree"], + title="Annotate objects in the image", + ) + ], + ) + ``` + """ + super().__init__( + name=name, + title=title, + required=required, + description=description, + settings=ImageAnnotationQuestionSettings( + field=field, + allow_multiple=allow_multiple, + shape_types=shape_types or ["rectangle", "polygon"], + visible_options=visible_labels, + options=self._render_values_as_options(labels), + ), + _client=client, + ) + + @property + def field(self): + return self._model.settings.field + + @field.setter + def field(self, field: str): + self._model.settings.field = field + + @property + def allow_multiple(self): + return self._model.settings.allow_multiple + + @allow_multiple.setter + def allow_multiple(self, allow_multiple: bool): + self._model.settings.allow_multiple = allow_multiple + + @property + def shape_types(self): + return self._model.settings.shape_types + + @shape_types.setter + def shape_types(self, shape_types: List[str]): + self._model.settings.shape_types = shape_types + + @property + def visible_labels(self) -> Optional[int]: + return self._model.settings.visible_options + + @visible_labels.setter + def visible_labels(self, visible_labels: Optional[int]) -> None: + self._model.settings.visible_options = visible_labels + + @property + def labels(self) -> List[str]: + return self._render_options_as_labels(self._model.settings.options) + + @labels.setter + def labels(self, labels: List[str]) -> None: + self._model.settings.options = self._render_values_as_options(labels) + + @classmethod + def from_model(cls, model: QuestionModel) -> "Self": + instance = cls( + name=model.name, + field=model.settings.field, + labels=cls._render_options_as_labels(model.settings.options), + ) # noqa + instance._model = model + + return instance + + QuestionType = Union[ LabelQuestion, MultiLabelQuestion, @@ -476,6 +596,7 @@ def from_model(cls, model: QuestionModel) -> "Self": TextQuestion, RatingQuestion, SpanQuestion, + ImageAnnotationQuestion, ] @@ -494,6 +615,8 @@ def question_from_model(model: QuestionModel) -> QuestionType: return RatingQuestion.from_model(model) elif question_type == "span": return SpanQuestion.from_model(model) + elif question_type == "image_annotation": + return ImageAnnotationQuestion.from_model(model) else: raise ValueError(f"Unsupported question model type: {question_type}")