@annotorious/annotorious 3.4.1 → 3.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
- import { MultiPolygonElement } from './MultiPolygon';
1
+ import { MultiPolygonElement, MultiPolygonGeometry } from './MultiPolygon';
2
2
  export declare const boundsFromMultiPolygonElements: (elements: MultiPolygonElement[]) => import('../Shape').Bounds;
3
3
  export declare const multipolygonElementToPath: (element: MultiPolygonElement) => string;
4
+ export declare const getAllCorners: (geom: MultiPolygonGeometry) => [number, number][];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@annotorious/annotorious",
3
- "version": "3.4.1",
3
+ "version": "3.4.2",
4
4
  "description": "Add image annotation functionality to any web page with a few lines of JavaScript",
5
5
  "author": "Rainer Simon",
6
6
  "license": "BSD-3-Clause",
@@ -49,7 +49,8 @@
49
49
  "vitest": "^3.1.4"
50
50
  },
51
51
  "dependencies": {
52
- "@annotorious/core": "3.4.1",
52
+ "@annotorious/core": "3.4.2",
53
+ "dequal": "^2.0.3",
53
54
  "rbush": "^4.0.1",
54
55
  "svg-pathdata": "^7.2.0",
55
56
  "uuid": "^11.1.0"
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { isTouch } from '../../utils';
2
+ import { isTouch } from '../utils';
3
3
 
4
4
  /** props **/
5
5
  export let x: number;
@@ -1,18 +1,30 @@
1
1
  <script lang="ts">
2
- import type {
3
- MultiPolygon,
4
- MultiPolygonElement,
5
- MultiPolygonGeometry,
6
- Shape
7
- } from '../../../model';
2
+ import { createEventDispatcher, onMount, tick } from 'svelte';
3
+ import { dequal } from 'dequal/lite';
4
+ import type { MultiPolygon, MultiPolygonElement, MultiPolygonGeometry, Shape } from '../../../model';
5
+ import { getMaskDimensions, isTouch } from '../../utils';
6
+ import type { Transform } from '../../Transform';
7
+ import Editor from '../Editor.svelte';
8
+ import Handle from '../Handle.svelte';
9
+ import MidpointHandle from '../MidpointHandle.svelte';
10
+ import { computeMidpoints } from './utils';
8
11
  import {
9
12
  boundsFromMultiPolygonElements,
10
13
  boundsFromPoints,
11
- multipolygonElementToPath
14
+ getAllCorners,
15
+ multipolygonElementToPath
12
16
  } from '../../../model';
13
- import type { Transform } from '../../Transform';
14
- import { getMaskDimensions } from '../../utils';
15
- import { Editor, Handle } from '..';
17
+
18
+ const dispatch = createEventDispatcher<{ change: MultiPolygon }>();
19
+
20
+ /** Time difference (milliseconds) required for registering a click/tap **/
21
+ const CLICK_THRESHOLD = 250;
22
+
23
+ /** Minimum distance (px) to shape required for midpoints to show */
24
+ const MIN_HOVER_DISTANCE = 1000;
25
+
26
+ /** Needed for the <mask> element **/
27
+ const MIDPOINT_SIZE = 4.5;
16
28
 
17
29
  /** Props */
18
30
  export let shape: MultiPolygon;
@@ -21,9 +33,120 @@
21
33
  export let viewportScale: number = 1;
22
34
  export let svgEl: SVGSVGElement;
23
35
 
36
+ /** Drawing tool layer **/
37
+ let visibleMidpoint: number | undefined;
38
+ let isHandleHovered = false;
39
+ let lastHandleClick: number | undefined;
40
+ let selectedCorners: { polygon: number, ring: number, point: number }[] = [];
41
+
24
42
  $: geom = shape.geometry;
25
43
 
44
+ // No support yet for adding or removing points in mobile!
45
+ $: midpoints = isTouch ? [] : computeMidpoints(geom, viewportScale);
46
+
47
+ /** Handle hover state **/
48
+ const onEnterHandle = () => isHandleHovered = true;
49
+ const onLeaveHandle = () => isHandleHovered = false;
50
+
51
+ /** Determine visible midpoint, if any **/
52
+ const onPointerMove = (evt: PointerEvent) => {
53
+ if (selectedCorners.length > 0) {
54
+ visibleMidpoint = undefined;
55
+ return;
56
+ }
57
+
58
+ const [px, py] = transform.elementToImage(evt.offsetX, evt.offsetY);
59
+
60
+ const getDistSq = (pt: number[]) =>
61
+ Math.pow(pt[0] - px, 2) + Math.pow(pt[1] - py, 2);
62
+
63
+ const closestCorner = getAllCorners(geom).reduce((closest, corner) =>
64
+ getDistSq(corner) < getDistSq(closest) ? corner : closest);
65
+
66
+ const closestVisibleMidpoint = midpoints
67
+ .filter(m => m.visible)
68
+ .reduce((closest, midpoint) =>
69
+ getDistSq(midpoint.point) < getDistSq(closest.point) ? midpoint : closest);
70
+
71
+ // Show midpoint of the mouse is at least within THRESHOLD distance
72
+ // of the midpoint or the closest corner. (Basically a poor man's shape buffering).
73
+ const threshold = Math.pow(MIN_HOVER_DISTANCE / viewportScale, 2);
74
+
75
+ const shouldShow =
76
+ getDistSq(closestCorner) < threshold ||
77
+ getDistSq(closestVisibleMidpoint.point) < threshold;
78
+
79
+ if (shouldShow)
80
+ visibleMidpoint = midpoints.indexOf(closestVisibleMidpoint);
81
+ else
82
+ visibleMidpoint = undefined;
83
+ }
84
+
85
+ /**
86
+ * SVG element keeps loosing focus when interacting with
87
+ * shapes–this function refocuses.
88
+ */
89
+ const reclaimFocus = () => {
90
+ if (document.activeElement !== svgEl)
91
+ svgEl.focus();
92
+ }
93
+
94
+ /**
95
+ * De-selects all corners and reclaims focus.
96
+ */
97
+ const onShapePointerUp = () => {
98
+ selectedCorners = [];
99
+ reclaimFocus();
100
+ }
101
+
102
+ /**
103
+ * Updates state, waiting for potential click.
104
+ */
105
+ const onHandlePointerDown = (evt: PointerEvent) => {
106
+ isHandleHovered = true;
107
+
108
+ evt.preventDefault();
109
+ evt.stopPropagation();
110
+
111
+ lastHandleClick = performance.now();
112
+ }
113
+
114
+ /** Selection handling logic **/
115
+ const onHandlePointerUp = (polygon: number, ring: number, point: number) => (evt: PointerEvent) => {
116
+ if (!lastHandleClick || isTouch) return;
117
+
118
+ // Drag, not click
119
+ if (performance.now() - lastHandleClick > CLICK_THRESHOLD) return;
120
+
121
+ // Shorthand
122
+ const isMatch = (other: { polygon: number, ring: number, point: number }) =>
123
+ other.polygon === polygon && other.ring === ring && other.point === point;
124
+
125
+ const isSelected = selectedCorners.some(isMatch);
126
+
127
+ if (evt.metaKey || evt.ctrlKey || evt.shiftKey) {
128
+ // Add to or remove from selection
129
+ if (isSelected)
130
+ selectedCorners = selectedCorners.filter(other => !isMatch(other));
131
+ else
132
+ selectedCorners = [...selectedCorners, { polygon, ring, point }];
133
+ } else {
134
+ if (isSelected && selectedCorners.length > 1)
135
+ // Keep selected, de-select others
136
+ selectedCorners = [{ polygon, ring, point }]
137
+ else if (isSelected)
138
+ // De-select
139
+ selectedCorners = [];
140
+ else
141
+ selectedCorners = [{ polygon, ring, point }];
142
+ }
143
+
144
+ reclaimFocus();
145
+ }
146
+
26
147
  const editor = (shape: Shape, handle: string, delta: [number, number]) => {
148
+ reclaimFocus();
149
+
27
150
  const elements = ((shape.geometry) as MultiPolygonGeometry).polygons;
28
151
 
29
152
  let updated: MultiPolygonElement[];
@@ -79,9 +202,126 @@
79
202
  } as MultiPolygon;
80
203
  }
81
204
 
82
- $: mask = getMaskDimensions(geom.bounds, 2 / viewportScale);
205
+ const onAddPoint = (midpointIdx: number) => async (evt: PointerEvent) => {
206
+ evt.stopPropagation();
83
207
 
84
- const maskId = `multipoly-mask-${Math.random().toString(36).substring(2, 12)}`;
208
+ const midpoint = midpoints[midpointIdx];
209
+
210
+ const updated = geom.polygons.map((element, elIdx) => {
211
+ if (elIdx === midpoint.elementIdx) {
212
+ const rings = element.rings.map((ring, ringIdx) => {
213
+ if (ringIdx === midpoint.ringIdx) {
214
+ const points = [
215
+ ...ring.points.slice(0, midpoint.pointIdx + 1),
216
+ midpoint.point,
217
+ ...ring.points.slice(midpoint.pointIdx + 1)
218
+ ] as [number, number][];
219
+
220
+ return { points };
221
+ } else {
222
+ return ring;
223
+ }
224
+ });
225
+
226
+ const bounds = boundsFromPoints(rings[0].points as [number, number][]);
227
+ return { rings, bounds } as MultiPolygonElement;
228
+ } else {
229
+ return element;
230
+ }
231
+ });
232
+
233
+ dispatch('change', {
234
+ ...shape,
235
+ geometry: {
236
+ polygons: updated,
237
+ bounds: boundsFromMultiPolygonElements(updated)
238
+ }
239
+ } as MultiPolygon);
240
+
241
+ await tick();
242
+
243
+ // Find the newly inserted handle and dispatch grab event
244
+ const newHandle = [...document.querySelectorAll(`.a9s-handle`)][midpointIdx + 1];
245
+ if (newHandle?.firstChild) {
246
+ const newEvent = new PointerEvent('pointerdown', {
247
+ bubbles: true,
248
+ cancelable: true,
249
+ clientX: evt.clientX,
250
+ clientY: evt.clientY,
251
+ pointerId: evt.pointerId,
252
+ pointerType: evt.pointerType,
253
+ isPrimary: evt.isPrimary,
254
+ buttons: evt.buttons
255
+ });
256
+
257
+ newHandle.firstChild.dispatchEvent(newEvent);
258
+ }
259
+ }
260
+
261
+ const onDeleteSelected = () => {
262
+ const updatedPolygons = geom.polygons.map((polygon, polygonIdx) => {
263
+ const hasSelected = selectedCorners.some(s => s.polygon === polygonIdx);
264
+
265
+ if (hasSelected) {
266
+ const updatedRings = polygon.rings.map((ring, ringIdx) => {
267
+ const hasSelected = selectedCorners.some(s => s.polygon === polygonIdx && s.ring === ringIdx);
268
+
269
+ // Rings needs 3 points min
270
+ if (hasSelected && ring.points.length > 3) {
271
+ const points = ring.points.filter((_, i) =>
272
+ !selectedCorners.some(s => s.polygon === polygonIdx && s.ring === ringIdx && s.point === i));
273
+
274
+ return { points };
275
+ } else {
276
+ // No points selected on this ring
277
+ return ring;
278
+ }
279
+ });
280
+
281
+ const bounds = boundsFromPoints(updatedRings[0].points as [number, number][]);
282
+ return { rings: updatedRings, bounds } as MultiPolygonElement;
283
+ } else {
284
+ // No points selected on this polygon
285
+ return polygon;
286
+ }
287
+ });
288
+
289
+ const hasChanged = !dequal(geom.polygons, updatedPolygons);
290
+ if (hasChanged) {
291
+ dispatch('change', {
292
+ ...shape,
293
+ geometry: {
294
+ polygons: updatedPolygons,
295
+ bounds: boundsFromMultiPolygonElements(updatedPolygons)
296
+ }
297
+ } as MultiPolygon);
298
+
299
+ selectedCorners = [];
300
+ }
301
+ }
302
+
303
+ onMount(() => {
304
+ if (isTouch) return;
305
+
306
+ const onKeydown = (evt: KeyboardEvent) => {
307
+ if (evt.key === 'Delete' || evt.key === 'Backspace') {
308
+ evt.preventDefault();
309
+ onDeleteSelected();
310
+ }
311
+ };
312
+
313
+ svgEl.addEventListener('pointermove', onPointerMove);
314
+ svgEl.addEventListener('keydown', onKeydown);
315
+
316
+ return () => {
317
+ svgEl.removeEventListener('pointermove', onPointerMove);
318
+ svgEl.removeEventListener('keydown', onKeydown);
319
+ }
320
+ });
321
+
322
+ $: mask = getMaskDimensions(geom.bounds, MIDPOINT_SIZE / viewportScale);
323
+
324
+ const maskId = `polygon-mask-${Math.random().toString(36).substring(2, 12)}`;
85
325
  </script>
86
326
 
87
327
  <Editor
@@ -96,23 +336,39 @@
96
336
  {#each geom.polygons as element, elementIdx}
97
337
  <g>
98
338
  <defs>
99
- <mask id={`${maskId}-${elementIdx}`} class="a9s-multipolygon-editor-mask">
339
+ <mask id={`${maskId}-${elementIdx}-outer`} class="a9s-multipolygon-editor-mask">
100
340
  <rect x={mask.x} y={mask.y} width={mask.w} height={mask.h} />
101
- <path d={multipolygonElementToPath(element)} />
341
+ <path d={multipolygonElementToPath(element)} />
342
+
343
+ {#if (visibleMidpoint !== undefined && !isHandleHovered)}
344
+ {@const { point } = midpoints[visibleMidpoint]}
345
+ <circle cx={point[0]} cy={point[1]} r={MIDPOINT_SIZE / viewportScale} />
346
+ {/if}
102
347
  </mask>
348
+
349
+ {#if (visibleMidpoint !== undefined && !isHandleHovered)}
350
+ {@const { point } = midpoints[visibleMidpoint]}
351
+ <mask id={`${maskId}-${elementIdx}-inner`} class="a9s-multipolygon-editor-mask">
352
+ <rect x={mask.x} y={mask.y} width={mask.w} height={mask.h} />
353
+ <circle cx={point[0]} cy={point[1]} r={MIDPOINT_SIZE / viewportScale} />
354
+ </mask>
355
+ {/if}
103
356
  </defs>
104
357
 
105
358
  <path
106
359
  class="a9s-outer"
107
- mask={`url(#${maskId}-${elementIdx})`}
360
+ mask={`url(#${maskId}-${elementIdx}-outer)`}
108
361
  fill-rule="evenodd"
362
+ on:pointerup={onShapePointerUp}
109
363
  on:pointerdown={grab('SHAPE')}
110
364
  d={multipolygonElementToPath(element)} />
111
365
 
112
366
  <path
113
367
  class="a9s-inner"
368
+ mask={`url(#${maskId}-${elementIdx}-inner)`}
114
369
  style={computedStyle}
115
370
  fill-rule="evenodd"
371
+ on:pointerup={onShapePointerUp}
116
372
  on:pointerdown={grab('SHAPE')}
117
373
  d={multipolygonElementToPath(element)} />
118
374
 
@@ -120,13 +376,29 @@
120
376
  {#each ring.points as point, pointIdx}
121
377
  <Handle
122
378
  class="a9s-corner-handle"
379
+ x={point[0]}
380
+ y={point[1]}
381
+ scale={viewportScale}
382
+ selected={selectedCorners.some(({ polygon, ring, point }) =>
383
+ polygon === elementIdx && ring === ringIdx && point === pointIdx)}
384
+ on:pointerenter={onEnterHandle}
385
+ on:pointerleave={onLeaveHandle}
386
+ on:pointerdown={onHandlePointerDown}
123
387
  on:pointerdown={grab(`HANDLE-${elementIdx}-${ringIdx}-${pointIdx}`)}
124
- x={point[0]} y={point[1]}
125
- scale={viewportScale} />
388
+ on:pointerup={onHandlePointerUp(elementIdx, ringIdx, pointIdx)} />
126
389
  {/each}
127
390
  {/each}
128
391
  </g>
129
392
  {/each}
393
+
394
+ {#if (visibleMidpoint !== undefined && !isHandleHovered)}
395
+ {@const { point } = midpoints[visibleMidpoint]}
396
+ <MidpointHandle
397
+ x={point[0]}
398
+ y={point[1]}
399
+ scale={viewportScale}
400
+ on:pointerdown={onAddPoint(visibleMidpoint)} />
401
+ {/if}
130
402
  </Editor>
131
403
 
132
404
  <style>
@@ -134,6 +406,7 @@
134
406
  fill: #fff;
135
407
  }
136
408
 
409
+ mask.a9s-multipolygon-editor-mask > circle,
137
410
  mask.a9s-multipolygon-editor-mask > path {
138
411
  fill: #000;
139
412
  }
@@ -0,0 +1,42 @@
1
+ import type { MultiPolygonGeometry } from '../../../model';
2
+
3
+ /** Minimum distance (px) between corners required for midpoints to show **/
4
+ const MIN_VISIBILITY_DISTANCE = 12;
5
+
6
+ export interface MultipolygonMidpoint {
7
+
8
+ point: [number, number];
9
+
10
+ visible: boolean;
11
+
12
+ elementIdx: number;
13
+
14
+ ringIdx: number;
15
+
16
+ pointIdx: number;
17
+
18
+ }
19
+
20
+ export const computeMidpoints = (geom: MultiPolygonGeometry, viewportScale: number) =>
21
+ geom.polygons.reduce<MultipolygonMidpoint[]>((all, element, elementIdx) => {
22
+ const forThisPolygon = element.rings.reduce<MultipolygonMidpoint[]>((forThisPolygon, ring, ringIdx) => {
23
+ const forThisRing: MultipolygonMidpoint[] = ring.points.map((thisPoint, pointIdx) => {
24
+ const nextPoint = pointIdx === ring.points.length - 1 ? ring.points[0] : ring.points[pointIdx + 1];
25
+
26
+ const x = (thisPoint[0] + nextPoint[0]) / 2;
27
+ const y = (thisPoint[1] + nextPoint[1]) / 2;
28
+
29
+ const dist = Math.sqrt(
30
+ Math.pow(nextPoint[0] - x, 2) + Math.pow(nextPoint[1] - y, 2));
31
+
32
+ // Don't show if the distance between the corners is too small
33
+ const visible = dist > MIN_VISIBILITY_DISTANCE / viewportScale;
34
+
35
+ return { point: [x, y], visible, elementIdx, ringIdx, pointIdx };
36
+ });
37
+
38
+ return [...forThisPolygon, ...forThisRing];
39
+ }, []);
40
+
41
+ return [...all, ...forThisPolygon];
42
+ }, []);
@@ -6,7 +6,7 @@
6
6
  import type { Transform } from '../../Transform';
7
7
  import Editor from '../Editor.svelte';
8
8
  import Handle from '../Handle.svelte';
9
- import Midpoint from './MidpointHandle.svelte';
9
+ import MidpointHandle from '../MidpointHandle.svelte';
10
10
 
11
11
  const dispatch = createEventDispatcher<{ change: Polygon }>();
12
12
 
@@ -30,7 +30,6 @@
30
30
  export let svgEl: SVGSVGElement;
31
31
 
32
32
  /** Drawing tool layer **/
33
- let polygonEl: SVGGElement;
34
33
  let visibleMidpoint: number | undefined;
35
34
  let isHandleHovered = false;
36
35
  let lastHandleClick: number | undefined;
@@ -287,7 +286,6 @@
287
286
  points={geom.points.map(xy => xy.join(',')).join(' ')} />
288
287
 
289
288
  <polygon
290
- bind:this={polygonEl}
291
289
  class="a9s-inner a9s-shape-handle"
292
290
  mask={`url(#${maskId}-inner)`}
293
291
  style={computedStyle}
@@ -312,7 +310,7 @@
312
310
 
313
311
  {#if (visibleMidpoint !== undefined && !isHandleHovered)}
314
312
  {@const { point } = midpoints[visibleMidpoint]}
315
- <Midpoint
313
+ <MidpointHandle
316
314
  x={point[0]}
317
315
  y={point[1]}
318
316
  scale={viewportScale}
@@ -1,7 +1,7 @@
1
1
  import { ShapeType } from '../Shape';
2
2
  import type { ShapeUtil } from '../shapeUtils';
3
3
  import { boundsFromPoints, computePolygonArea, isPointInPolygon, pointsToPath, registerShapeUtil } from '../shapeUtils';
4
- import type { MultiPolygon, MultiPolygonElement } from './MultiPolygon';
4
+ import type { MultiPolygon, MultiPolygonElement, MultiPolygonGeometry } from './MultiPolygon';
5
5
 
6
6
  const MultiPolygonUtil: ShapeUtil<MultiPolygon> = {
7
7
 
@@ -60,4 +60,14 @@ export const multipolygonElementToPath = (element: MultiPolygonElement) => {
60
60
  return paths.join(' ');
61
61
  }
62
62
 
63
+ export const getAllCorners = (geom: MultiPolygonGeometry) =>
64
+ geom.polygons.reduce<[number, number][]>((all, element) => (
65
+ [
66
+ ...all,
67
+ ...element.rings.reduce<[number, number][]>((onThisElement, ring) => (
68
+ [...onThisElement, ...ring.points]
69
+ ), [])
70
+ ]
71
+ ), []);
72
+
63
73
  registerShapeUtil(ShapeType.MULTIPOLYGLON, MultiPolygonUtil);