js-draw 0.11.2 → 0.12.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 (49) hide show
  1. package/.github/workflows/github-pages.yml +2 -0
  2. package/CHANGELOG.md +12 -0
  3. package/dist/bundle.js +1 -1
  4. package/dist/src/Color4.d.ts +1 -0
  5. package/dist/src/Color4.js +1 -0
  6. package/dist/src/Editor.js +4 -5
  7. package/dist/src/EditorImage.js +1 -11
  8. package/dist/src/SVGLoader.js +43 -35
  9. package/dist/src/components/AbstractComponent.d.ts +1 -0
  10. package/dist/src/components/AbstractComponent.js +15 -0
  11. package/dist/src/math/Rect2.d.ts +1 -0
  12. package/dist/src/math/Rect2.js +20 -0
  13. package/dist/src/toolbar/HTMLToolbar.d.ts +51 -0
  14. package/dist/src/toolbar/HTMLToolbar.js +63 -5
  15. package/dist/src/toolbar/IconProvider.d.ts +1 -1
  16. package/dist/src/toolbar/IconProvider.js +38 -9
  17. package/dist/src/toolbar/widgets/EraserToolWidget.d.ts +8 -1
  18. package/dist/src/toolbar/widgets/EraserToolWidget.js +45 -4
  19. package/dist/src/toolbar/widgets/PenToolWidget.js +2 -2
  20. package/dist/src/toolbar/widgets/SelectionToolWidget.js +12 -3
  21. package/dist/src/tools/Eraser.d.ts +10 -1
  22. package/dist/src/tools/Eraser.js +65 -13
  23. package/dist/src/tools/SelectionTool/Selection.d.ts +4 -1
  24. package/dist/src/tools/SelectionTool/Selection.js +64 -27
  25. package/dist/src/tools/SelectionTool/SelectionTool.js +3 -1
  26. package/dist/src/tools/TextTool.js +21 -6
  27. package/dist/src/tools/ToolController.js +3 -3
  28. package/dist/src/types.d.ts +2 -2
  29. package/package.json +1 -1
  30. package/src/Color4.ts +1 -0
  31. package/src/Editor.ts +3 -4
  32. package/src/EditorImage.ts +1 -11
  33. package/src/SVGLoader.ts +14 -14
  34. package/src/components/AbstractComponent.ts +19 -0
  35. package/src/math/Rect2.test.ts +22 -0
  36. package/src/math/Rect2.ts +26 -0
  37. package/src/toolbar/HTMLToolbar.ts +81 -5
  38. package/src/toolbar/IconProvider.ts +39 -9
  39. package/src/toolbar/widgets/EraserToolWidget.ts +64 -5
  40. package/src/toolbar/widgets/PenToolWidget.ts +2 -2
  41. package/src/toolbar/widgets/SelectionToolWidget.ts +2 -2
  42. package/src/tools/Eraser.test.ts +79 -0
  43. package/src/tools/Eraser.ts +81 -17
  44. package/src/tools/SelectionTool/Selection.ts +73 -23
  45. package/src/tools/SelectionTool/SelectionTool.test.ts +138 -21
  46. package/src/tools/SelectionTool/SelectionTool.ts +3 -1
  47. package/src/tools/TextTool.ts +26 -8
  48. package/src/tools/ToolController.ts +3 -3
  49. package/src/types.ts +2 -2
@@ -4,11 +4,20 @@ import Editor from '../Editor';
4
4
  export default class Eraser extends BaseTool {
5
5
  private editor;
6
6
  private lastPoint;
7
+ private isFirstEraseEvt;
7
8
  private toRemove;
9
+ private thickness;
8
10
  private partialCommands;
9
11
  constructor(editor: Editor, description: string);
12
+ private clearPreview;
13
+ private getSizeOnCanvas;
14
+ private drawPreviewAt;
15
+ private getEraserRect;
16
+ private eraseTo;
10
17
  onPointerDown(event: PointerEvt): boolean;
11
18
  onPointerMove(event: PointerEvt): void;
12
- onPointerUp(_event: PointerEvt): void;
19
+ onPointerUp(event: PointerEvt): void;
13
20
  onGestureCancel(): void;
21
+ getThickness(): number;
22
+ setThickness(thickness: number): void;
14
23
  }
@@ -1,31 +1,55 @@
1
+ import { EditorEventType } from '../types';
1
2
  import BaseTool from './BaseTool';
3
+ import { Vec2 } from '../math/Vec2';
2
4
  import LineSegment2 from '../math/LineSegment2';
3
5
  import Erase from '../commands/Erase';
4
6
  import { PointerDevice } from '../Pointer';
7
+ import Color4 from '../Color4';
8
+ import Rect2 from '../math/Rect2';
5
9
  export default class Eraser extends BaseTool {
6
10
  constructor(editor, description) {
7
11
  super(editor.notifier, description);
8
12
  this.editor = editor;
13
+ this.lastPoint = null;
14
+ this.isFirstEraseEvt = true;
15
+ this.thickness = 10;
9
16
  // Commands that each remove one element
10
17
  this.partialCommands = [];
11
18
  }
12
- onPointerDown(event) {
13
- if (event.allPointers.length === 1 || event.current.device === PointerDevice.Eraser) {
14
- this.lastPoint = event.current.canvasPos;
15
- this.toRemove = [];
16
- return true;
17
- }
18
- return false;
19
+ clearPreview() {
20
+ this.editor.clearWetInk();
19
21
  }
20
- onPointerMove(event) {
21
- const currentPoint = event.current.canvasPos;
22
- if (currentPoint.minus(this.lastPoint).magnitude() === 0) {
22
+ getSizeOnCanvas() {
23
+ return this.thickness / this.editor.viewport.getScaleFactor();
24
+ }
25
+ drawPreviewAt(point) {
26
+ this.clearPreview();
27
+ const size = this.getSizeOnCanvas();
28
+ const renderer = this.editor.display.getWetInkRenderer();
29
+ const rect = this.getEraserRect(point);
30
+ const fill = {
31
+ fill: Color4.gray,
32
+ };
33
+ renderer.drawRect(rect, size / 4, fill);
34
+ }
35
+ getEraserRect(centerPoint) {
36
+ const size = this.getSizeOnCanvas();
37
+ const halfSize = Vec2.of(size / 2, size / 2);
38
+ return Rect2.fromCorners(centerPoint.minus(halfSize), centerPoint.plus(halfSize));
39
+ }
40
+ eraseTo(currentPoint) {
41
+ if (!this.isFirstEraseEvt && currentPoint.minus(this.lastPoint).magnitude() === 0) {
23
42
  return;
24
43
  }
44
+ this.isFirstEraseEvt = false;
45
+ // Currently only objects within eraserRect or that intersect a straight line
46
+ // from the center of the current rect to the previous are erased. TODO: Erase
47
+ // all objects as if there were pointerMove events between the two points.
48
+ const eraserRect = this.getEraserRect(currentPoint);
25
49
  const line = new LineSegment2(this.lastPoint, currentPoint);
26
- const region = line.bbox;
50
+ const region = Rect2.union(line.bbox, eraserRect);
27
51
  const intersectingElems = this.editor.image.getElementsIntersectingRegion(region).filter(component => {
28
- return component.intersects(line);
52
+ return component.intersects(line) || component.intersectsRect(eraserRect);
29
53
  });
30
54
  // Remove any intersecting elements.
31
55
  this.toRemove.push(...intersectingElems);
@@ -33,9 +57,25 @@ export default class Eraser extends BaseTool {
33
57
  const newPartialCommands = intersectingElems.map(elem => new Erase([elem]));
34
58
  newPartialCommands.forEach(cmd => cmd.apply(this.editor));
35
59
  this.partialCommands.push(...newPartialCommands);
60
+ this.drawPreviewAt(currentPoint);
36
61
  this.lastPoint = currentPoint;
37
62
  }
38
- onPointerUp(_event) {
63
+ onPointerDown(event) {
64
+ if (event.allPointers.length === 1 || event.current.device === PointerDevice.Eraser) {
65
+ this.lastPoint = event.current.canvasPos;
66
+ this.toRemove = [];
67
+ this.isFirstEraseEvt = true;
68
+ this.drawPreviewAt(event.current.canvasPos);
69
+ return true;
70
+ }
71
+ return false;
72
+ }
73
+ onPointerMove(event) {
74
+ const currentPoint = event.current.canvasPos;
75
+ this.eraseTo(currentPoint);
76
+ }
77
+ onPointerUp(event) {
78
+ this.eraseTo(event.current.canvasPos);
39
79
  if (this.toRemove.length > 0) {
40
80
  // Undo commands for each individual component and unite into a single command.
41
81
  this.partialCommands.forEach(cmd => cmd.unapply(this.editor));
@@ -43,9 +83,21 @@ export default class Eraser extends BaseTool {
43
83
  const command = new Erase(this.toRemove);
44
84
  this.editor.dispatch(command); // dispatch: Makes undo-able.
45
85
  }
86
+ this.clearPreview();
46
87
  }
47
88
  onGestureCancel() {
48
89
  this.partialCommands.forEach(cmd => cmd.unapply(this.editor));
49
90
  this.partialCommands = [];
91
+ this.clearPreview();
92
+ }
93
+ getThickness() {
94
+ return this.thickness;
95
+ }
96
+ setThickness(thickness) {
97
+ this.thickness = thickness;
98
+ this.editor.notifier.dispatch(EditorEventType.ToolUpdated, {
99
+ kind: EditorEventType.ToolUpdated,
100
+ tool: this,
101
+ });
50
102
  }
51
103
  }
@@ -19,6 +19,7 @@ export default class Selection {
19
19
  private backgroundElem;
20
20
  private hasParent;
21
21
  constructor(startPoint: Point2, editor: Editor);
22
+ getBackgroundElem(): HTMLElement;
22
23
  getTransform(): Mat33;
23
24
  get preTransformRegion(): Rect2;
24
25
  get region(): Rect2;
@@ -36,7 +37,9 @@ export default class Selection {
36
37
  getMinCanvasSize(): number;
37
38
  getSelectedItemCount(): number;
38
39
  updateUI(): void;
40
+ private removedFromImage;
39
41
  private addRemoveSelectionFromImage;
42
+ private removeDeletedElemsFromSelection;
40
43
  private targetHandle;
41
44
  private backgroundDragging;
42
45
  onDragStart(pointer: Pointer, target: EventTarget): boolean;
@@ -45,7 +48,7 @@ export default class Selection {
45
48
  onDragCancel(): void;
46
49
  scrollTo(): Promise<void>;
47
50
  deleteSelectedObjects(): Command;
48
- duplicateSelectedObjects(): Command;
51
+ duplicateSelectedObjects(): Promise<Command>;
49
52
  addTo(elem: HTMLElement): void;
50
53
  setToPoint(point: Point2): void;
51
54
  cancelSelection(): void;
@@ -32,6 +32,8 @@ export default class Selection {
32
32
  this.transform = Mat33.identity;
33
33
  this.selectedElems = [];
34
34
  this.hasParent = true;
35
+ // Maps IDs to whether we removed the component from the image
36
+ this.removedFromImage = {};
35
37
  this.targetHandle = null;
36
38
  this.backgroundDragging = false;
37
39
  this.originalRegion = new Rect2(startPoint.x, startPoint.y, 0, 0);
@@ -58,6 +60,10 @@ export default class Selection {
58
60
  handle.addTo(this.backgroundElem);
59
61
  }
60
62
  }
63
+ // @internal Intended for unit tests
64
+ getBackgroundElem() {
65
+ return this.backgroundElem;
66
+ }
61
67
  getTransform() {
62
68
  return this.transform;
63
69
  }
@@ -140,16 +146,7 @@ export default class Selection {
140
146
  singleItemSelectionMode = true;
141
147
  }
142
148
  this.selectedElems = this.editor.image.getElementsIntersectingRegion(this.region).filter(elem => {
143
- if (this.region.containsRect(elem.getBBox())) {
144
- return true;
145
- }
146
- // Calculated bounding boxes can be slightly larger than their actual contents' bounding box.
147
- // As such, test with more lines than just this' edges.
148
- const testLines = [];
149
- for (const subregion of this.region.divideIntoGrid(2, 2)) {
150
- testLines.push(...subregion.getEdges());
151
- }
152
- return testLines.some(edge => elem.intersects(edge));
149
+ return elem.intersectsRect(this.region);
153
150
  });
154
151
  if (singleItemSelectionMode && this.selectedElems.length > 0) {
155
152
  this.selectedElems = [this.selectedElems[this.selectedElems.length - 1]];
@@ -215,30 +212,45 @@ export default class Selection {
215
212
  //
216
213
  // If removed from the image, selected elements are drawn as wet ink.
217
214
  addRemoveSelectionFromImage(inImage) {
218
- return __awaiter(this, void 0, void 0, function* () {
219
- // Don't hide elements if doing so will be slow.
220
- if (!inImage && this.selectedElems.length > maxPreviewElemCount) {
221
- return;
215
+ // Don't hide elements if doing so will be slow.
216
+ if (!inImage && this.selectedElems.length > maxPreviewElemCount) {
217
+ return;
218
+ }
219
+ for (const elem of this.selectedElems) {
220
+ const parent = this.editor.image.findParent(elem);
221
+ if (!inImage && parent) {
222
+ this.removedFromImage[elem.getId()] = true;
223
+ parent.remove();
222
224
  }
223
- for (const elem of this.selectedElems) {
224
- const parent = this.editor.image.findParent(elem);
225
- if (!inImage) {
226
- parent === null || parent === void 0 ? void 0 : parent.remove();
227
- }
228
- // If we're making things visible and the selected object wasn't previously
229
- // visible,
230
- else if (!parent) {
231
- EditorImage.addElement(elem).apply(this.editor);
232
- }
225
+ // If we're making things visible and the selected object wasn't previously
226
+ // visible,
227
+ else if (!parent && this.removedFromImage[elem.getId()]) {
228
+ EditorImage.addElement(elem).apply(this.editor);
229
+ this.removedFromImage[elem.getId()] = false;
230
+ delete this.removedFromImage[elem.getId()];
233
231
  }
234
- yield this.editor.queueRerender();
232
+ }
233
+ // Don't await queueRerender. If we're running in a test, the re-render might never
234
+ // happen.
235
+ this.editor.queueRerender().then(() => {
235
236
  if (!inImage) {
236
237
  this.previewTransformCmds();
237
238
  }
238
239
  });
239
240
  }
241
+ removeDeletedElemsFromSelection() {
242
+ // Remove any deleted elements from the selection.
243
+ this.selectedElems = this.selectedElems.filter(elem => {
244
+ const hasParent = !!this.editor.image.findParent(elem);
245
+ // If we removed the element and haven't added it back yet, don't remove it
246
+ // from the selection.
247
+ const weRemoved = this.removedFromImage[elem.getId()];
248
+ return hasParent || weRemoved;
249
+ });
250
+ }
240
251
  onDragStart(pointer, target) {
241
- void this.addRemoveSelectionFromImage(false);
252
+ this.removeDeletedElemsFromSelection();
253
+ this.addRemoveSelectionFromImage(false);
242
254
  for (const handle of this.handles) {
243
255
  if (handle.isTarget(target)) {
244
256
  handle.handleDragStart(pointer);
@@ -299,10 +311,34 @@ export default class Selection {
299
311
  });
300
312
  }
301
313
  deleteSelectedObjects() {
314
+ if (this.backgroundDragging || this.targetHandle) {
315
+ this.onDragEnd();
316
+ }
302
317
  return new Erase(this.selectedElems);
303
318
  }
304
319
  duplicateSelectedObjects() {
305
- return new Duplicate(this.selectedElems);
320
+ return __awaiter(this, void 0, void 0, function* () {
321
+ const wasTransforming = this.backgroundDragging || this.targetHandle;
322
+ let tmpApplyCommand = null;
323
+ if (wasTransforming) {
324
+ // Don't update the selection's focus when redoing/undoing
325
+ const selectionToUpdate = null;
326
+ tmpApplyCommand = new Selection.ApplyTransformationCommand(selectionToUpdate, this.selectedElems, this.transform);
327
+ // Transform to ensure that the duplicates are in the correct location
328
+ yield tmpApplyCommand.apply(this.editor);
329
+ // Show items again
330
+ this.addRemoveSelectionFromImage(true);
331
+ }
332
+ const duplicateCommand = new Duplicate(this.selectedElems);
333
+ if (wasTransforming) {
334
+ // Move the selected objects back to the correct location.
335
+ yield (tmpApplyCommand === null || tmpApplyCommand === void 0 ? void 0 : tmpApplyCommand.unapply(this.editor));
336
+ this.addRemoveSelectionFromImage(false);
337
+ this.previewTransformCmds();
338
+ this.updateUI();
339
+ }
340
+ return duplicateCommand;
341
+ });
306
342
  }
307
343
  addTo(elem) {
308
344
  if (this.container.parentElement) {
@@ -323,6 +359,7 @@ export default class Selection {
323
359
  this.hasParent = false;
324
360
  }
325
361
  setSelectedObjects(objects, bbox) {
362
+ this.addRemoveSelectionFromImage(true);
326
363
  this.originalRegion = bbox;
327
364
  this.selectedElems = objects.filter(object => object.isSelectable());
328
365
  this.updateUI();
@@ -241,7 +241,9 @@ export default class SelectionTool extends BaseTool {
241
241
  }
242
242
  else if (evt.ctrlKey) {
243
243
  if (this.selectionBox && evt.key === 'd') {
244
- this.editor.dispatch(this.selectionBox.duplicateSelectedObjects());
244
+ this.selectionBox.duplicateSelectedObjects().then(command => {
245
+ this.editor.dispatch(command);
246
+ });
245
247
  return true;
246
248
  }
247
249
  else if (evt.key === 'a') {
@@ -63,11 +63,20 @@ export default class TextTool extends BaseTool {
63
63
  // Estimate
64
64
  return style.size * 2 / 3;
65
65
  }
66
- flushInput() {
66
+ // Take input from this' textInputElem and add it to the EditorImage.
67
+ // If [removeInput], the HTML input element is removed. Otherwise, its value
68
+ // is cleared.
69
+ flushInput(removeInput = true) {
67
70
  if (this.textInputElem && this.textTargetPosition) {
68
71
  const content = this.textInputElem.value.trimEnd();
69
- this.textInputElem.remove();
70
- this.textInputElem = null;
72
+ this.textInputElem.value = '';
73
+ if (removeInput) {
74
+ // In some browsers, .remove() triggers a .blur event (synchronously).
75
+ // Clear this.textInputElem before removal
76
+ const input = this.textInputElem;
77
+ this.textInputElem = null;
78
+ input.remove();
79
+ }
71
80
  if (content === '') {
72
81
  return;
73
82
  }
@@ -134,9 +143,15 @@ export default class TextTool extends BaseTool {
134
143
  }
135
144
  };
136
145
  this.textInputElem.onblur = () => {
137
- // Don't remove the input within the context of a blur event handler.
138
- // Doing so causes errors.
139
- setTimeout(() => this.flushInput(), 0);
146
+ // Delay removing the input -- flushInput may be called within a blur()
147
+ // event handler
148
+ const removeInput = false;
149
+ const input = this.textInputElem;
150
+ this.flushInput(removeInput);
151
+ this.textInputElem = null;
152
+ setTimeout(() => {
153
+ input === null || input === void 0 ? void 0 : input.remove();
154
+ }, 0);
140
155
  };
141
156
  this.textInputElem.onkeyup = (evt) => {
142
157
  var _a, _b;
@@ -21,13 +21,13 @@ export default class ToolController {
21
21
  this.primaryToolGroup = primaryToolGroup;
22
22
  const panZoomTool = new PanZoom(editor, PanZoomMode.TwoFingerTouchGestures | PanZoomMode.RightClickDrags, localization.touchPanTool);
23
23
  const keyboardPanZoomTool = new PanZoom(editor, PanZoomMode.Keyboard, localization.keyboardPanZoom);
24
- const primaryPenTool = new Pen(editor, localization.penTool(1), { color: Color4.purple, thickness: 16 });
24
+ const primaryPenTool = new Pen(editor, localization.penTool(1), { color: Color4.purple, thickness: 8 });
25
25
  const primaryTools = [
26
26
  // Three pens
27
27
  primaryPenTool,
28
28
  new Pen(editor, localization.penTool(2), { color: Color4.clay, thickness: 4 }),
29
- // Highlighter-like pen with width=64
30
- new Pen(editor, localization.penTool(3), { color: Color4.ofRGBA(1, 1, 0, 0.5), thickness: 64 }, makePressureSensitiveFreehandLineBuilder),
29
+ // Highlighter-like pen with width=40
30
+ new Pen(editor, localization.penTool(3), { color: Color4.ofRGBA(1, 1, 0, 0.5), thickness: 40 }, makePressureSensitiveFreehandLineBuilder),
31
31
  new Eraser(editor, localization.eraserTool),
32
32
  new SelectionTool(editor, localization.selectionTool),
33
33
  new TextTool(editor, localization.textTool, localization),
@@ -129,8 +129,8 @@ export interface ToolbarDropdownShownEvent {
129
129
  readonly parentWidget: BaseWidget;
130
130
  }
131
131
  export type EditorEventDataType = EditorToolEvent | EditorObjectEvent | EditorViewportChangedEvent | DisplayResizedEvent | EditorUndoStackUpdated | CommandDoneEvent | CommandUndoneEvent | ColorPickerToggled | ColorPickerColorSelected | ToolbarDropdownShownEvent;
132
- export type OnProgressListener = (amountProcessed: number, totalToProcess: number) => Promise<void> | null;
133
- export type ComponentAddedListener = (component: AbstractComponent) => void;
132
+ export type OnProgressListener = (amountProcessed: number, totalToProcess: number) => Promise<void> | null | void;
133
+ export type ComponentAddedListener = (component: AbstractComponent) => Promise<void> | void;
134
134
  export type OnDetermineExportRectListener = (exportRect: Rect2) => void;
135
135
  export interface ImageLoader {
136
136
  start(onAddComponent: ComponentAddedListener, onProgressListener: OnProgressListener, onDetermineExportRect?: OnDetermineExportRectListener): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "js-draw",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
4
4
  "description": "Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript. ",
5
5
  "main": "./dist/src/lib.d.ts",
6
6
  "types": "./dist/src/lib.js",
package/src/Color4.ts CHANGED
@@ -176,5 +176,6 @@ export default class Color4 {
176
176
  public static yellow = Color4.ofRGB(1, 1, 0.1);
177
177
  public static clay = Color4.ofRGB(0.8, 0.4, 0.2);
178
178
  public static black = Color4.ofRGB(0, 0, 0);
179
+ public static gray = Color4.ofRGB(0.5, 0.5, 0.5);
179
180
  public static white = Color4.ofRGB(1, 1, 1);
180
181
  }
package/src/Editor.ts CHANGED
@@ -292,8 +292,7 @@ export class Editor {
292
292
  const toolbar = new HTMLToolbar(this, this.container, this.localization);
293
293
 
294
294
  if (defaultLayout) {
295
- toolbar.addDefaultToolWidgets();
296
- toolbar.addDefaultActionButtons();
295
+ toolbar.addDefaults();
297
296
  }
298
297
 
299
298
  return toolbar;
@@ -946,8 +945,8 @@ export class Editor {
946
945
  this.showLoadingWarning(0);
947
946
  this.display.setDraftMode(true);
948
947
 
949
- await loader.start((component) => {
950
- this.dispatchNoAnnounce(EditorImage.addElement(component));
948
+ await loader.start(async (component) => {
949
+ await this.dispatchNoAnnounce(EditorImage.addElement(component));
951
950
  }, (countProcessed: number, totalToProcess: number) => {
952
951
  if (countProcessed % 500 === 0) {
953
952
  this.showLoadingWarning(countProcessed / totalToProcess);
@@ -320,17 +320,7 @@ export class ImageNode {
320
320
  if (this.content !== null) {
321
321
  this.bbox = this.content.getBBox();
322
322
  } else {
323
- this.bbox = Rect2.empty;
324
- let isFirst = true;
325
-
326
- for (const child of this.children) {
327
- if (isFirst) {
328
- this.bbox = child.getBBox();
329
- isFirst = false;
330
- } else {
331
- this.bbox = this.bbox.union(child.getBBox());
332
- }
333
- }
323
+ this.bbox = Rect2.union(...this.children.map(child => child.getBBox()));
334
324
  }
335
325
 
336
326
  if (bubbleUp && !oldBBox.eq(this.bbox)) {
package/src/SVGLoader.ts CHANGED
@@ -156,7 +156,7 @@ export default class SVGLoader implements ImageLoader {
156
156
  }
157
157
 
158
158
  // Adds a stroke with a single path
159
- private addPath(node: SVGPathElement) {
159
+ private async addPath(node: SVGPathElement) {
160
160
  let elem: AbstractComponent;
161
161
  try {
162
162
  const strokeData = this.strokeDataFromElem(node);
@@ -181,7 +181,7 @@ export default class SVGLoader implements ImageLoader {
181
181
  return;
182
182
  }
183
183
  }
184
- this.onAddComponent?.(elem);
184
+ await this.onAddComponent?.(elem);
185
185
  }
186
186
 
187
187
  // If given, 'supportedAttrs' will have x, y, etc. attributes that were used in computing the transform added to it,
@@ -274,10 +274,10 @@ export default class SVGLoader implements ImageLoader {
274
274
  return result;
275
275
  }
276
276
 
277
- private addText(elem: SVGTextElement|SVGTSpanElement) {
277
+ private async addText(elem: SVGTextElement|SVGTSpanElement) {
278
278
  try {
279
279
  const textElem = this.makeText(elem);
280
- this.onAddComponent?.(textElem);
280
+ await this.onAddComponent?.(textElem);
281
281
  } catch (e) {
282
282
  console.error('Invalid text object in node', elem, '. Continuing.... Error:', e);
283
283
  this.addUnknownNode(elem);
@@ -300,17 +300,17 @@ export default class SVGLoader implements ImageLoader {
300
300
  new Set([ 'transform' ])
301
301
  );
302
302
 
303
- this.onAddComponent?.(imageElem);
303
+ await this.onAddComponent?.(imageElem);
304
304
  } catch (e) {
305
305
  console.error('Error loading image:', e, '. Element: ', elem, '. Continuing...');
306
- this.addUnknownNode(elem);
306
+ await this.addUnknownNode(elem);
307
307
  }
308
308
  }
309
309
 
310
- private addUnknownNode(node: SVGElement) {
310
+ private async addUnknownNode(node: SVGElement) {
311
311
  if (this.storeUnknown) {
312
312
  const component = new UnknownSVGObject(node);
313
- this.onAddComponent?.(component);
313
+ await this.onAddComponent?.(component);
314
314
  }
315
315
  }
316
316
 
@@ -335,9 +335,9 @@ export default class SVGLoader implements ImageLoader {
335
335
  this.onDetermineExportRect?.(this.rootViewBox);
336
336
  }
337
337
 
338
- private updateSVGAttrs(node: SVGSVGElement) {
338
+ private async updateSVGAttrs(node: SVGSVGElement) {
339
339
  if (this.storeUnknown) {
340
- this.onAddComponent?.(new SVGGlobalAttributesObject(this.getSourceAttrs(node)));
340
+ await this.onAddComponent?.(new SVGGlobalAttributesObject(this.getSourceAttrs(node)));
341
341
  }
342
342
  }
343
343
 
@@ -350,10 +350,10 @@ export default class SVGLoader implements ImageLoader {
350
350
  // Continue -- visit the node's children.
351
351
  break;
352
352
  case 'path':
353
- this.addPath(node as SVGPathElement);
353
+ await this.addPath(node as SVGPathElement);
354
354
  break;
355
355
  case 'text':
356
- this.addText(node as SVGTextElement);
356
+ await this.addText(node as SVGTextElement);
357
357
  visitChildren = false;
358
358
  break;
359
359
  case 'image':
@@ -367,7 +367,7 @@ export default class SVGLoader implements ImageLoader {
367
367
  this.updateSVGAttrs(node as SVGSVGElement);
368
368
  break;
369
369
  case 'style':
370
- this.addUnknownNode(node as SVGStyleElement);
370
+ await this.addUnknownNode(node as SVGStyleElement);
371
371
  break;
372
372
  default:
373
373
  console.warn('Unknown SVG element,', node);
@@ -377,7 +377,7 @@ export default class SVGLoader implements ImageLoader {
377
377
  );
378
378
  }
379
379
 
380
- this.addUnknownNode(node as SVGElement);
380
+ await this.addUnknownNode(node as SVGElement);
381
381
  return;
382
382
  }
383
383
 
@@ -78,6 +78,25 @@ export default abstract class AbstractComponent {
78
78
  public abstract render(canvas: AbstractRenderer, visibleRect?: Rect2): void;
79
79
  public abstract intersects(lineSegment: LineSegment2): boolean;
80
80
 
81
+ public intersectsRect(rect: Rect2): boolean {
82
+ // If this component intersects rect,
83
+ // it is either contained entirely within rect or intersects one of rect's edges.
84
+
85
+ // If contained within,
86
+ if (rect.containsRect(this.getBBox())) {
87
+ return true;
88
+ }
89
+
90
+ // Calculated bounding boxes can be slightly larger than their actual contents' bounding box.
91
+ // As such, test with more lines than just the rect's edges.
92
+ const testLines = [];
93
+ for (const subregion of rect.divideIntoGrid(2, 2)) {
94
+ testLines.push(...subregion.getEdges());
95
+ }
96
+
97
+ return testLines.some(edge => this.intersects(edge));
98
+ }
99
+
81
100
  // Return null iff this object cannot be safely serialized/deserialized.
82
101
  protected abstract serializeToJSON(): any[]|Record<string, any>|number|string|null;
83
102
 
@@ -46,6 +46,28 @@ describe('Rect2', () => {
46
46
  expect(Rect2.empty.union(Rect2.empty)).objEq(Rect2.empty);
47
47
  });
48
48
 
49
+ it('should handle empty unions', () => {
50
+ expect(Rect2.union()).toStrictEqual(Rect2.empty);
51
+ });
52
+
53
+ it('should correctly union multiple rectangles', () => {
54
+ expect(Rect2.union(new Rect2(0, 0, 1, 1), new Rect2(1, 1, 2, 2))).objEq(
55
+ new Rect2(0, 0, 3, 3)
56
+ );
57
+
58
+ expect(
59
+ Rect2.union(new Rect2(-1, 0, 1, 1), new Rect2(1, 1, 2, 2), new Rect2(1, 10, 1, 0.1))
60
+ ).objEq(
61
+ new Rect2(-1, 0, 4, 10.1)
62
+ );
63
+
64
+ expect(
65
+ Rect2.union(new Rect2(-1, 0, 1, 1), new Rect2(1, -11.1, 2, 2), new Rect2(1, 10, 1, 0.1))
66
+ ).objEq(
67
+ new Rect2(-1, -11.1, 4, 21.2)
68
+ );
69
+ });
70
+
49
71
  it('should contain points that are within a rectangle', () => {
50
72
  expect(new Rect2(-1, -1, 2, 2).containsPoint(Vec2.zero)).toBe(true);
51
73
  expect(new Rect2(-1, -1, 0, 0).containsPoint(Vec2.zero)).toBe(false);
package/src/math/Rect2.ts CHANGED
@@ -278,6 +278,32 @@ export default class Rect2 {
278
278
  );
279
279
  }
280
280
 
281
+ // @returns a rectangle that contains all of the given rectangles, the bounding box
282
+ // of the given rectangles.
283
+ public static union(...rects: Rect2[]): Rect2 {
284
+ if (rects.length === 0) {
285
+ return Rect2.empty;
286
+ }
287
+
288
+ const firstRect = rects[0];
289
+ let minX: number = firstRect.topLeft.x;
290
+ let minY: number = firstRect.topLeft.y;
291
+ let maxX: number = firstRect.bottomRight.x;
292
+ let maxY: number = firstRect.bottomRight.y;
293
+
294
+ for (let i = 1; i < rects.length; i++) {
295
+ const rect = rects[i];
296
+ minX = Math.min(minX, rect.topLeft.x);
297
+ minY = Math.min(minY, rect.topLeft.y);
298
+ maxX = Math.max(maxX, rect.bottomRight.x);
299
+ maxY = Math.max(maxY, rect.bottomRight.y);
300
+ }
301
+
302
+ return new Rect2(
303
+ minX, minY, maxX - minX, maxY - minY,
304
+ );
305
+ }
306
+
281
307
  public static of(template: RectTemplate) {
282
308
  const width = template.width ?? template.w ?? 0;
283
309
  const height = template.height ?? template.h ?? 0;