js-draw 1.6.1 → 1.7.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.
Files changed (60) hide show
  1. package/README.md +0 -2
  2. package/dist/Editor.css +30 -4
  3. package/dist/bundle.js +2 -2
  4. package/dist/bundledStyles.js +1 -1
  5. package/dist/cjs/Editor.d.ts +5 -0
  6. package/dist/cjs/Editor.js +53 -70
  7. package/dist/cjs/components/BackgroundComponent.js +6 -1
  8. package/dist/cjs/components/TextComponent.d.ts +1 -1
  9. package/dist/cjs/components/TextComponent.js +19 -12
  10. package/dist/cjs/localization.d.ts +2 -0
  11. package/dist/cjs/localization.js +2 -0
  12. package/dist/cjs/localizations/comments.js +1 -0
  13. package/dist/cjs/rendering/RenderablePathSpec.js +16 -1
  14. package/dist/cjs/rendering/caching/CacheRecordManager.d.ts +1 -0
  15. package/dist/cjs/rendering/caching/CacheRecordManager.js +18 -0
  16. package/dist/cjs/rendering/caching/RenderingCache.d.ts +1 -0
  17. package/dist/cjs/rendering/caching/RenderingCache.js +3 -0
  18. package/dist/cjs/rendering/renderers/CanvasRenderer.js +3 -2
  19. package/dist/cjs/tools/SelectionTool/Selection.d.ts +5 -4
  20. package/dist/cjs/tools/SelectionTool/Selection.js +75 -48
  21. package/dist/cjs/tools/SelectionTool/SelectionHandle.d.ts +2 -2
  22. package/dist/cjs/tools/SelectionTool/SelectionHandle.js +8 -3
  23. package/dist/cjs/tools/SelectionTool/SelectionTool.d.ts +3 -1
  24. package/dist/cjs/tools/SelectionTool/SelectionTool.js +36 -16
  25. package/dist/cjs/tools/SelectionTool/ToPointerAutoscroller.d.ts +23 -0
  26. package/dist/cjs/tools/SelectionTool/ToPointerAutoscroller.js +83 -0
  27. package/dist/cjs/tools/SelectionTool/TransformMode.d.ts +10 -3
  28. package/dist/cjs/tools/SelectionTool/TransformMode.js +52 -9
  29. package/dist/cjs/util/listenForKeyboardEventsFrom.d.ts +16 -0
  30. package/dist/cjs/util/listenForKeyboardEventsFrom.js +142 -0
  31. package/dist/cjs/version.js +1 -1
  32. package/dist/mjs/Editor.d.ts +5 -0
  33. package/dist/mjs/Editor.mjs +53 -70
  34. package/dist/mjs/components/BackgroundComponent.mjs +6 -1
  35. package/dist/mjs/components/TextComponent.d.ts +1 -1
  36. package/dist/mjs/components/TextComponent.mjs +19 -12
  37. package/dist/mjs/localization.d.ts +2 -0
  38. package/dist/mjs/localization.mjs +2 -0
  39. package/dist/mjs/localizations/comments.mjs +1 -0
  40. package/dist/mjs/rendering/RenderablePathSpec.mjs +16 -1
  41. package/dist/mjs/rendering/caching/CacheRecordManager.d.ts +1 -0
  42. package/dist/mjs/rendering/caching/CacheRecordManager.mjs +18 -0
  43. package/dist/mjs/rendering/caching/RenderingCache.d.ts +1 -0
  44. package/dist/mjs/rendering/caching/RenderingCache.mjs +3 -0
  45. package/dist/mjs/rendering/renderers/CanvasRenderer.mjs +3 -2
  46. package/dist/mjs/tools/SelectionTool/Selection.d.ts +5 -4
  47. package/dist/mjs/tools/SelectionTool/Selection.mjs +75 -48
  48. package/dist/mjs/tools/SelectionTool/SelectionHandle.d.ts +2 -2
  49. package/dist/mjs/tools/SelectionTool/SelectionHandle.mjs +8 -3
  50. package/dist/mjs/tools/SelectionTool/SelectionTool.d.ts +3 -1
  51. package/dist/mjs/tools/SelectionTool/SelectionTool.mjs +36 -16
  52. package/dist/mjs/tools/SelectionTool/ToPointerAutoscroller.d.ts +23 -0
  53. package/dist/mjs/tools/SelectionTool/ToPointerAutoscroller.mjs +77 -0
  54. package/dist/mjs/tools/SelectionTool/TransformMode.d.ts +10 -3
  55. package/dist/mjs/tools/SelectionTool/TransformMode.mjs +52 -9
  56. package/dist/mjs/util/listenForKeyboardEventsFrom.d.ts +16 -0
  57. package/dist/mjs/util/listenForKeyboardEventsFrom.mjs +140 -0
  58. package/dist/mjs/version.mjs +1 -1
  59. package/package.json +4 -4
  60. package/src/tools/SelectionTool/SelectionTool.scss +62 -9
@@ -40,4 +40,22 @@ export class CacheRecordManager {
40
40
  this.cacheRecords.sort((a, b) => a.getLastUsedCycle() - b.getLastUsedCycle());
41
41
  return this.cacheRecords[0];
42
42
  }
43
+ // Returns information to (hopefully) help debug performance issues
44
+ getDebugInfo() {
45
+ let numberAllocd = 0;
46
+ let averageReassignedCount = 0;
47
+ for (const cacheRecord of this.cacheRecords) {
48
+ averageReassignedCount += cacheRecord.allocCount;
49
+ if (cacheRecord.isAllocd()) {
50
+ numberAllocd++;
51
+ }
52
+ }
53
+ averageReassignedCount /= Math.max(this.cacheRecords.length, 0);
54
+ const debugInfo = [
55
+ `${this.cacheRecords.length} cache records (max ${this.maxCanvases})`,
56
+ `${numberAllocd} assigned to screen regions`,
57
+ `Average number of times reassigned: ${Math.round(averageReassignedCount * 100) / 100}`,
58
+ ];
59
+ return debugInfo.join('\n');
60
+ }
43
61
  }
@@ -8,4 +8,5 @@ export default class RenderingCache {
8
8
  private rootNode;
9
9
  constructor(cacheProps: CacheProps);
10
10
  render(screenRenderer: AbstractRenderer, image: ImageNode, viewport: Viewport): void;
11
+ getDebugInfo(): string;
11
12
  }
@@ -41,4 +41,7 @@ export default class RenderingCache {
41
41
  image.render(screenRenderer, visibleRect);
42
42
  }
43
43
  }
44
+ getDebugInfo() {
45
+ return this.recordManager.getDebugInfo();
46
+ }
44
47
  }
@@ -144,9 +144,10 @@ export default class CanvasRenderer extends AbstractRenderer {
144
144
  return;
145
145
  }
146
146
  // If part of a huge object, it might be worth trimming the path
147
- if (this.currentObjectBBox?.containsRect(this.getViewport().visibleRect)) {
147
+ const visibleRect = this.getViewport().visibleRect;
148
+ if (this.currentObjectBBox?.containsRect(visibleRect)) {
148
149
  // Try to trim/remove parts of the path outside of the bounding box.
149
- path = visualEquivalent(path, this.getViewport().visibleRect);
150
+ path = visualEquivalent(path, visibleRect);
150
151
  }
151
152
  super.drawPath(path);
152
153
  }
@@ -15,7 +15,8 @@ export default class Selection {
15
15
  private transformers;
16
16
  private transform;
17
17
  private selectedElems;
18
- private container;
18
+ private outerContainer;
19
+ private innerContainer;
19
20
  private backgroundElem;
20
21
  private hasParent;
21
22
  constructor(startPoint: Point2, editor: Editor);
@@ -32,10 +33,10 @@ export default class Selection {
32
33
  get regionRotation(): number;
33
34
  get preTransformedScreenRegion(): Rect2;
34
35
  get preTransformedScreenRegionRotation(): number;
35
- get screenRegion(): Rect2;
36
+ getScreenRegion(): Rect2;
36
37
  get screenRegionRotation(): number;
37
38
  setTransform(transform: Mat33, preview?: boolean): void;
38
- finalizeTransform(): Promise<void>;
39
+ finalizeTransform(): void | Promise<void>;
39
40
  private static ApplyTransformationCommand;
40
41
  private previewTransformCmds;
41
42
  resolveToObjects(): boolean;
@@ -53,7 +54,7 @@ export default class Selection {
53
54
  onDragUpdate(pointer: Pointer): void;
54
55
  onDragEnd(): void;
55
56
  onDragCancel(): void;
56
- scrollTo(): Promise<void>;
57
+ scrollTo(): boolean;
57
58
  deleteSelectedObjects(): Command;
58
59
  private selectionDuplicatedAnimationTimeout;
59
60
  private runSelectionDuplicatedAnimation;
@@ -36,22 +36,34 @@ class Selection {
36
36
  resize: new ResizeTransformer(editor, this),
37
37
  rotate: new RotateTransformer(editor, this),
38
38
  };
39
- this.container = document.createElement('div');
39
+ // We need two containers for some CSS to apply (the outer container
40
+ // needs zero height, the inner needs to prevent the selection background
41
+ // from being visible outside of the editor).
42
+ this.outerContainer = document.createElement('div');
43
+ this.outerContainer.classList.add(`${cssPrefix}selection-outer-container`);
44
+ this.innerContainer = document.createElement('div');
45
+ this.innerContainer.classList.add(`${cssPrefix}selection-inner-container`);
40
46
  this.backgroundElem = document.createElement('div');
41
47
  this.backgroundElem.classList.add(`${cssPrefix}selection-background`);
42
- this.container.appendChild(this.backgroundElem);
43
- const resizeHorizontalHandle = new SelectionHandle({
44
- action: HandleAction.ResizeX,
45
- side: Vec2.of(1, 0.5),
46
- }, this, this.editor.viewport, (startPoint) => this.transformers.resize.onDragStart(startPoint, ResizeMode.HorizontalOnly), (currentPoint) => this.transformers.resize.onDragUpdate(currentPoint), () => this.transformers.resize.onDragEnd());
47
- const resizeVerticalHandle = new SelectionHandle({
48
- action: HandleAction.ResizeY,
49
- side: Vec2.of(0.5, 1),
50
- }, this, this.editor.viewport, (startPoint) => this.transformers.resize.onDragStart(startPoint, ResizeMode.VerticalOnly), (currentPoint) => this.transformers.resize.onDragUpdate(currentPoint), () => this.transformers.resize.onDragEnd());
51
- const resizeBothHandle = new SelectionHandle({
52
- action: HandleAction.ResizeXY,
53
- side: Vec2.of(1, 1),
54
- }, this, this.editor.viewport, (startPoint) => this.transformers.resize.onDragStart(startPoint, ResizeMode.Both), (currentPoint) => this.transformers.resize.onDragUpdate(currentPoint), () => this.transformers.resize.onDragEnd());
48
+ this.innerContainer.appendChild(this.backgroundElem);
49
+ this.outerContainer.appendChild(this.innerContainer);
50
+ const makeResizeHandle = (mode, side) => {
51
+ const modeToAction = {
52
+ [ResizeMode.Both]: HandleAction.ResizeXY,
53
+ [ResizeMode.HorizontalOnly]: HandleAction.ResizeX,
54
+ [ResizeMode.VerticalOnly]: HandleAction.ResizeY,
55
+ };
56
+ return new SelectionHandle({
57
+ action: modeToAction[mode],
58
+ side,
59
+ }, this, this.editor.viewport, (startPoint) => this.transformers.resize.onDragStart(startPoint, mode), (currentPoint) => this.transformers.resize.onDragUpdate(currentPoint), () => this.transformers.resize.onDragEnd());
60
+ };
61
+ const resizeHorizontalHandles = [
62
+ makeResizeHandle(ResizeMode.HorizontalOnly, Vec2.of(0, 0.5)),
63
+ makeResizeHandle(ResizeMode.HorizontalOnly, Vec2.of(1, 0.5)),
64
+ ];
65
+ const resizeVerticalHandle = makeResizeHandle(ResizeMode.VerticalOnly, Vec2.of(0.5, 1));
66
+ const resizeBothHandle = makeResizeHandle(ResizeMode.Both, Vec2.of(1, 1));
55
67
  const rotationHandle = new SelectionHandle({
56
68
  action: HandleAction.Rotate,
57
69
  side: Vec2.of(0.5, 0),
@@ -59,7 +71,7 @@ class Selection {
59
71
  }, this, this.editor.viewport, (startPoint) => this.transformers.rotate.onDragStart(startPoint), (currentPoint) => this.transformers.rotate.onDragUpdate(currentPoint), () => this.transformers.rotate.onDragEnd());
60
72
  this.handles = [
61
73
  resizeBothHandle,
62
- resizeHorizontalHandle,
74
+ ...resizeHorizontalHandles,
63
75
  resizeVerticalHandle,
64
76
  rotationHandle,
65
77
  ];
@@ -105,7 +117,7 @@ class Selection {
105
117
  get preTransformedScreenRegionRotation() {
106
118
  return this.editor.viewport.getRotationAngle();
107
119
  }
108
- get screenRegion() {
120
+ getScreenRegion() {
109
121
  const toScreen = this.editor.viewport.canvasToScreenTransform;
110
122
  const scaleFactor = this.editor.viewport.getScaleFactor();
111
123
  const screenCenter = toScreen.transformVec2(this.region.center);
@@ -118,29 +130,33 @@ class Selection {
118
130
  setTransform(transform, preview = true) {
119
131
  this.transform = transform;
120
132
  if (preview && this.hasParent) {
121
- this.scrollTo();
122
133
  this.previewTransformCmds();
123
134
  }
124
135
  }
125
136
  // Applies the current transformation to the selection
126
- async finalizeTransform() {
137
+ finalizeTransform() {
127
138
  const fullTransform = this.transform;
128
139
  const selectedElems = this.selectedElems;
129
140
  // Reset for the next drag
130
141
  this.originalRegion = this.originalRegion.transformedBoundingBox(this.transform);
131
142
  this.transform = Mat33.identity;
143
+ this.scrollTo();
132
144
  // Make the commands undo-able.
133
145
  // Don't check for non-empty transforms because this breaks changing the
134
146
  // z-index of the just-transformed commands.
135
147
  //
136
148
  // TODO: Check whether the selectedElems are already all toplevel.
137
- await this.editor.dispatch(new _a.ApplyTransformationCommand(this, selectedElems, fullTransform));
149
+ const transformPromise = this.editor.dispatch(new _a.ApplyTransformationCommand(this, selectedElems, fullTransform));
138
150
  // Clear renderings of any in-progress transformations
139
151
  const wetInkRenderer = this.editor.display.getWetInkRenderer();
140
152
  wetInkRenderer.clear();
153
+ return transformPromise;
141
154
  }
142
155
  // Preview the effects of the current transformation on the selection
143
156
  previewTransformCmds() {
157
+ if (this.selectedElems.length === 0) {
158
+ return;
159
+ }
144
160
  // Don't render what we're moving if it's likely to be slow.
145
161
  if (this.selectedElems.length > maxPreviewElemCount) {
146
162
  this.updateUI();
@@ -221,28 +237,29 @@ class Selection {
221
237
  if (!this.hasParent) {
222
238
  return;
223
239
  }
240
+ const screenRegion = this.getScreenRegion();
224
241
  // marginLeft, marginTop: Display relative to the top left of the selection overlay.
225
242
  // left, top don't work for this.
226
- this.backgroundElem.style.marginLeft = `${this.screenRegion.topLeft.x}px`;
227
- this.backgroundElem.style.marginTop = `${this.screenRegion.topLeft.y}px`;
228
- this.backgroundElem.style.width = `${this.screenRegion.width}px`;
229
- this.backgroundElem.style.height = `${this.screenRegion.height}px`;
243
+ this.backgroundElem.style.marginLeft = `${screenRegion.topLeft.x}px`;
244
+ this.backgroundElem.style.marginTop = `${screenRegion.topLeft.y}px`;
245
+ this.backgroundElem.style.width = `${screenRegion.width}px`;
246
+ this.backgroundElem.style.height = `${screenRegion.height}px`;
230
247
  const rotationDeg = this.screenRegionRotation * 180 / Math.PI;
231
248
  this.backgroundElem.style.transform = `rotate(${rotationDeg}deg)`;
232
249
  this.backgroundElem.style.transformOrigin = 'center';
233
250
  // If closer to perpendicular, apply different CSS
234
251
  const perpendicularClassName = `${cssPrefix}rotated-near-perpendicular`;
235
252
  if (Math.abs(Math.sin(this.screenRegionRotation)) > 0.5) {
236
- this.container.classList.add(perpendicularClassName);
253
+ this.innerContainer.classList.add(perpendicularClassName);
237
254
  }
238
255
  else {
239
- this.container.classList.remove(perpendicularClassName);
256
+ this.innerContainer.classList.remove(perpendicularClassName);
240
257
  }
241
258
  for (const handle of this.handles) {
242
259
  handle.updatePosition();
243
260
  }
244
261
  }
245
- // Add/remove the contents of this' seleciton from the editor.
262
+ // Add/remove the contents of this seleciton from the editor.
246
263
  // Used to prevent previewed content from looking like duplicate content
247
264
  // while dragging.
248
265
  //
@@ -250,6 +267,9 @@ class Selection {
250
267
  // the editor image is likely to be slow.)
251
268
  //
252
269
  // If removed from the image, selected elements are drawn as wet ink.
270
+ //
271
+ // [inImage] should be `true` if the selected elements should be added to the
272
+ // main image, `false` if they should be removed.
253
273
  addRemoveSelectionFromImage(inImage) {
254
274
  // Don't hide elements if doing so will be slow.
255
275
  if (!inImage && this.selectedElems.length > maxPreviewElemCount) {
@@ -292,17 +312,18 @@ class Selection {
292
312
  document.getSelection()?.removeAllRanges();
293
313
  this.targetHandle = null;
294
314
  let result = false;
315
+ this.backgroundDragging = false;
316
+ if (this.region.containsPoint(pointer.canvasPos)) {
317
+ this.backgroundDragging = true;
318
+ result = true;
319
+ }
295
320
  for (const handle of this.handles) {
296
321
  if (handle.containsPoint(pointer.canvasPos)) {
297
322
  this.targetHandle = handle;
323
+ this.backgroundDragging = false;
298
324
  result = true;
299
325
  }
300
326
  }
301
- this.backgroundDragging = false;
302
- if (this.region.containsPoint(pointer.canvasPos)) {
303
- this.backgroundDragging = true;
304
- result = true;
305
- }
306
327
  if (result) {
307
328
  this.removeDeletedElemsFromSelection();
308
329
  this.addRemoveSelectionFromImage(false);
@@ -340,23 +361,29 @@ class Selection {
340
361
  this.targetHandle = null;
341
362
  this.setTransform(Mat33.identity);
342
363
  this.addRemoveSelectionFromImage(true);
364
+ this.updateUI();
343
365
  }
344
366
  // Scroll the viewport to this. Does not zoom
345
- async scrollTo() {
367
+ scrollTo() {
346
368
  if (this.selectedElems.length === 0) {
347
- return;
369
+ return false;
348
370
  }
349
- const screenRect = new Rect2(0, 0, this.editor.display.width, this.editor.display.height);
350
- if (!screenRect.containsPoint(this.screenRegion.center)) {
351
- const closestPoint = screenRect.getClosestPointOnBoundaryTo(this.screenRegion.center);
352
- const screenDelta = this.screenRegion.center.minus(closestPoint);
353
- const delta = this.editor.viewport.screenToCanvasTransform.transformVec3(screenDelta);
354
- await this.editor.dispatchNoAnnounce(Viewport.transformBy(Mat33.translation(delta.times(-1))), false);
355
- // Re-renders clear wet ink, so we need to re-draw the preview
356
- // after the full re-render.
357
- await this.editor.queueRerender();
358
- this.previewTransformCmds();
371
+ const screenSize = this.editor.viewport.getScreenRectSize();
372
+ const screenRect = new Rect2(0, 0, screenSize.x, screenSize.y);
373
+ const selectionScreenRegion = this.getScreenRegion();
374
+ if (!screenRect.containsPoint(selectionScreenRegion.center)) {
375
+ const targetPointScreen = selectionScreenRegion.center;
376
+ const closestPointScreen = screenRect.getClosestPointOnBoundaryTo(targetPointScreen);
377
+ const closestPointCanvas = this.editor.viewport.screenToCanvas(closestPointScreen);
378
+ const targetPointCanvas = this.region.center;
379
+ const delta = closestPointCanvas.minus(targetPointCanvas);
380
+ this.editor.dispatchNoAnnounce(Viewport.transformBy(Mat33.translation(delta.times(0.5))), false);
381
+ this.editor.queueRerender().then(() => {
382
+ this.previewTransformCmds();
383
+ });
384
+ return true;
359
385
  }
386
+ return false;
360
387
  }
361
388
  deleteSelectedObjects() {
362
389
  if (this.backgroundDragging || this.targetHandle) {
@@ -401,10 +428,10 @@ class Selection {
401
428
  return duplicateCommand;
402
429
  }
403
430
  addTo(elem) {
404
- if (this.container.parentElement) {
405
- this.container.remove();
431
+ if (this.outerContainer.parentElement) {
432
+ this.outerContainer.remove();
406
433
  }
407
- elem.appendChild(this.container);
434
+ elem.appendChild(this.outerContainer);
408
435
  this.hasParent = true;
409
436
  }
410
437
  setToPoint(point) {
@@ -413,8 +440,8 @@ class Selection {
413
440
  this.updateUI();
414
441
  }
415
442
  cancelSelection() {
416
- if (this.container.parentElement) {
417
- this.container.remove();
443
+ if (this.outerContainer.parentElement) {
444
+ this.outerContainer.remove();
418
445
  }
419
446
  this.originalRegion = Rect2.empty;
420
447
  this.selectionTightBoundingBox = null;
@@ -16,7 +16,7 @@ export interface HandlePresentation {
16
16
  export declare const handleSize = 30;
17
17
  export type DragStartCallback = (startPoint: Point2) => void;
18
18
  export type DragUpdateCallback = (canvasPoint: Point2) => void;
19
- export type DragEndCallback = () => void;
19
+ export type DragEndCallback = () => Promise<void> | void;
20
20
  export default class SelectionHandle {
21
21
  readonly presentation: HandlePresentation;
22
22
  private readonly parent;
@@ -50,7 +50,7 @@ export default class SelectionHandle {
50
50
  private dragLastPos;
51
51
  handleDragStart(pointer: Pointer): void;
52
52
  handleDragUpdate(pointer: Pointer): void;
53
- handleDragEnd(): void;
53
+ handleDragEnd(): void | Promise<void>;
54
54
  setSnapToGrid(snap: boolean): void;
55
55
  isSnappingToGrid(): boolean;
56
56
  }
@@ -13,6 +13,7 @@ export var HandleAction;
13
13
  HandleAction["ResizeX"] = "resize-x";
14
14
  HandleAction["ResizeY"] = "resize-y";
15
15
  })(HandleAction || (HandleAction = {}));
16
+ // The *interactable* handle size. The visual size will be slightly smaller.
16
17
  export const handleSize = 30;
17
18
  export default class SelectionHandle {
18
19
  constructor(presentation, parent, viewport, onDragStart, onDragUpdate, onDragEnd) {
@@ -25,10 +26,14 @@ export default class SelectionHandle {
25
26
  this.dragLastPos = null;
26
27
  this.element = document.createElement('div');
27
28
  this.element.classList.add(`${cssPrefix}handle`, `${cssPrefix}${presentation.action}`);
29
+ // Create a slightly smaller content/background element.
30
+ const visibleContent = document.createElement('div');
31
+ visibleContent.classList.add(`${cssPrefix}content`);
32
+ this.element.appendChild(visibleContent);
28
33
  this.parentSide = presentation.side;
29
34
  const icon = presentation.icon;
30
35
  if (icon) {
31
- this.element.appendChild(icon);
36
+ visibleContent.appendChild(icon);
32
37
  icon.classList.add('icon');
33
38
  }
34
39
  if (presentation.action === HandleAction.Rotate) {
@@ -61,7 +66,7 @@ export default class SelectionHandle {
61
66
  * selection box.
62
67
  */
63
68
  getBBoxParentCoords() {
64
- const parentRect = this.parent.screenRegion;
69
+ const parentRect = this.parent.getScreenRegion();
65
70
  const size = Vec2.of(handleSize, handleSize);
66
71
  const topLeft = parentRect.size.scale(this.parentSide)
67
72
  // Center
@@ -115,7 +120,7 @@ export default class SelectionHandle {
115
120
  if (!this.dragLastPos) {
116
121
  return;
117
122
  }
118
- this.onDragEnd();
123
+ return this.onDragEnd();
119
124
  }
120
125
  setSnapToGrid(snap) {
121
126
  this.snapToGrid = snap;
@@ -13,13 +13,15 @@ export default class SelectionTool extends BaseTool {
13
13
  private expandingSelectionBox;
14
14
  private shiftKeyPressed;
15
15
  private snapToGrid;
16
+ private lastPointer;
17
+ private autoscroller;
16
18
  constructor(editor: Editor, description: string);
17
19
  private makeSelectionBox;
18
20
  private snapSelectionToGrid;
19
21
  private selectionBoxHandlingEvt;
20
22
  onPointerDown({ allPointers, current }: PointerEvt): boolean;
21
23
  onPointerMove(event: PointerEvt): void;
22
- private onGestureEnd;
24
+ private onMainPointerUpdated;
23
25
  onPointerUp(event: PointerEvt): void;
24
26
  onGestureCancel(): void;
25
27
  private lastSelectedObjects;
@@ -6,6 +6,7 @@ import SVGRenderer from '../../rendering/renderers/SVGRenderer.mjs';
6
6
  import Selection from './Selection.mjs';
7
7
  import TextComponent from '../../components/TextComponent.mjs';
8
8
  import { duplicateSelectionShortcut, selectAllKeyboardShortcut, snapToGridKeyboardShortcutId } from '../keybindings.mjs';
9
+ import ToPointerAutoscroller from './ToPointerAutoscroller.mjs';
9
10
  export const cssPrefix = 'selection-tool-';
10
11
  // Allows users to select/transform portions of the `EditorImage`.
11
12
  // With respect to `extend`ing, `SelectionTool` is not stable.
@@ -17,8 +18,19 @@ class SelectionTool extends BaseTool {
17
18
  this.expandingSelectionBox = false;
18
19
  this.shiftKeyPressed = false;
19
20
  this.snapToGrid = false;
21
+ this.lastPointer = null;
20
22
  this.selectionBoxHandlingEvt = false;
21
23
  this.lastSelectedObjects = [];
24
+ this.autoscroller = new ToPointerAutoscroller(editor.viewport, (scrollBy) => {
25
+ editor.dispatch(Viewport.transformBy(Mat33.translation(scrollBy)), false);
26
+ // Update the selection box/content to match the new viewport.
27
+ if (this.lastPointer) {
28
+ // The viewport has changed -- ensure that the screen and canvas positions
29
+ // of the pointer are both correct
30
+ const updatedPointer = this.lastPointer.withScreenPosition(this.lastPointer.screenPos, editor.viewport);
31
+ this.onMainPointerUpdated(updatedPointer);
32
+ }
33
+ });
22
34
  this.handleOverlay = document.createElement('div');
23
35
  editor.createHTMLOverlay(this.handleOverlay);
24
36
  this.handleOverlay.style.display = 'none';
@@ -81,14 +93,22 @@ class SelectionTool extends BaseTool {
81
93
  this.expandingSelectionBox = this.shiftKeyPressed;
82
94
  this.makeSelectionBox(current.canvasPos);
83
95
  }
96
+ else {
97
+ // Only autoscroll if we're transforming an existing selection
98
+ this.autoscroller.start();
99
+ }
84
100
  return true;
85
101
  }
86
102
  return false;
87
103
  }
88
104
  onPointerMove(event) {
105
+ this.onMainPointerUpdated(event.current);
106
+ }
107
+ onMainPointerUpdated(currentPointer) {
108
+ this.lastPointer = currentPointer;
89
109
  if (!this.selectionBox)
90
110
  return;
91
- let currentPointer = event.current;
111
+ this.autoscroller.onPointerMove(currentPointer.screenPos);
92
112
  if (!this.expandingSelectionBox && this.shiftKeyPressed && this.startPoint) {
93
113
  const screenPos = this.editor.viewport.canvasToScreen(this.startPoint);
94
114
  currentPointer = currentPointer.lockedToXYAxesScreen(screenPos, this.editor.viewport);
@@ -103,21 +123,8 @@ class SelectionTool extends BaseTool {
103
123
  this.selectionBox.setToPoint(currentPointer.canvasPos);
104
124
  }
105
125
  }
106
- // Called after a gestureCancel and a pointerUp
107
- onGestureEnd() {
108
- if (!this.selectionBox)
109
- return;
110
- if (!this.selectionBoxHandlingEvt) {
111
- // Expand/shrink the selection rectangle, if applicable
112
- this.selectionBox.resolveToObjects();
113
- this.onSelectionUpdated();
114
- }
115
- else {
116
- this.selectionBox.onDragEnd();
117
- }
118
- this.selectionBoxHandlingEvt = false;
119
- }
120
126
  onPointerUp(event) {
127
+ this.autoscroller.stop();
121
128
  if (!this.selectionBox)
122
129
  return;
123
130
  let currentPointer = event.current;
@@ -136,10 +143,20 @@ class SelectionTool extends BaseTool {
136
143
  ]);
137
144
  }
138
145
  else {
139
- this.onGestureEnd();
146
+ if (!this.selectionBoxHandlingEvt) {
147
+ // Expand/shrink the selection rectangle, if applicable
148
+ this.selectionBox.resolveToObjects();
149
+ this.onSelectionUpdated();
150
+ }
151
+ else {
152
+ this.selectionBox.onDragEnd();
153
+ }
154
+ this.selectionBoxHandlingEvt = false;
155
+ this.lastPointer = null;
140
156
  }
141
157
  }
142
158
  onGestureCancel() {
159
+ this.autoscroller.stop();
143
160
  if (this.selectionBoxHandlingEvt) {
144
161
  this.selectionBox?.onDragCancel();
145
162
  }
@@ -152,6 +169,8 @@ class SelectionTool extends BaseTool {
152
169
  this.prevSelectionBox = null;
153
170
  }
154
171
  this.expandingSelectionBox = false;
172
+ this.lastPointer = null;
173
+ this.selectionBoxHandlingEvt = false;
155
174
  }
156
175
  onSelectionUpdated() {
157
176
  const selectedItemCount = this.selectionBox?.getSelectedItemCount() ?? 0;
@@ -277,6 +296,7 @@ class SelectionTool extends BaseTool {
277
296
  const transform = Mat33.scaling2D(scaleFactor, this.editor.viewport.roundPoint(region.topLeft)).rightMul(Mat33.translation(regionCenter).rightMul(roundedRotationMatrix).rightMul(Mat33.translation(regionCenter.times(-1)))).rightMul(Mat33.translation(this.editor.viewport.roundPoint(Vec2.of(xTranslateSteps, yTranslateSteps).times(translateStepSize))));
278
297
  const oldTransform = this.selectionBox.getTransform();
279
298
  this.selectionBox.setTransform(oldTransform.rightMul(transform));
299
+ this.selectionBox.scrollTo();
280
300
  }
281
301
  if (this.selectionBox && !handled && (event.key === 'Delete' || event.key === 'Backspace')) {
282
302
  this.editor.dispatch(this.selectionBox.deleteSelectedObjects());
@@ -0,0 +1,23 @@
1
+ import { Point2, Vec2 } from '@js-draw/math';
2
+ import Viewport from '../../Viewport';
3
+ type ScrollByCallback = (delta: Vec2) => void;
4
+ /**
5
+ * Automatically scrolls the viewport such that the user's pointer is visible.
6
+ */
7
+ export default class ToPointerAutoscroller {
8
+ private viewport;
9
+ private scrollByCanvasDelta;
10
+ private started;
11
+ private updateLoopId;
12
+ private updateLoopRunning;
13
+ private targetPoint;
14
+ private scrollRate;
15
+ constructor(viewport: Viewport, scrollByCanvasDelta: ScrollByCallback);
16
+ private getScrollForPoint;
17
+ start(): void;
18
+ onPointerMove(pointerScreenPosition: Point2): void;
19
+ stop(): void;
20
+ private startUpdateLoop;
21
+ private stopUpdateLoop;
22
+ }
23
+ export {};
@@ -0,0 +1,77 @@
1
+ import { Rect2, Vec2 } from '@js-draw/math';
2
+ import untilNextAnimationFrame from '../../util/untilNextAnimationFrame.mjs';
3
+ /**
4
+ * Automatically scrolls the viewport such that the user's pointer is visible.
5
+ */
6
+ export default class ToPointerAutoscroller {
7
+ constructor(viewport, scrollByCanvasDelta) {
8
+ this.viewport = viewport;
9
+ this.scrollByCanvasDelta = scrollByCanvasDelta;
10
+ this.started = false;
11
+ this.updateLoopId = 0;
12
+ this.updateLoopRunning = false;
13
+ this.targetPoint = null;
14
+ this.scrollRate = 1000; // px/s
15
+ }
16
+ getScrollForPoint(screenPoint) {
17
+ const screenSize = this.viewport.getScreenRectSize();
18
+ const screenRect = new Rect2(0, 0, screenSize.x, screenSize.y);
19
+ // Starts autoscrolling when the cursor is **outside of** this region
20
+ const marginSize = 44;
21
+ const autoscrollBoundary = screenRect.grownBy(-marginSize);
22
+ if (autoscrollBoundary.containsPoint(screenPoint)) {
23
+ return Vec2.zero;
24
+ }
25
+ const closestEdgePoint = autoscrollBoundary.getClosestPointOnBoundaryTo(screenPoint);
26
+ const distToEdge = closestEdgePoint.minus(screenPoint).magnitude();
27
+ const toEdge = closestEdgePoint.minus(screenPoint);
28
+ // Go faster for points further away from the boundary.
29
+ const maximumScaleFactor = 1.25;
30
+ const scaleFactor = Math.min(distToEdge / marginSize, maximumScaleFactor);
31
+ return toEdge.normalizedOrZero().times(scaleFactor);
32
+ }
33
+ start() {
34
+ this.started = true;
35
+ }
36
+ onPointerMove(pointerScreenPosition) {
37
+ if (!this.started) {
38
+ return;
39
+ }
40
+ if (this.getScrollForPoint(pointerScreenPosition) === Vec2.zero) {
41
+ this.stopUpdateLoop();
42
+ }
43
+ else {
44
+ this.targetPoint = pointerScreenPosition;
45
+ this.startUpdateLoop();
46
+ }
47
+ }
48
+ stop() {
49
+ this.targetPoint = null;
50
+ this.started = false;
51
+ this.stopUpdateLoop();
52
+ }
53
+ startUpdateLoop() {
54
+ if (this.updateLoopRunning) {
55
+ return;
56
+ }
57
+ (async () => {
58
+ this.updateLoopId++;
59
+ const currentUpdateLoopId = this.updateLoopId;
60
+ let lastUpdateTime = performance.now();
61
+ while (this.updateLoopId === currentUpdateLoopId && this.targetPoint) {
62
+ this.updateLoopRunning = true;
63
+ const currentTime = performance.now();
64
+ const deltaTimeMs = currentTime - lastUpdateTime;
65
+ const scrollDirection = this.getScrollForPoint(this.targetPoint);
66
+ const screenScrollAmount = scrollDirection.times(this.scrollRate * deltaTimeMs / 1000);
67
+ this.scrollByCanvasDelta(this.viewport.screenToCanvasTransform.transformVec3(screenScrollAmount));
68
+ lastUpdateTime = currentTime;
69
+ await untilNextAnimationFrame();
70
+ }
71
+ this.updateLoopRunning = false;
72
+ })();
73
+ }
74
+ stopUpdateLoop() {
75
+ this.updateLoopId++;
76
+ }
77
+ }
@@ -9,26 +9,33 @@ export declare class DragTransformer {
9
9
  constructor(editor: Editor, selection: Selection);
10
10
  onDragStart(startPoint: Vec3): void;
11
11
  onDragUpdate(canvasPos: Vec3): void;
12
- onDragEnd(): void;
12
+ onDragEnd(): void | Promise<void>;
13
13
  }
14
14
  export declare class ResizeTransformer {
15
15
  private readonly editor;
16
16
  private selection;
17
17
  private mode;
18
18
  private dragStartPoint;
19
+ private transformOrigin;
20
+ private scaleRate;
19
21
  constructor(editor: Editor, selection: Selection);
20
22
  onDragStart(startPoint: Vec3, mode: ResizeMode): void;
23
+ private computeOriginAndScaleRate;
21
24
  onDragUpdate(canvasPos: Vec3): void;
22
- onDragEnd(): void;
25
+ onDragEnd(): void | Promise<void>;
23
26
  }
24
27
  export declare class RotateTransformer {
25
28
  private readonly editor;
26
29
  private selection;
27
30
  private startAngle;
31
+ private targetRotation;
32
+ private maximumDistFromStart;
33
+ private startPoint;
28
34
  constructor(editor: Editor, selection: Selection);
29
35
  private getAngle;
30
36
  private roundAngle;
31
37
  onDragStart(startPoint: Vec3): void;
38
+ private setRotationTo;
32
39
  onDragUpdate(canvasPos: Vec3): void;
33
- onDragEnd(): void;
40
+ onDragEnd(): void | Promise<void>;
34
41
  }