@annotorious/annotorious 3.3.6 → 3.4.0

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