@annotorious/annotorious 3.3.6 → 3.4.1

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,64 +1,332 @@
1
1
  <script lang="ts">
2
+ import { createEventDispatcher, onMount, tick } from 'svelte';
2
3
  import { boundsFromPoints } from '../../../model';
3
4
  import type { Polygon, PolygonGeometry, Shape } from '../../../model';
5
+ import { getMaskDimensions, isTouch } from '../../utils';
4
6
  import type { Transform } from '../../Transform';
5
- import { Editor, Handle } from '..';
7
+ import Editor from '../Editor.svelte';
8
+ import Handle from '../Handle.svelte';
9
+ import Midpoint from './MidpointHandle.svelte';
10
+
11
+ const dispatch = createEventDispatcher<{ change: Polygon }>();
12
+
13
+ /** Time difference (milliseconds) required for registering a click/tap **/
14
+ const CLICK_THRESHOLD = 250;
15
+
16
+ /** Minimum distance (px) to shape required for midpoints to show */
17
+ const MIN_HOVER_DISTANCE = 1000;
18
+
19
+ /** Minimum distance (px) between corners required for midpoints to show **/
20
+ const MIN_CORNER_DISTANCE = 12;
21
+
22
+ /** Needed for the <mask> element **/
23
+ const MIDPOINT_SIZE = 4.5;
6
24
 
7
25
  /** Props */
8
26
  export let shape: Polygon;
9
27
  export let computedStyle: string | undefined;
10
28
  export let transform: Transform;
11
29
  export let viewportScale: number = 1;
30
+ export let svgEl: SVGSVGElement;
31
+
32
+ /** Drawing tool layer **/
33
+ let polygonEl: SVGGElement;
34
+ let visibleMidpoint: number | undefined;
35
+ let isHandleHovered = false;
36
+ let lastHandleClick: number | undefined;
37
+ let selectedCorners: number[] = [];
12
38
 
13
39
  $: geom = shape.geometry;
14
40
 
41
+ // No support yet for adding or removing points in mobile!
42
+ $: midpoints = isTouch ? [] : geom.points.map((thisCorner, idx) => {
43
+ const nextCorner = idx === geom.points.length - 1 ? geom.points[0] : geom.points[idx + 1];
44
+
45
+ const x = (thisCorner[0] + nextCorner[0]) / 2;
46
+ const y = (thisCorner[1] + nextCorner[1]) / 2;
47
+
48
+ const dist = Math.sqrt(
49
+ Math.pow(nextCorner[0] - x, 2) + Math.pow(nextCorner[1] - y, 2));
50
+
51
+ // Don't show if the distance between the corners is too small
52
+ const visible = dist > MIN_CORNER_DISTANCE / viewportScale;
53
+
54
+ return { point: [x, y], visible };
55
+ });
56
+
57
+ /** Handle hover state **/
58
+ const onEnterHandle = () => isHandleHovered = true;
59
+ const onLeaveHandle = () => isHandleHovered = false;
60
+
61
+ /** Determine visible midpoint, if any **/
62
+ const onPointerMove = (evt: PointerEvent) => {
63
+ if (selectedCorners.length > 0) {
64
+ visibleMidpoint = undefined;
65
+ return;
66
+ }
67
+
68
+ const [px, py] = transform.elementToImage(evt.offsetX, evt.offsetY);
69
+
70
+ const getDistSq = (pt: number[]) =>
71
+ Math.pow(pt[0] - px, 2) + Math.pow(pt[1] - py, 2);
72
+
73
+ const closestCorner = geom.points.reduce((closest, corner) =>
74
+ getDistSq(corner) < getDistSq(closest) ? corner : closest);
75
+
76
+ const closestVisibleMidpoint = midpoints
77
+ .filter(m => m.visible)
78
+ .reduce((closest, midpoint) =>
79
+ getDistSq(midpoint.point) < getDistSq(closest.point) ? midpoint : closest);
80
+
81
+ // Show midpoint of the mouse is at least within THRESHOLD distance
82
+ // of the midpoint or the closest corner. (Basically a poor man's shape buffering).
83
+ const threshold = Math.pow(MIN_HOVER_DISTANCE / viewportScale, 2);
84
+
85
+ const shouldShow =
86
+ getDistSq(closestCorner) < threshold ||
87
+ getDistSq(closestVisibleMidpoint.point) < threshold;
88
+
89
+ if (shouldShow)
90
+ visibleMidpoint = midpoints.indexOf(closestVisibleMidpoint);
91
+ else
92
+ visibleMidpoint = undefined;
93
+ }
94
+
95
+ /**
96
+ * SVG element keeps loosing focus when interacting with
97
+ * shapes–this function refocuses.
98
+ */
99
+ const reclaimFocus = () => {
100
+ if (document.activeElement !== svgEl)
101
+ svgEl.focus();
102
+ }
103
+
104
+ /**
105
+ * De-selects all corners and reclaims focus.
106
+ */
107
+ const onShapePointerUp = () => {
108
+ selectedCorners = [];
109
+ reclaimFocus();
110
+ }
111
+
112
+ /**
113
+ * Updates state, waiting for potential click.
114
+ */
115
+ const onHandlePointerDown = (evt: PointerEvent) => {
116
+ isHandleHovered = true;
117
+
118
+ evt.preventDefault();
119
+ evt.stopPropagation();
120
+
121
+ lastHandleClick = performance.now();
122
+ }
123
+
124
+ /** Selection handling logic **/
125
+ const onHandlePointerUp = (idx: number) => (evt: PointerEvent) => {
126
+ if (!lastHandleClick || isTouch) return;
127
+
128
+ // Drag, not click
129
+ if (performance.now() - lastHandleClick > CLICK_THRESHOLD) return;
130
+
131
+ const isSelected = selectedCorners.includes(idx);
132
+
133
+ if (evt.metaKey || evt.ctrlKey || evt.shiftKey) {
134
+ // Add to or remove from selection
135
+ if (isSelected)
136
+ selectedCorners = selectedCorners.filter(i => i !== idx);
137
+ else
138
+ selectedCorners = [...selectedCorners, idx];
139
+ } else {
140
+ if (isSelected && selectedCorners.length > 1)
141
+ // Keep selected, de-select others
142
+ selectedCorners = [idx]
143
+ else if (isSelected)
144
+ // De-select
145
+ selectedCorners = [];
146
+ else
147
+ selectedCorners = [idx];
148
+ }
149
+
150
+ reclaimFocus();
151
+ }
152
+
15
153
  const editor = (polygon: Shape, handle: string, delta: [number, number]) => {
154
+ reclaimFocus();
155
+
16
156
  let points: [number, number][];
17
157
 
18
158
  const geom = (polygon.geometry) as PolygonGeometry;
19
159
 
20
- if (handle === 'SHAPE') {
160
+ if (selectedCorners.length > 1) {
161
+ points = geom.points.map(([x, y], idx) =>
162
+ selectedCorners.includes(idx) ? [x + delta[0], y + delta[1]] : [x, y]);
163
+ } else if (handle === 'SHAPE') {
21
164
  points = geom.points.map(([x, y]) => [x + delta[0], y + delta[1]]);
22
165
  } else {
23
166
  points = geom.points.map(([x, y], idx) =>
24
- handle === `HANDLE-${idx}` ? [x + delta[0], y + delta[1]] : [x, y]
25
- );
167
+ handle === `HANDLE-${idx}` ? [x + delta[0], y + delta[1]] : [x, y]);
26
168
  }
27
169
 
28
170
  const bounds = boundsFromPoints(points);
29
-
30
171
  return {
31
172
  ...polygon,
32
173
  geometry: { points, bounds }
33
174
  }
34
175
  }
176
+
177
+ const onAddPoint = (midpointIdx: number) => async (evt: PointerEvent) => {
178
+ evt.stopPropagation();
179
+
180
+ const points = [
181
+ ...geom.points.slice(0, midpointIdx + 1),
182
+ midpoints[midpointIdx].point,
183
+ ...geom.points.slice(midpointIdx + 1)
184
+ ] as [number, number][];
185
+
186
+ const bounds = boundsFromPoints(points);
187
+
188
+ dispatch('change', {
189
+ ...shape,
190
+ geometry: { points, bounds }
191
+ });
192
+
193
+ await tick();
194
+
195
+ // Find the newly inserted handle and dispatch grab event
196
+ const newHandle = [...document.querySelectorAll(`.a9s-handle`)][midpointIdx + 1];
197
+ if (newHandle?.firstChild) {
198
+ const newEvent = new PointerEvent('pointerdown', {
199
+ bubbles: true,
200
+ cancelable: true,
201
+ clientX: evt.clientX,
202
+ clientY: evt.clientY,
203
+ pointerId: evt.pointerId,
204
+ pointerType: evt.pointerType,
205
+ isPrimary: evt.isPrimary,
206
+ buttons: evt.buttons
207
+ });
208
+
209
+ newHandle.firstChild.dispatchEvent(newEvent);
210
+ }
211
+ }
212
+
213
+ const onDeleteSelected = () => {
214
+ // Polygon needs 3 points min
215
+ if (geom.points.length < 4) return;
216
+
217
+ const points = geom.points.filter((_, i) => !selectedCorners.includes(i)) as [number, number][];
218
+ const bounds = boundsFromPoints(points);
219
+
220
+ dispatch('change', {
221
+ ...shape,
222
+ geometry: { points, bounds }
223
+ });
224
+
225
+ selectedCorners = [];
226
+ }
227
+
228
+ onMount(() => {
229
+ if (isTouch) return;
230
+
231
+ const onKeydown = (evt: KeyboardEvent) => {
232
+ if (evt.key === 'Delete' || evt.key === 'Backspace') {
233
+ evt.preventDefault();
234
+ onDeleteSelected();
235
+ }
236
+ };
237
+
238
+ svgEl.addEventListener('pointermove', onPointerMove);
239
+ svgEl.addEventListener('keydown', onKeydown);
240
+
241
+ return () => {
242
+ svgEl.removeEventListener('pointermove', onPointerMove);
243
+ svgEl.removeEventListener('keydown', onKeydown);
244
+ }
245
+ });
246
+
247
+ $: mask = getMaskDimensions(geom.bounds, MIDPOINT_SIZE / viewportScale);
248
+
249
+ const maskId = `polygon-mask-${Math.random().toString(36).substring(2, 12)}`;
35
250
  </script>
36
251
 
37
252
  <Editor
38
253
  shape={shape}
39
254
  transform={transform}
40
255
  editor={editor}
256
+ svgEl={svgEl}
41
257
  on:change
42
258
  on:grab
43
259
  on:release
44
260
  let:grab={grab}>
261
+
262
+ <defs>
263
+ <mask id={`${maskId}-outer`} class="a9s-polygon-editor-mask">
264
+ <rect x={mask.x} y={mask.y} width={mask.w} height={mask.h} />
265
+ <polygon points={geom.points.map(xy => xy.join(',')).join(' ')} />
266
+
267
+ {#if (visibleMidpoint !== undefined && !isHandleHovered)}
268
+ {@const { point } = midpoints[visibleMidpoint]}
269
+ <circle cx={point[0]} cy={point[1]} r={MIDPOINT_SIZE / viewportScale} />
270
+ {/if}
271
+ </mask>
272
+
273
+ {#if (visibleMidpoint !== undefined && !isHandleHovered)}
274
+ {@const { point } = midpoints[visibleMidpoint]}
275
+ <mask id={`${maskId}-inner`} class="a9s-polygon-editor-mask">
276
+ <rect x={mask.x} y={mask.y} width={mask.w} height={mask.h} />
277
+ <circle cx={point[0]} cy={point[1]} r={MIDPOINT_SIZE / viewportScale} />
278
+ </mask>
279
+ {/if}
280
+ </defs>
45
281
 
46
282
  <polygon
47
283
  class="a9s-outer"
48
- style={computedStyle ? 'display:none;' : undefined}
284
+ mask={`url(#${maskId}-outer)`}
285
+ on:pointerup={onShapePointerUp}
49
286
  on:pointerdown={grab('SHAPE')}
50
287
  points={geom.points.map(xy => xy.join(',')).join(' ')} />
51
288
 
52
289
  <polygon
290
+ bind:this={polygonEl}
53
291
  class="a9s-inner a9s-shape-handle"
292
+ mask={`url(#${maskId}-inner)`}
54
293
  style={computedStyle}
294
+ on:pointermove={onPointerMove}
295
+ on:pointerup={onShapePointerUp}
55
296
  on:pointerdown={grab('SHAPE')}
56
297
  points={geom.points.map(xy => xy.join(',')).join(' ')} />
57
298
 
58
299
  {#each geom.points as point, idx}
59
300
  <Handle
301
+ class="a9s-corner-handle"
302
+ x={point[0]}
303
+ y={point[1]}
304
+ scale={viewportScale}
305
+ selected={selectedCorners.includes(idx)}
306
+ on:pointerenter={onEnterHandle}
307
+ on:pointerleave={onLeaveHandle}
308
+ on:pointerdown={onHandlePointerDown}
60
309
  on:pointerdown={grab(`HANDLE-${idx}`)}
61
- x={point[0]} y={point[1]}
62
- scale={viewportScale} />
310
+ on:pointerup={onHandlePointerUp(idx)} />
63
311
  {/each}
64
- </Editor>
312
+
313
+ {#if (visibleMidpoint !== undefined && !isHandleHovered)}
314
+ {@const { point } = midpoints[visibleMidpoint]}
315
+ <Midpoint
316
+ x={point[0]}
317
+ y={point[1]}
318
+ scale={viewportScale}
319
+ on:pointerdown={onAddPoint(visibleMidpoint)} />
320
+ {/if}
321
+ </Editor>
322
+
323
+ <style>
324
+ mask.a9s-polygon-editor-mask > rect {
325
+ fill: #fff;
326
+ }
327
+
328
+ mask.a9s-polygon-editor-mask > circle,
329
+ mask.a9s-polygon-editor-mask > polygon {
330
+ fill: #000;
331
+ }
332
+ </style>
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import Handle from '../Handle.svelte';
3
+ import { getMaskDimensions } from '../../utils';
3
4
  import type { Rectangle, Shape } from '../../../model';
4
5
  import type { Transform } from '../../Transform';
5
6
  import { Editor } from '..';
@@ -9,6 +10,7 @@
9
10
  export let computedStyle: string | undefined;
10
11
  export let transform: Transform;
11
12
  export let viewportScale: number = 1;
13
+ export let svgEl: SVGSVGElement;
12
14
 
13
15
  $: geom = shape.geometry;
14
16
 
@@ -77,20 +79,32 @@
77
79
  }
78
80
  };
79
81
  }
82
+
83
+ $: mask = getMaskDimensions(geom.bounds, 2 / viewportScale);
84
+
85
+ const maskId = `rect-mask-${Math.random().toString(36).substring(2, 12)}`;
80
86
  </script>
81
87
 
82
88
  <Editor
83
89
  shape={shape}
84
90
  transform={transform}
85
91
  editor={editor}
92
+ svgEl={svgEl}
86
93
  on:grab
87
94
  on:change
88
95
  on:release
89
96
  let:grab={grab}>
90
97
 
98
+ <defs>
99
+ <mask id={maskId} class="a9s-rectangle-editor-mask">
100
+ <rect class="rect-mask-bg" x={mask.x} y={mask.y} width={mask.w} height={mask.h} />
101
+ <rect class="rect-mask-fg" x={geom.x} y={geom.y} width={geom.w} height={geom.h} />
102
+ </mask>
103
+ </defs>
104
+
91
105
  <rect
92
106
  class="a9s-outer"
93
- style={computedStyle ? 'display:none;' : undefined}
107
+ mask={`url(#${maskId})`}
94
108
  on:pointerdown={grab('SHAPE')}
95
109
  x={geom.x} y={geom.y} width={geom.w} height={geom.h} />
96
110
 
@@ -143,4 +157,14 @@
143
157
  on:pointerdown={grab('BOTTOM_LEFT')}
144
158
  x={geom.x} y={geom.y + geom.h}
145
159
  scale={viewportScale} />
146
- </Editor>
160
+ </Editor>
161
+
162
+ <style>
163
+ mask.a9s-rectangle-editor-mask > rect.rect-mask-bg {
164
+ fill: #fff;
165
+ }
166
+
167
+ mask.a9s-rectangle-editor-mask > rect.rect-mask-fg {
168
+ fill: #000;
169
+ }
170
+ </style>
@@ -2,7 +2,7 @@
2
2
  import { onMount, createEventDispatcher } from 'svelte';
3
3
  import type { DrawingMode } from '../../../AnnotoriousOpts';
4
4
  import { boundsFromPoints, computeArea, ShapeType, type Polygon } from '../../../model';
5
- import { distance } from '../../utils';
5
+ import { distance, getMaskDimensions } from '../../utils';
6
6
  import type { Transform } from '../..';
7
7
 
8
8
  const dispatch = createEventDispatcher<{ create: Polygon }>();
@@ -30,7 +30,7 @@
30
30
 
31
31
  const TOUCH_PAUSE_LIMIT = 1500;
32
32
 
33
- $: handleSize = 10 / viewportScale;
33
+ $: handleRadius = 4 / viewportScale;
34
34
 
35
35
  const onPointerDown = (event: Event) => {
36
36
  const evt = event as PointerEvent;
@@ -167,26 +167,58 @@
167
167
  addEventListener('pointerup', onPointerUp, true);
168
168
  addEventListener('dblclick', onDblClick, true);
169
169
  });
170
+
171
+ $: coords = cursor ? (isClosable ? points : [...points, cursor]) : [];
172
+
173
+ $: mask = coords.length > 0 ? getMaskDimensions(boundsFromPoints(coords), 2 / viewportScale) : undefined;
174
+
175
+ const maskId = `polygon-mask-${Math.random().toString(36).substring(2, 12)}`;
170
176
  </script>
171
177
 
172
178
  <g class="a9s-annotation a9s-rubberband">
173
- {#if cursor}
174
- {@const coords = (isClosable ? points : [...points, cursor]).map(xy => xy.join(',')).join(' ')}
175
- <polygon
176
- class="a9s-outer"
177
- points={coords} />
178
-
179
- <polygon
180
- class="a9s-inner"
181
- points={coords} />
179
+ {#if mask}
180
+ {@const str = coords.map(xy => xy.join(',')).join(' ')}
181
+
182
+ <defs>
183
+ <mask id={maskId} class="a9s-rubberband-polygon-mask">
184
+ <rect x={mask.x} y={mask.y} width={mask.w} height={mask.h} />
185
+ <polygon points={str} />
186
+ </mask>
187
+ </defs>
188
+
189
+ <polygon
190
+ class="a9s-outer"
191
+ mask={`url(#${maskId})`}
192
+ points={str} />
193
+
194
+ <polygon
195
+ class="a9s-inner"
196
+ points={str} />
182
197
 
183
198
  {#if isClosable}
184
- <rect
199
+ <circle
185
200
  class="a9s-handle"
186
- x={points[0][0] - handleSize / 2}
187
- y={points[0][1] - handleSize / 2}
188
- height={handleSize}
189
- width={handleSize} />
201
+ cx={points[0][0]}
202
+ cy={points[0][1]}
203
+ r={handleRadius} />
190
204
  {/if}
191
205
  {/if}
192
- </g>
206
+ </g>
207
+
208
+ <style>
209
+ mask.a9s-rubberband-polygon-mask > rect {
210
+ fill: #fff;
211
+ }
212
+
213
+ mask.a9s-rubberband-polygon-mask > polygon {
214
+ fill: #000;
215
+ }
216
+
217
+ circle.a9s-handle {
218
+ fill: #fff;
219
+ pointer-events: none;
220
+ stroke: rgba(0, 0, 0, 0.35);
221
+ stroke-width: 1px;
222
+ vector-effect: non-scaling-stroke;
223
+ }
224
+ </style>
@@ -2,7 +2,7 @@
2
2
  import { createEventDispatcher, onMount } from 'svelte';
3
3
  import type { DrawingMode } from '../../../AnnotoriousOpts';
4
4
  import { ShapeType, type Rectangle } from '../../../model';
5
- import type { Transform } from '../..';
5
+ import { getMaskDimensions, type Transform } from '../..';
6
6
 
7
7
  const dispatch = createEventDispatcher<{ create: Rectangle }>();
8
8
 
@@ -10,6 +10,7 @@
10
10
  export let addEventListener: (type: string, fn: EventListener, capture?: boolean) => void;
11
11
  export let drawingMode: DrawingMode;
12
12
  export let transform: Transform;
13
+ export let viewportScale: number = 1;
13
14
 
14
15
  let lastPointerDown: number;
15
16
 
@@ -121,12 +122,35 @@
121
122
  addEventListener('pointermove', onPointerMove);
122
123
  addEventListener('pointerup', onPointerUp, true);
123
124
  });
125
+
126
+ const maskId = `rect-mask-${Math.random().toString(36).substring(2, 12)}`;
127
+
128
+ $: buffer = 2 / viewportScale;
124
129
  </script>
125
130
 
126
131
  <g class="a9s-annotation a9s-rubberband">
127
132
  {#if origin}
133
+ <defs>
134
+ <mask id={maskId} class="a9s-rubberband-rectangle-mask">
135
+ <rect
136
+ class="rect-mask-bg"
137
+ x={x - buffer}
138
+ y={y - buffer}
139
+ width={w + 2 * buffer}
140
+ height={h + 2 * buffer}/>
141
+
142
+ <rect
143
+ class="rect-mask-fg"
144
+ x={x}
145
+ y={y}
146
+ width={w}
147
+ height={h} />
148
+ </mask>
149
+ </defs>
150
+
128
151
  <rect
129
152
  class="a9s-outer"
153
+ mask={`url(#${maskId})`}
130
154
  x={x}
131
155
  y={y}
132
156
  width={w}
@@ -139,4 +163,14 @@
139
163
  width={w}
140
164
  height={h} />
141
165
  {/if}
142
- </g>
166
+ </g>
167
+
168
+ <style>
169
+ mask.a9s-rubberband-rectangle-mask > rect.rect-mask-bg {
170
+ fill: #fff;
171
+ }
172
+
173
+ mask.a9s-rubberband-rectangle-mask > rect.rect-mask-fg {
174
+ fill: #000;
175
+ }
176
+ </style>
@@ -1,3 +1,5 @@
1
1
  export * from './math';
2
2
  export * from './responsive';
3
+ export * from './styling';
4
+ export * from './svg';
3
5
  export * from './touch';
@@ -0,0 +1,11 @@
1
+ import type { Bounds } from '../../model';
2
+
3
+ export const getMaskDimensions = (bounds: Bounds, buffer: number = 0) => {
4
+ const { minX, minY, maxX, maxY } = bounds;
5
+ return {
6
+ x: minX - buffer,
7
+ y: minY - buffer,
8
+ w: maxX - minX + 2 * buffer,
9
+ h: maxY - minY + 2 * buffer
10
+ }
11
+ }
@@ -2,8 +2,8 @@
2
2
  .a9s-annotationlayer .a9s-outer,
3
3
  div[data-theme="light"] .a9s-annotationlayer .a9s-outer {
4
4
  display: block;
5
- stroke: rgba(0, 0, 0, 0.25);
6
- stroke-width: 3.5px;
5
+ stroke: rgba(0, 0, 0, 0.35);
6
+ stroke-width: 3px;
7
7
  }
8
8
 
9
9
  .a9s-annotationlayer .a9s-inner,