@annotorious/annotorious
Advanced tools
| import { RectangleGeometry } from '../../../model'; | ||
| /** | ||
| * Rotates a point around a center by the given angle (in rad). | ||
| */ | ||
| export declare const rotatePoint: (point: [number, number], center: [number, number], angle: number) => [number, number]; | ||
| /** | ||
| * Gets the four corner points of a rotated rectangle in world space. | ||
| */ | ||
| export declare const getRotatedCorners: (x: number, y: number, w: number, h: number, rot?: number) => [[number, number], [number, number], [number, number], [number, number]]; | ||
| /** | ||
| * Calculates the position of the rotation handle. | ||
| */ | ||
| export declare const getRotationHandlePosition: (geom: RectangleGeometry, offset: number) => [number, number]; | ||
| /** | ||
| * Transforms a movement delta from world coords to the rectangle's | ||
| * local (non-rotated) coordinate system. | ||
| */ | ||
| export declare const transformDeltaToLocalCoords: (deltaX: number, deltaY: number, rot: number) => [number, number]; | ||
| /** | ||
| * Calculates the rotation angle between a point and the origin, relative to a center. | ||
| */ | ||
| export declare const angleFromPoints: (point1: [number, number], point2: [number, number], center: [number, number]) => number; | ||
| /** | ||
| * Snaps an angle to the nearest 45-degree increment | ||
| */ | ||
| export declare const snapAngle: (angle: number, inc?: number) => number; |
| import type { Bounds, RectangleGeometry } from '../../../model'; | ||
| /** | ||
| * Rotates a point around a center by the given angle (in rad). | ||
| */ | ||
| export const rotatePoint = ( | ||
| point: [number, number], | ||
| center: [number, number], | ||
| angle: number | ||
| ): [number, number] => { | ||
| const [px, py] = point; | ||
| const [cx, cy] = center; | ||
| const cos = Math.cos(angle); | ||
| const sin = Math.sin(angle); | ||
| const dx = px - cx; | ||
| const dy = py - cy; | ||
| return [ | ||
| cx + dx * cos - dy * sin, | ||
| cy + dx * sin + dy * cos | ||
| ]; | ||
| } | ||
| /** | ||
| * Gets the four corner points of a rotated rectangle in world space. | ||
| */ | ||
| export const getRotatedCorners = ( | ||
| x: number, | ||
| y: number, | ||
| w: number, | ||
| h: number, | ||
| rot: number = 0 | ||
| ): [[number, number], [number, number], [number, number], [number, number]] => { | ||
| const corners: [number, number][] = [ | ||
| [x, y], | ||
| [x + w, y], | ||
| [x + w, y + h], | ||
| [x, y + h] | ||
| ]; | ||
| const center: [number, number] = [x + w / 2, y + h / 2]; | ||
| return corners.map(corner => | ||
| rotatePoint(corner, center, rot)) as [[number, number], [number, number], [number, number], [number, number]]; | ||
| } | ||
| /** | ||
| * Calculates the position of the rotation handle. | ||
| */ | ||
| export const getRotationHandlePosition = ( | ||
| geom: RectangleGeometry, offset: number | ||
| ): [number, number] => { | ||
| const { x , y, w, h, rot = 0 } = geom; | ||
| const center: [number, number] = [x + w / 2, y + h / 2]; | ||
| let topCenter: [number, number] = [x + w / 2, y - offset]; | ||
| return rotatePoint(topCenter, center, rot); | ||
| } | ||
| /** | ||
| * Transforms a movement delta from world coords to the rectangle's | ||
| * local (non-rotated) coordinate system. | ||
| */ | ||
| export const transformDeltaToLocalCoords = ( | ||
| deltaX: number, | ||
| deltaY: number, | ||
| rot: number | ||
| ): [number, number] => { | ||
| const cos = Math.cos(rot); | ||
| const sin = Math.sin(rot); | ||
| return [ | ||
| deltaX * cos + deltaY * sin, | ||
| -deltaX * sin + deltaY * cos | ||
| ]; | ||
| } | ||
| /** | ||
| * Calculates the rotation angle between a point and the origin, relative to a center. | ||
| */ | ||
| export const angleFromPoints = ( | ||
| point1: [number, number], | ||
| point2: [number, number], | ||
| center: [number, number] | ||
| ): number => { | ||
| const dx1 = point1[0] - center[0]; | ||
| const dy1 = point1[1] - center[1]; | ||
| const angle1 = Math.atan2(dy1, dx1); | ||
| const dx2 = point2[0] - center[0]; | ||
| const dy2 = point2[1] - center[1]; | ||
| const angle2 = Math.atan2(dy2, dx2); | ||
| return angle2 - angle1; | ||
| } | ||
| /** | ||
| * Snaps an angle to the nearest 45-degree increment | ||
| */ | ||
| export const snapAngle = (angle: number, inc = 10): number => { | ||
| const step = (inc * Math.PI) / 180; | ||
| return Math.round(angle / step) * step; | ||
| } |
@@ -1,1 +0,1 @@ | ||
| circle.a9s-handle-buffer.svelte-qtyc7s:focus{outline:none}circle.a9s-handle-buffer.svelte-qtyc7s:focus-visible{stroke:#fffc;stroke-width:3px}.a9s-polygon-midpoint.svelte-12ykj76{cursor:crosshair}.a9s-polygon-midpoint-buffer.svelte-12ykj76{fill:transparent}.a9s-polygon-midpoint-outer.svelte-12ykj76{display:none;fill:transparent;pointer-events:none;stroke:#00000059;stroke-width:1.5px;vector-effect:non-scaling-stroke}.a9s-polygon-midpoint-inner.svelte-12ykj76{fill:#00000040;pointer-events:none;stroke:#fff;stroke-width:1px;vector-effect:non-scaling-stroke}mask.a9s-polygon-editor-mask.svelte-1h2slbm>rect.svelte-1h2slbm{fill:#fff}mask.a9s-polygon-editor-mask.svelte-1h2slbm>circle.svelte-1h2slbm,mask.a9s-polygon-editor-mask.svelte-1h2slbm>polygon.svelte-1h2slbm{fill:#000}mask.a9s-rectangle-editor-mask.svelte-1njczvj>rect.rect-mask-bg.svelte-1njczvj{fill:#fff}mask.a9s-rectangle-editor-mask.svelte-1njczvj>rect.rect-mask-fg.svelte-1njczvj{fill:#000}mask.a9s-multipolygon-editor-mask.svelte-1vxo6dc>rect.svelte-1vxo6dc{fill:#fff}mask.a9s-multipolygon-editor-mask.svelte-1vxo6dc>circle.svelte-1vxo6dc,mask.a9s-multipolygon-editor-mask.svelte-1vxo6dc>path.svelte-1vxo6dc{fill:#000}mask.a9s-rubberband-rectangle-mask.svelte-1a76qe7>rect.rect-mask-bg.svelte-1a76qe7{fill:#fff}mask.a9s-rubberband-rectangle-mask.svelte-1a76qe7>rect.rect-mask-fg.svelte-1a76qe7{fill:#000}mask.a9s-rubberband-polygon-mask.svelte-18wrg3t>rect.svelte-18wrg3t{fill:#fff}mask.a9s-rubberband-polygon-mask.svelte-18wrg3t>polygon.svelte-18wrg3t{fill:#000}circle.a9s-handle.svelte-18wrg3t.svelte-18wrg3t{fill:#fff;pointer-events:none;stroke:#00000059;stroke-width:1px;vector-effect:non-scaling-stroke}path.open.svelte-1w0132l{fill:transparent!important}.a9s-annotationlayer{box-sizing:border-box;height:100%;left:0;outline:none;position:absolute;top:0;touch-action:none;width:100%;-webkit-tap-highlight-color:transparent;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;user-select:none}.a9s-annotationlayer.hover{cursor:pointer}.a9s-annotationlayer.hidden{display:none}.a9s-annotationlayer ellipse,.a9s-annotationlayer line,.a9s-annotationlayer path,.a9s-annotationlayer polygon,.a9s-annotationlayer rect{fill:transparent;shape-rendering:geometricPrecision;vector-effect:non-scaling-stroke;-webkit-tap-highlight-color:transparent}.a9s-touch-halo{fill:transparent;pointer-events:none;stroke-width:0;transition:fill .15s}.a9s-touch-halo.touched{fill:#fff6}.a9s-handle-buffer{fill:transparent}.a9s-handle [role=button]{cursor:inherit!important}.a9s-handle-dot{fill:#fff;pointer-events:none;stroke:#00000059;stroke-width:1px;vector-effect:non-scaling-stroke}.a9s-handle-dot.selected{fill:#1a1a1a;stroke:none}.a9s-handle-selected{animation:dash-rotate .35s linear infinite reverse;fill:#ffffff40;stroke:#000000e6;stroke-dasharray:2 2;stroke-width:1px;pointer-events:none;vector-effect:non-scaling-stroke}@keyframes dash-rotate{0%{stroke-dashoffset:0}to{stroke-dashoffset:4}}.a9s-edge-handle{fill:transparent;stroke:transparent;stroke-width:6px}.a9s-shape-handle,.a9s-handle{cursor:move}.a9s-handle.a9s-corner-handle{cursor:crosshair}.a9s-edge-handle-top{cursor:n-resize}.a9s-edge-handle-right{cursor:e-resize}.a9s-edge-handle-bottom{cursor:s-resize}.a9s-edge-handle-left{cursor:w-resize}.a9s-handle.a9s-corner-handle-topleft{cursor:nw-resize}.a9s-handle.a9s-corner-handle-topright{cursor:ne-resize}.a9s-handle.a9s-corner-handle-bottomright{cursor:se-resize}.a9s-handle.a9s-corner-handle-bottomleft{cursor:sw-resize}.a9s-annotationlayer .a9s-outer,div[data-theme=dark] .a9s-annotationlayer .a9s-outer{display:none}.a9s-annotationlayer .a9s-inner,div[data-theme=dark] .a9s-annotationlayer .a9s-inner{fill:#0000001f;stroke:#000;stroke-width:1px}rect.a9s-handle,div[data-theme=dark] rect.a9s-handle{fill:#000;rx:2px}rect.a9s-close-polygon-handle,div[data-theme=dark] rect.a9s-close-polygon-handle{fill:#000;rx:1px}.a9s-annotationlayer .a9s-outer,div[data-theme=light] .a9s-annotationlayer .a9s-outer{display:block;stroke:#00000059;stroke-width:3px}.a9s-annotationlayer .a9s-inner,div[data-theme=light] .a9s-annotationlayer .a9s-inner{fill:#ffffff26;stroke:#fff;stroke-width:1.5px}rect.a9s-handle,div[data-theme=light] rect.a9s-handle{fill:#fff;rx:1px;stroke:#00000073;stroke-width:1px}rect.a9s-close-polygon-handle,div[data-theme=light] rect.a9s-close-polygon-handle{fill:#fff;rx:1px;stroke:#00000073;stroke-width:1px} | ||
| circle.a9s-handle-buffer.svelte-qtyc7s:focus{outline:none}circle.a9s-handle-buffer.svelte-qtyc7s:focus-visible{stroke:#fffc;stroke-width:3px}.a9s-polygon-midpoint.svelte-12ykj76{cursor:crosshair}.a9s-polygon-midpoint-buffer.svelte-12ykj76{fill:transparent}.a9s-polygon-midpoint-outer.svelte-12ykj76{display:none;fill:transparent;pointer-events:none;stroke:#00000059;stroke-width:1.5px;vector-effect:non-scaling-stroke}.a9s-polygon-midpoint-inner.svelte-12ykj76{fill:#00000040;pointer-events:none;stroke:#fff;stroke-width:1px;vector-effect:non-scaling-stroke}mask.a9s-polygon-editor-mask.svelte-1h2slbm>rect.svelte-1h2slbm{fill:#fff}mask.a9s-polygon-editor-mask.svelte-1h2slbm>circle.svelte-1h2slbm,mask.a9s-polygon-editor-mask.svelte-1h2slbm>polygon.svelte-1h2slbm{fill:#000}mask.a9s-rectangle-editor-mask.svelte-1bwhzbc rect.rect-mask-bg.svelte-1bwhzbc{fill:#fff}mask.a9s-rectangle-editor-mask.svelte-1bwhzbc polygon.rect-mask-fg.svelte-1bwhzbc{fill:#000}.a9s-rotation-handle-line-bg{stroke:#00000080;stroke-width:1.5px;vector-effect:non-scaling-stroke}.a9s-rotation-handle-line-fg{stroke:#fff;stroke-width:1px;stroke-dasharray:3 1;vector-effect:non-scaling-stroke}mask.a9s-multipolygon-editor-mask.svelte-1vxo6dc>rect.svelte-1vxo6dc{fill:#fff}mask.a9s-multipolygon-editor-mask.svelte-1vxo6dc>circle.svelte-1vxo6dc,mask.a9s-multipolygon-editor-mask.svelte-1vxo6dc>path.svelte-1vxo6dc{fill:#000}mask.a9s-rubberband-rectangle-mask.svelte-1a76qe7>rect.rect-mask-bg.svelte-1a76qe7{fill:#fff}mask.a9s-rubberband-rectangle-mask.svelte-1a76qe7>rect.rect-mask-fg.svelte-1a76qe7{fill:#000}mask.a9s-rubberband-polygon-mask.svelte-18wrg3t>rect.svelte-18wrg3t{fill:#fff}mask.a9s-rubberband-polygon-mask.svelte-18wrg3t>polygon.svelte-18wrg3t{fill:#000}circle.a9s-handle.svelte-18wrg3t.svelte-18wrg3t{fill:#fff;pointer-events:none;stroke:#00000059;stroke-width:1px;vector-effect:non-scaling-stroke}path.open.svelte-1w0132l{fill:transparent!important}.a9s-annotationlayer{box-sizing:border-box;height:100%;left:0;outline:none;position:absolute;top:0;touch-action:none;width:100%;-webkit-tap-highlight-color:transparent;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;user-select:none}.a9s-annotationlayer.hover{cursor:pointer}.a9s-annotationlayer.hidden{display:none}.a9s-annotationlayer ellipse,.a9s-annotationlayer line,.a9s-annotationlayer path,.a9s-annotationlayer polygon,.a9s-annotationlayer rect{fill:transparent;shape-rendering:geometricPrecision;vector-effect:non-scaling-stroke;-webkit-tap-highlight-color:transparent}.a9s-touch-halo{fill:transparent;pointer-events:none;stroke-width:0;transition:fill .15s}.a9s-touch-halo.touched{fill:#fff6}.a9s-handle-buffer{fill:transparent}.a9s-handle [role=button]{cursor:inherit!important}.a9s-handle-dot{fill:#fff;pointer-events:none;stroke:#00000059;stroke-width:1px;vector-effect:non-scaling-stroke}.a9s-handle-dot.selected{fill:#1a1a1a;stroke:none}.a9s-handle-selected{animation:dash-rotate .35s linear infinite reverse;fill:#ffffff40;stroke:#000000e6;stroke-dasharray:2 2;stroke-width:1px;pointer-events:none;vector-effect:non-scaling-stroke}@keyframes dash-rotate{0%{stroke-dashoffset:0}to{stroke-dashoffset:4}}.a9s-edge-handle{fill:transparent;stroke:transparent;stroke-width:6px;vector-effect:non-scaling-stroke}.a9s-shape-handle,.a9s-handle{cursor:move}.a9s-handle.a9s-corner-handle{cursor:crosshair}.a9s-edge-handle-top{cursor:n-resize}.a9s-edge-handle-right{cursor:e-resize}.a9s-edge-handle-bottom{cursor:s-resize}.a9s-edge-handle-left{cursor:w-resize}.a9s-handle.a9s-corner-handle-topleft{cursor:nw-resize}.a9s-handle.a9s-corner-handle-topright{cursor:ne-resize}.a9s-handle.a9s-corner-handle-bottomright{cursor:se-resize}.a9s-handle.a9s-corner-handle-bottomleft{cursor:sw-resize}.a9s-annotationlayer .a9s-outer,div[data-theme=dark] .a9s-annotationlayer .a9s-outer{display:none}.a9s-annotationlayer .a9s-inner,div[data-theme=dark] .a9s-annotationlayer .a9s-inner{fill:#0000001f;stroke:#000;stroke-width:1px}rect.a9s-handle,div[data-theme=dark] rect.a9s-handle{fill:#000;rx:2px}rect.a9s-close-polygon-handle,div[data-theme=dark] rect.a9s-close-polygon-handle{fill:#000;rx:1px}.a9s-annotationlayer .a9s-outer,div[data-theme=light] .a9s-annotationlayer .a9s-outer{display:block;stroke:#00000059;stroke-width:3px}.a9s-annotationlayer .a9s-inner,div[data-theme=light] .a9s-annotationlayer .a9s-inner{fill:#ffffff26;stroke:#fff;stroke-width:1.5px}rect.a9s-handle,div[data-theme=light] rect.a9s-handle{fill:#fff;rx:1px;stroke:#00000073;stroke-width:1px}rect.a9s-close-polygon-handle,div[data-theme=light] rect.a9s-close-polygon-handle{fill:#fff;rx:1px;stroke:#00000073;stroke-width:1px} |
@@ -10,3 +10,4 @@ import { Bounds, Geometry, Shape } from '../Shape'; | ||
| h: number; | ||
| rot?: number; | ||
| bounds: Bounds; | ||
| } |
+2
-2
| { | ||
| "name": "@annotorious/annotorious", | ||
| "version": "3.7.22", | ||
| "version": "3.8.0", | ||
| "description": "Add image annotation functionality to any web page with a few lines of JavaScript", | ||
@@ -58,3 +58,3 @@ "author": "Rainer Simon", | ||
| "dependencies": { | ||
| "@annotorious/core": "3.7.22", | ||
| "@annotorious/core": "3.8.0", | ||
| "dequal": "^2.0.3", | ||
@@ -61,0 +61,0 @@ "rbush": "^4.0.1", |
| <script lang="ts"> | ||
| import { onMount } from 'svelte'; | ||
| import Handle from '../Handle.svelte'; | ||
| import { getMaskDimensions } from '../../utils'; | ||
| import type { Rectangle, Shape } from '../../../model'; | ||
| import { boundsFromPoints, type Rectangle, type RectangleGeometry, type Shape } from '../../../model'; | ||
| import type { Transform } from '../../Transform'; | ||
| import { Editor } from '..'; | ||
| import { | ||
| getRotatedCorners, | ||
| getRotationHandlePosition, | ||
| transformDeltaToLocalCoords, | ||
| angleFromPoints, | ||
| snapAngle | ||
| } from './rotationUtils'; | ||
@@ -15,32 +23,55 @@ /** Props */ | ||
| let shiftPressed = false; | ||
| $: ROTATION_HANDLE_OFFSET = 20 / viewportScale; | ||
| $: geom = shape.geometry; | ||
| $: rotatedCorners = getRotatedCorners(geom.x, geom.y, geom.w, geom.h, geom.rot); | ||
| $: rotationHandlePos = getRotationHandlePosition(geom, ROTATION_HANDLE_OFFSET); | ||
| const editor = (rectangle: Shape, handle: string, delta: [number, number]) => { | ||
| const initialBounds = rectangle.geometry.bounds; | ||
| let { x, y, w, h, rot = 0 } = (rectangle.geometry as RectangleGeometry); | ||
| let [x0, y0] = [initialBounds.minX, initialBounds.minY]; | ||
| let [x1, y1] = [initialBounds.maxX, initialBounds.maxY]; | ||
| const [dx, dy] = delta; | ||
| if (handle === 'SHAPE') { | ||
| x0 += dx; | ||
| x1 += dx; | ||
| y0 += dy; | ||
| y1 += dy; | ||
| if (handle === 'ROTATION') { | ||
| const handlePos = getRotationHandlePosition(rectangle.geometry as RectangleGeometry, ROTATION_HANDLE_OFFSET); | ||
| // Handle position after moving by delta | ||
| const currentHandleX = handlePos[0] + dx; | ||
| const currentHandleY = handlePos[1] + dy; | ||
| // Calculate the new rotation angle | ||
| const center: [number, number] = [x + w / 2, y + h / 2]; | ||
| rot += angleFromPoints([handlePos[0], handlePos[1]], [currentHandleX, currentHandleY], center); | ||
| // Snap to 10 degrees if SHIFT is held | ||
| if (shiftPressed) | ||
| rot = snapAngle(rot); | ||
| } else if (handle === 'SHAPE') { | ||
| // Moving the entire shape - translate it without rotation change | ||
| x += dx; | ||
| y += dy; | ||
| } else { | ||
| // Edge or corner handle - resize in local (rotated) coordinate space | ||
| let localX0 = 0; | ||
| let localY0 = 0; | ||
| let localX1 = w; | ||
| let localY1 = h; | ||
| const [localDx, localDy] = rot !== 0 | ||
| ? transformDeltaToLocalCoords(dx, dy, rot) | ||
| : [dx, dy]; | ||
| switch (handle) { | ||
| case 'TOP': | ||
| case 'TOP_LEFT': | ||
| case 'TOP_RIGHT': { | ||
| y0 += dy; | ||
| case 'TOP_RIGHT': | ||
| localY0 += localDy; | ||
| break; | ||
| } | ||
| case 'BOTTOM': | ||
| case 'BOTTOM_LEFT': | ||
| case 'BOTTOM_RIGHT': { | ||
| y1 += dy; | ||
| case 'BOTTOM_RIGHT': | ||
| localY1 += localDy; | ||
| break; | ||
| } | ||
| } | ||
@@ -51,20 +82,42 @@ | ||
| case 'TOP_LEFT': | ||
| case 'BOTTOM_LEFT': { | ||
| x0 += dx; | ||
| case 'BOTTOM_LEFT': | ||
| localX0 += localDx; | ||
| break; | ||
| } | ||
| case 'RIGHT': | ||
| case 'TOP_RIGHT': | ||
| case 'BOTTOM_RIGHT': { | ||
| x1 += dx; | ||
| case 'BOTTOM_RIGHT': | ||
| localX1 += localDx; | ||
| break; | ||
| } | ||
| } | ||
| // The center shifts as edges move - calculate new center in local space | ||
| const newLocalCx = (localX0 + localX1) / 2; | ||
| const newLocalCy = (localY0 + localY1) / 2; | ||
| w = Math.abs(localX1 - localX0); | ||
| h = Math.abs(localY1 - localY0); | ||
| // Rotate the local center offset back to world space | ||
| const oldCenter: [number, number] = [ | ||
| x + (rectangle.geometry as RectangleGeometry).w / 2, | ||
| y + (rectangle.geometry as RectangleGeometry).h / 2 | ||
| ]; | ||
| const localCenterOffset: [number, number] = [ | ||
| newLocalCx - (rectangle.geometry as RectangleGeometry).w / 2, | ||
| newLocalCy - (rectangle.geometry as RectangleGeometry).h / 2 | ||
| ]; | ||
| const cos = Math.cos(rot); | ||
| const sin = Math.sin(rot); | ||
| const worldCx = oldCenter[0] + localCenterOffset[0] * cos - localCenterOffset[1] * sin; | ||
| const worldCy = oldCenter[1] + localCenterOffset[0] * sin + localCenterOffset[1] * cos; | ||
| x = worldCx - w / 2; | ||
| y = worldCy - h / 2; | ||
| } | ||
| const x = Math.min(x0, x1); | ||
| const y = Math.min(y0, y1); | ||
| const w = Math.abs(x1 - x0); | ||
| const h = Math.abs(y1 - y0); | ||
| // Calculate new bounds | ||
| const bounds = boundsFromPoints(rotatedCorners); | ||
@@ -74,9 +127,4 @@ return { | ||
| geometry: { | ||
| x, y, w, h, | ||
| bounds: { | ||
| minX: x, | ||
| minY: y, | ||
| maxX: x + w, | ||
| maxY: y + h | ||
| } | ||
| x, y, w, h, rot, | ||
| bounds | ||
| } | ||
@@ -86,3 +134,22 @@ }; | ||
| $: mask = getMaskDimensions(geom.bounds, 2 / viewportScale); | ||
| onMount(() => { | ||
| // Track SHIFT key | ||
| const onKeyDown = (evt: KeyboardEvent) => { | ||
| if (evt.key === 'Shift') shiftPressed = true; | ||
| } | ||
| const onKeyUp = (evt: KeyboardEvent) => { | ||
| if (evt.key === 'Shift') shiftPressed = false; | ||
| } | ||
| window.addEventListener('keydown', onKeyDown); | ||
| window.addEventListener('keyup', onKeyUp); | ||
| return () => { | ||
| window.removeEventListener('keydown', onKeyDown); | ||
| window.removeEventListener('keyup', onKeyUp); | ||
| }; | ||
| }); | ||
| $: mask = getMaskDimensions(geom.bounds, 5 / viewportScale); | ||
@@ -105,42 +172,78 @@ const maskId = `rect-mask-${Math.random().toString(36).substring(2, 12)}`; | ||
| <rect class="rect-mask-bg" x={mask.x} y={mask.y} width={mask.w} height={mask.h} /> | ||
| <rect class="rect-mask-fg" x={geom.x} y={geom.y} width={geom.w} height={geom.h} /> | ||
| <polygon | ||
| class="rect-mask-fg" | ||
| points={rotatedCorners.map(c => `${c[0]},${c[1]}`).join(' ')} /> | ||
| </mask> | ||
| </defs> | ||
| <rect | ||
| class="a9s-outer" | ||
| mask={`url(#${maskId})`} | ||
| on:pointerdown={grab('SHAPE')} | ||
| x={geom.x} y={geom.y} width={geom.w} height={geom.h} /> | ||
| <!-- Rotation handle --> | ||
| <g> | ||
| <line | ||
| class="a9s-rotation-handle-line-bg" | ||
| x1={rotatedCorners[0][0] + (rotatedCorners[1][0] - rotatedCorners[0][0]) / 2} | ||
| y1={rotatedCorners[0][1] + (rotatedCorners[1][1] - rotatedCorners[0][1]) / 2} | ||
| x2={rotationHandlePos[0]} | ||
| y2={rotationHandlePos[1]} | ||
| pointer-events="none" /> | ||
| <rect | ||
| class="a9s-inner a9s-shape-handle" | ||
| style={computedStyle} | ||
| on:pointerdown={grab('SHAPE')} | ||
| x={geom.x} y={geom.y} width={geom.w} height={geom.h} /> | ||
| <line | ||
| class="a9s-rotation-handle-line-fg" | ||
| x1={rotatedCorners[0][0] + (rotatedCorners[1][0] - rotatedCorners[0][0]) / 2} | ||
| y1={rotatedCorners[0][1] + (rotatedCorners[1][1] - rotatedCorners[0][1]) / 2} | ||
| x2={rotationHandlePos[0]} | ||
| y2={rotationHandlePos[1]} | ||
| pointer-events="none" /> | ||
| <rect | ||
| class="a9s-edge-handle a9s-edge-handle-top" | ||
| on:pointerdown={grab('TOP')} | ||
| x={geom.x} y={geom.y} height={1} width={geom.w} /> | ||
| <Handle | ||
| class="a9s-rotation-handle" | ||
| on:pointerdown={grab('ROTATION')} | ||
| x={rotationHandlePos[0]} y={rotationHandlePos[1]} | ||
| scale={viewportScale} /> | ||
| </g> | ||
| <rect | ||
| <!-- Rectangle shape --> | ||
| <g> | ||
| <polygon | ||
| class="a9s-outer" | ||
| mask={`url(#${maskId})`} | ||
| on:pointerdown={grab('SHAPE')} | ||
| points={rotatedCorners.map(c => `${c[0]},${c[1]}`).join(' ')} /> | ||
| <polygon | ||
| class="a9s-inner a9s-shape-handle" | ||
| style={computedStyle} | ||
| on:pointerdown={grab('SHAPE')} | ||
| points={rotatedCorners.map(c => `${c[0]},${c[1]}`).join(' ')} /> | ||
| </g> | ||
| <!-- Edge handles --> | ||
| <line | ||
| class="a9s-edge-handle a9s-edge-handle-top" | ||
| x1={rotatedCorners[0][0]} y1={rotatedCorners[0][1]} | ||
| x2={rotatedCorners[1][0]} y2={rotatedCorners[1][1]} | ||
| on:pointerdown={grab('TOP')} /> | ||
| <line | ||
| class="a9s-edge-handle a9s-edge-handle-right" | ||
| on:pointerdown={grab('RIGHT')} | ||
| x={geom.x + geom.w} y={geom.y} height={geom.h} width={1}/> | ||
| x1={rotatedCorners[1][0]} y1={rotatedCorners[1][1]} | ||
| x2={rotatedCorners[2][0]} y2={rotatedCorners[2][1]} | ||
| on:pointerdown={grab('RIGHT')} /> | ||
| <rect | ||
| class="a9s-edge-handle a9s-edge-handle-bottom" | ||
| on:pointerdown={grab('BOTTOM')} | ||
| x={geom.x} y={geom.y + geom.h} height={1} width={geom.w} /> | ||
| <line | ||
| class="a9s-edge-handle a9s-edge-handle-bottom" | ||
| x1={rotatedCorners[2][0]} y1={rotatedCorners[2][1]} | ||
| x2={rotatedCorners[3][0]} y2={rotatedCorners[3][1]} | ||
| on:pointerdown={grab('BOTTOM')} /> | ||
| <rect | ||
| class="a9s-edge-handle a9s-edge-handle-left" | ||
| on:pointerdown={grab('LEFT')} | ||
| x={geom.x} y={geom.y} height={geom.h} width={1} /> | ||
| <line | ||
| class="a9s-edge-handle a9s-edge-handle-left" | ||
| x1={rotatedCorners[3][0]} y1={rotatedCorners[3][1]} | ||
| x2={rotatedCorners[0][0]} y2={rotatedCorners[0][1]} | ||
| on:pointerdown={grab('LEFT')} /> | ||
| <!-- Corner handles --> | ||
| <Handle | ||
| class="a9s-corner-handle-topleft" | ||
| on:pointerdown={grab('TOP_LEFT')} | ||
| x={geom.x} y={geom.y} | ||
| x={rotatedCorners[0][0]} y={rotatedCorners[0][1]} | ||
| scale={viewportScale} /> | ||
@@ -151,3 +254,3 @@ | ||
| on:pointerdown={grab('TOP_RIGHT')} | ||
| x={geom.x + geom.w} y={geom.y} | ||
| x={rotatedCorners[1][0]} y={rotatedCorners[1][1]} | ||
| scale={viewportScale} /> | ||
@@ -158,3 +261,3 @@ | ||
| on:pointerdown={grab('BOTTOM_RIGHT')} | ||
| x={geom.x + geom.w} y={geom.y + geom.h} | ||
| x={rotatedCorners[2][0]} y={rotatedCorners[2][1]} | ||
| scale={viewportScale} /> | ||
@@ -165,3 +268,3 @@ | ||
| on:pointerdown={grab('BOTTOM_LEFT')} | ||
| x={geom.x} y={geom.y + geom.h} | ||
| x={rotatedCorners[3][0]} y={rotatedCorners[3][1]} | ||
| scale={viewportScale} /> | ||
@@ -171,9 +274,22 @@ </Editor> | ||
| <style> | ||
| mask.a9s-rectangle-editor-mask > rect.rect-mask-bg { | ||
| mask.a9s-rectangle-editor-mask rect.rect-mask-bg { | ||
| fill: #fff; | ||
| } | ||
| mask.a9s-rectangle-editor-mask > rect.rect-mask-fg { | ||
| mask.a9s-rectangle-editor-mask polygon.rect-mask-fg { | ||
| fill: #000; | ||
| } | ||
| :global(.a9s-rotation-handle-line-bg) { | ||
| stroke: rgba(0, 0, 0, 0.5); | ||
| stroke-width: 1.5px; | ||
| vector-effect: non-scaling-stroke; | ||
| } | ||
| :global(.a9s-rotation-handle-line-fg) { | ||
| stroke: #fff; | ||
| stroke-width: 1px; | ||
| stroke-dasharray: 3 1; | ||
| vector-effect: non-scaling-stroke; | ||
| } | ||
| </style> |
@@ -13,21 +13,28 @@ <script lang="ts"> | ||
| $: ({ x, y, w, h } = geom as RectangleGeometry); | ||
| $: ({ x, y, w, h, rot } = geom as RectangleGeometry); | ||
| // Calculate transform for rotation | ||
| $: rectTransform = (rot ?? 0) !== 0 ? | ||
| `translate(${x + w / 2}, ${y + h / 2}) rotate(${((rot ?? 0) * 180) / Math.PI}) translate(${-(x + w / 2)}, ${-(y + h / 2)})` : | ||
| undefined; | ||
| </script> | ||
| <g class="a9s-annotation" data-id={annotation.id}> | ||
| <rect | ||
| class="a9s-outer" | ||
| style={computedStyle ? 'display:none;' : undefined} | ||
| x={x} | ||
| y={y} | ||
| width={w} | ||
| height={h} /> | ||
| <g transform={rectTransform}> | ||
| <rect | ||
| class="a9s-outer" | ||
| style={computedStyle ? 'display:none;' : undefined} | ||
| x={x} | ||
| y={y} | ||
| width={w} | ||
| height={h} /> | ||
| <rect | ||
| class="a9s-inner" | ||
| style={computedStyle} | ||
| x={x} | ||
| y={y} | ||
| width={w} | ||
| height={h} /> | ||
| <rect | ||
| class="a9s-inner" | ||
| style={computedStyle} | ||
| x={x} | ||
| y={y} | ||
| width={w} | ||
| height={h} /> | ||
| </g> | ||
| </g> |
| <script lang="ts" generics="I extends Annotation, E extends unknown"> | ||
| import { type SvelteComponent, onMount } from 'svelte'; | ||
| import { onMount } from 'svelte'; | ||
| import { v4 as uuidv4 } from 'uuid'; | ||
| import type { Annotation, DrawingStyleExpression, StoreChangeEvent, User } from '@annotorious/core'; | ||
| import type { Annotation, DrawingStyleExpression, Selection, StoreChangeEvent, User } from '@annotorious/core'; | ||
| import { isImageAnnotation, ShapeType } from '../model'; | ||
@@ -59,3 +59,3 @@ import type { ImageAnnotation, Shape} from '../model'; | ||
| $: trackSelection($selection.selected); | ||
| $: trackSelection(($selection as Selection).selected); | ||
@@ -62,0 +62,0 @@ const trackSelection = (selected: { id: string, editable?: boolean }[]) => { |
@@ -109,2 +109,3 @@ <script lang="ts"> | ||
| }, | ||
| rot: 0, | ||
| x, y, w, h | ||
@@ -111,0 +112,0 @@ } |
@@ -96,2 +96,3 @@ /** | ||
| stroke-width: 6px; | ||
| vector-effect: non-scaling-stroke; | ||
| } | ||
@@ -98,0 +99,0 @@ |
@@ -19,4 +19,6 @@ import type { Bounds, Geometry, Shape } from '../Shape'; | ||
| rot?: number; | ||
| bounds: Bounds; | ||
| } |
@@ -9,10 +9,36 @@ import { ShapeType } from '../Shape'; | ||
| intersects: (rect: Rectangle, x: number, y: number): boolean => | ||
| x >= rect.geometry.x && | ||
| x <= rect.geometry.x + rect.geometry.w && | ||
| y >= rect.geometry.y && | ||
| y <= rect.geometry.y + rect.geometry.h | ||
| intersects: (rect: Rectangle, x: number, y: number): boolean => { | ||
| const geom = rect.geometry; | ||
| if (!geom.rot) { | ||
| return x >= geom.x && | ||
| x <= geom.x + geom.w && | ||
| y >= geom.y && | ||
| y <= geom.y + geom.h; | ||
| } else { | ||
| // For rotated rectangles, transform the test point to local coordinates | ||
| const centerX = geom.x + geom.w / 2; | ||
| const centerY = geom.y + geom.h / 2; | ||
| // Translate point relative to center | ||
| const dx = x - centerX; | ||
| const dy = y - centerY; | ||
| // Rotate backwards to get to local (non-rotated) coordinates | ||
| const cos = Math.cos(geom.rot); | ||
| const sin = Math.sin(geom.rot); | ||
| const localX = dx * cos + dy * sin; | ||
| const localY = -dx * sin + dy * cos; | ||
| // Check if point is within rectangle bounds in local space | ||
| return localX >= -geom.w / 2 && | ||
| localX <= geom.w / 2 && | ||
| localY >= -geom.h / 2 && | ||
| localY <= geom.h / 2; | ||
| } | ||
| } | ||
| }; | ||
| registerShapeUtil(ShapeType.RECTANGLE, RectangleUtil); |
@@ -58,2 +58,3 @@ import type { Rectangle, RectangleGeometry } from '../../core'; | ||
| h, | ||
| rot: 0, | ||
| bounds: { | ||
@@ -60,0 +61,0 @@ minX: x, |
@@ -15,2 +15,4 @@ import { boundsFromPoints, computeSVGPath, multipolygonElementToPath, ShapeType } from '../../core'; | ||
| PolylineGeometry, | ||
| Rectangle, | ||
| RectangleGeometry, | ||
| Shape | ||
@@ -111,2 +113,64 @@ } from '../../core'; | ||
| const parseSVGRect = (value: string): Rectangle => { | ||
| const doc = parseSVGXML(value); | ||
| const rect = doc.nodeName === 'rect' ? doc : Array.from(doc.querySelectorAll('rect'))[0]; | ||
| if (!rect) throw new Error('Could not parse SVG rect'); | ||
| const x = parseFloat(rect.getAttribute('x')!); | ||
| const y = parseFloat(rect.getAttribute('y')!); | ||
| const w = parseFloat(rect.getAttribute('width')!); | ||
| const h = parseFloat(rect.getAttribute('height')!); | ||
| const transform = rect.getAttribute('transform'); | ||
| let rot = 0; | ||
| if (transform && transform.startsWith('rotate(')) { | ||
| const match = transform.match(/rotate\(([^)]+)\)/); | ||
| if (match) { | ||
| const params = match[1].split(/\s+/).map(parseFloat); | ||
| rot = (params[0] * Math.PI) / 180; | ||
| } | ||
| } | ||
| // Compute bounds | ||
| const cx = x + w / 2; | ||
| const cy = y + h / 2; | ||
| const corners = [ | ||
| [x, y], | ||
| [x + w, y], | ||
| [x + w, y + h], | ||
| [x, y + h] | ||
| ]; | ||
| // Rotate corners around center | ||
| const rotatedCorners = corners.map(([px, py]) => { | ||
| const dx = px - cx; | ||
| const dy = py - cy; | ||
| const cos = Math.cos(rot); | ||
| const sin = Math.sin(rot); | ||
| return [ | ||
| cx + dx * cos - dy * sin, | ||
| cy + dx * sin + dy * cos | ||
| ]; | ||
| }); | ||
| const bounds = boundsFromPoints(rotatedCorners as [number, number][]); | ||
| return { | ||
| type: ShapeType.RECTANGLE, | ||
| geometry: { | ||
| x, | ||
| y, | ||
| w, | ||
| h, | ||
| rot, | ||
| bounds | ||
| } | ||
| }; | ||
| } | ||
| const parseSVGPathToPolygon = (value: string): Polygon | MultiPolygon => { | ||
@@ -156,2 +220,4 @@ const doc = parseSVGXML(value); | ||
| return parseSVGLine(value) as unknown as T; | ||
| else if (value.includes('<rect ')) | ||
| return parseSVGRect(value) as unknown as T; | ||
| else | ||
@@ -172,2 +238,17 @@ throw 'Unsupported SVG shape: ' + value; | ||
| switch (shape.type) { | ||
| case ShapeType.RECTANGLE: { | ||
| const geom = shape.geometry as RectangleGeometry; | ||
| const { x, y, w, h, rot } = geom; | ||
| if (!rot) { | ||
| value = `<svg><rect x="${x}" y="${y}" width="${w}" height="${h}" /></svg>`; | ||
| } else { | ||
| const cx = x + w / 2; | ||
| const cy = y + h / 2; | ||
| const angle = ((rot ?? 0) * 180) / Math.PI; | ||
| value = `<svg><rect x="${x}" y="${y}" width="${w}" height="${h}" transform="rotate(${angle} ${cx} ${cy})" /></svg>`; | ||
| } | ||
| break; | ||
| } | ||
| case ShapeType.POLYGON: { | ||
@@ -174,0 +255,0 @@ const geom = shape.geometry as PolygonGeometry; |
@@ -106,5 +106,7 @@ import { v4 as uuidv4 } from 'uuid'; | ||
| try { | ||
| w3cSelector = selector.type == ShapeType.RECTANGLE ? | ||
| serializeFragmentSelector(selector.geometry as RectangleGeometry) : | ||
| serializeSVGSelector(selector); | ||
| if (selector.type === ShapeType.RECTANGLE && !(selector.geometry as RectangleGeometry).rot) { | ||
| w3cSelector = serializeFragmentSelector(selector.geometry as RectangleGeometry); | ||
| } else { | ||
| w3cSelector = serializeSVGSelector(selector); | ||
| } | ||
| } catch (error) { | ||
@@ -114,3 +116,3 @@ if (opts.strict) | ||
| else | ||
| w3cSelector = selector; | ||
| w3cSelector = selector; | ||
| } | ||
@@ -117,0 +119,0 @@ |
@@ -86,7 +86,4 @@ import RBush from 'rbush'; | ||
| // Exact hit test on shape (not needed for rectangles!) | ||
| const exactHits = idxHits.filter(target => { | ||
| return (target.selector.type === ShapeType.RECTANGLE) || | ||
| intersects(target.selector, x, y, buffer); | ||
| }); | ||
| // Exact hit test on shape | ||
| const exactHits = idxHits.filter(({ selector }) => intersects(selector, x, y, buffer)); | ||
@@ -93,0 +90,0 @@ // Get smallest shape |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
Minified code
QualityThis package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
Minified code
QualityThis package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.
Found 1 instance in 1 package
1351877
4.39%175
1.16%10257
3.81%+ Added
- Removed
Updated