js-draw 0.3.1 → 0.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.
Files changed (132) hide show
  1. package/.github/ISSUE_TEMPLATE/translation.md +4 -1
  2. package/CHANGELOG.md +16 -0
  3. package/README.md +1 -3
  4. package/dist/bundle.js +1 -1
  5. package/dist/src/Editor.d.ts +15 -1
  6. package/dist/src/Editor.js +221 -78
  7. package/dist/src/EditorImage.js +4 -1
  8. package/dist/src/Pointer.d.ts +1 -1
  9. package/dist/src/Pointer.js +8 -3
  10. package/dist/src/SVGLoader.d.ts +4 -1
  11. package/dist/src/SVGLoader.js +78 -33
  12. package/dist/src/UndoRedoHistory.d.ts +1 -0
  13. package/dist/src/UndoRedoHistory.js +6 -0
  14. package/dist/src/Viewport.d.ts +2 -0
  15. package/dist/src/Viewport.js +26 -5
  16. package/dist/src/commands/lib.d.ts +2 -1
  17. package/dist/src/commands/lib.js +2 -1
  18. package/dist/src/commands/localization.d.ts +1 -0
  19. package/dist/src/commands/localization.js +1 -0
  20. package/dist/src/commands/uniteCommands.d.ts +4 -0
  21. package/dist/src/commands/uniteCommands.js +105 -0
  22. package/dist/src/components/AbstractComponent.d.ts +2 -0
  23. package/dist/src/components/AbstractComponent.js +41 -5
  24. package/dist/src/components/ImageComponent.d.ts +27 -0
  25. package/dist/src/components/ImageComponent.js +129 -0
  26. package/dist/src/components/builders/FreehandLineBuilder.js +2 -2
  27. package/dist/src/components/lib.d.ts +4 -2
  28. package/dist/src/components/lib.js +4 -2
  29. package/dist/src/components/localization.d.ts +2 -0
  30. package/dist/src/components/localization.js +2 -0
  31. package/dist/src/language/assertions.d.ts +1 -0
  32. package/dist/src/language/assertions.js +5 -0
  33. package/dist/src/math/LineSegment2.d.ts +2 -0
  34. package/dist/src/math/LineSegment2.js +3 -0
  35. package/dist/src/math/Mat33.d.ts +38 -2
  36. package/dist/src/math/Mat33.js +30 -1
  37. package/dist/src/math/Path.d.ts +1 -1
  38. package/dist/src/math/Path.js +10 -8
  39. package/dist/src/math/Vec3.d.ts +11 -1
  40. package/dist/src/math/Vec3.js +15 -0
  41. package/dist/src/math/rounding.d.ts +1 -0
  42. package/dist/src/math/rounding.js +13 -6
  43. package/dist/src/rendering/localization.d.ts +3 -0
  44. package/dist/src/rendering/localization.js +3 -0
  45. package/dist/src/rendering/renderers/AbstractRenderer.d.ts +7 -0
  46. package/dist/src/rendering/renderers/AbstractRenderer.js +2 -1
  47. package/dist/src/rendering/renderers/CanvasRenderer.d.ts +2 -1
  48. package/dist/src/rendering/renderers/CanvasRenderer.js +7 -0
  49. package/dist/src/rendering/renderers/DummyRenderer.d.ts +3 -1
  50. package/dist/src/rendering/renderers/DummyRenderer.js +5 -0
  51. package/dist/src/rendering/renderers/SVGRenderer.d.ts +5 -2
  52. package/dist/src/rendering/renderers/SVGRenderer.js +45 -20
  53. package/dist/src/rendering/renderers/TextOnlyRenderer.d.ts +3 -1
  54. package/dist/src/rendering/renderers/TextOnlyRenderer.js +8 -1
  55. package/dist/src/toolbar/HTMLToolbar.js +5 -4
  56. package/dist/src/toolbar/widgets/SelectionToolWidget.d.ts +1 -1
  57. package/dist/src/tools/BaseTool.d.ts +3 -1
  58. package/dist/src/tools/BaseTool.js +6 -0
  59. package/dist/src/tools/PasteHandler.d.ts +16 -0
  60. package/dist/src/tools/PasteHandler.js +144 -0
  61. package/dist/src/tools/Pen.js +1 -1
  62. package/dist/src/tools/SelectionTool/Selection.d.ts +54 -0
  63. package/dist/src/tools/SelectionTool/Selection.js +337 -0
  64. package/dist/src/tools/SelectionTool/SelectionHandle.d.ts +35 -0
  65. package/dist/src/tools/SelectionTool/SelectionHandle.js +75 -0
  66. package/dist/src/tools/SelectionTool/SelectionTool.d.ts +31 -0
  67. package/dist/src/tools/SelectionTool/SelectionTool.js +276 -0
  68. package/dist/src/tools/SelectionTool/TransformMode.d.ts +34 -0
  69. package/dist/src/tools/SelectionTool/TransformMode.js +98 -0
  70. package/dist/src/tools/SelectionTool/types.d.ts +9 -0
  71. package/dist/src/tools/SelectionTool/types.js +11 -0
  72. package/dist/src/tools/ToolController.js +37 -28
  73. package/dist/src/tools/lib.d.ts +2 -1
  74. package/dist/src/tools/lib.js +2 -1
  75. package/dist/src/tools/localization.d.ts +3 -0
  76. package/dist/src/tools/localization.js +3 -0
  77. package/dist/src/types.d.ts +14 -3
  78. package/dist/src/types.js +2 -0
  79. package/package.json +1 -1
  80. package/src/Editor.css +1 -0
  81. package/src/Editor.ts +275 -109
  82. package/src/EditorImage.ts +7 -1
  83. package/src/Pointer.ts +8 -3
  84. package/src/SVGLoader.ts +90 -36
  85. package/src/UndoRedoHistory.test.ts +33 -0
  86. package/src/UndoRedoHistory.ts +8 -0
  87. package/src/Viewport.ts +30 -6
  88. package/src/commands/lib.ts +2 -0
  89. package/src/commands/localization.ts +2 -0
  90. package/src/commands/uniteCommands.test.ts +23 -0
  91. package/src/commands/uniteCommands.ts +121 -0
  92. package/src/components/AbstractComponent.ts +53 -11
  93. package/src/components/ImageComponent.ts +149 -0
  94. package/src/components/Text.ts +2 -6
  95. package/src/components/builders/FreehandLineBuilder.ts +2 -2
  96. package/src/components/lib.ts +7 -2
  97. package/src/components/localization.ts +4 -0
  98. package/src/language/assertions.ts +6 -0
  99. package/src/math/LineSegment2.test.ts +9 -0
  100. package/src/math/LineSegment2.ts +5 -0
  101. package/src/math/Mat33.test.ts +14 -0
  102. package/src/math/Mat33.ts +43 -2
  103. package/src/math/Path.toString.test.ts +12 -1
  104. package/src/math/Path.ts +11 -9
  105. package/src/math/Vec3.ts +22 -1
  106. package/src/math/rounding.test.ts +30 -5
  107. package/src/math/rounding.ts +16 -7
  108. package/src/rendering/localization.ts +6 -0
  109. package/src/rendering/renderers/AbstractRenderer.ts +19 -2
  110. package/src/rendering/renderers/CanvasRenderer.ts +10 -1
  111. package/src/rendering/renderers/DummyRenderer.ts +6 -1
  112. package/src/rendering/renderers/SVGRenderer.ts +50 -21
  113. package/src/rendering/renderers/TextOnlyRenderer.ts +10 -2
  114. package/src/toolbar/HTMLToolbar.ts +5 -4
  115. package/src/toolbar/widgets/SelectionToolWidget.ts +1 -1
  116. package/src/tools/BaseTool.ts +9 -1
  117. package/src/tools/PasteHandler.ts +159 -0
  118. package/src/tools/Pen.ts +1 -1
  119. package/src/tools/SelectionTool/Selection.ts +455 -0
  120. package/src/tools/SelectionTool/SelectionHandle.ts +99 -0
  121. package/src/tools/SelectionTool/SelectionTool.css +22 -0
  122. package/src/tools/{SelectionTool.test.ts → SelectionTool/SelectionTool.test.ts} +21 -21
  123. package/src/tools/SelectionTool/SelectionTool.ts +335 -0
  124. package/src/tools/SelectionTool/TransformMode.ts +114 -0
  125. package/src/tools/SelectionTool/types.ts +11 -0
  126. package/src/tools/ToolController.ts +52 -45
  127. package/src/tools/lib.ts +2 -1
  128. package/src/tools/localization.ts +8 -0
  129. package/src/types.ts +17 -3
  130. package/dist/src/tools/SelectionTool.d.ts +0 -59
  131. package/dist/src/tools/SelectionTool.js +0 -589
  132. package/src/tools/SelectionTool.ts +0 -725
package/src/Editor.ts CHANGED
@@ -1,18 +1,18 @@
1
1
  /**
2
2
  * The main entrypoint for the full editor.
3
- *
3
+ *
4
4
  * @example
5
5
  * To create an editor with a toolbar,
6
6
  * ```
7
7
  * const editor = new Editor(document.body);
8
- *
8
+ *
9
9
  * const toolbar = editor.addToolbar();
10
10
  * toolbar.addActionButton('Save', () => {
11
11
  * const saveData = editor.toSVG().outerHTML;
12
12
  * // Do something with saveData...
13
13
  * });
14
14
  * ```
15
- *
15
+ *
16
16
  * @packageDocumentation
17
17
  */
18
18
 
@@ -38,6 +38,9 @@ import Rect2 from './math/Rect2';
38
38
  import { EditorLocalization } from './localization';
39
39
  import getLocalizationTable from './localizations/getLocalizationTable';
40
40
 
41
+ type HTMLPointerEventType = 'pointerdown'|'pointermove'|'pointerup'|'pointercancel';
42
+ type HTMLPointerEventFilter = (eventName: HTMLPointerEventType, event: PointerEvent)=>boolean;
43
+
41
44
  export interface EditorSettings {
42
45
  /** Defaults to `RenderingMode.CanvasRenderer` */
43
46
  renderingMode: RenderingMode,
@@ -67,14 +70,14 @@ export class Editor {
67
70
 
68
71
  /**
69
72
  * Handles undo/redo.
70
- *
73
+ *
71
74
  * @example
72
75
  * ```
73
76
  * const editor = new Editor(document.body);
74
- *
77
+ *
75
78
  * // Do something undoable.
76
79
  * // ...
77
- *
80
+ *
78
81
  * // Undo the last action
79
82
  * editor.history.undo();
80
83
  * ```
@@ -83,17 +86,17 @@ export class Editor {
83
86
 
84
87
  /**
85
88
  * Data structure for adding/removing/querying objects in the image.
86
- *
89
+ *
87
90
  * @example
88
91
  * ```
89
92
  * const editor = new Editor(document.body);
90
- *
93
+ *
91
94
  * // Create a path.
92
95
  * const stroke = new Stroke([
93
96
  * Path.fromString('M0,0 L30,30 z').toRenderable({ fill: Color4.black }),
94
97
  * ]);
95
98
  * const addElementCommand = editor.image.addElement(stroke);
96
- *
99
+ *
97
100
  * // Add the stroke to the editor
98
101
  * editor.dispatch(addElementCommand);
99
102
  * ```
@@ -118,6 +121,7 @@ export class Editor {
118
121
  private loadingWarning: HTMLElement;
119
122
  private accessibilityAnnounceArea: HTMLElement;
120
123
  private accessibilityControlArea: HTMLTextAreaElement;
124
+ private eventListenerTargets: HTMLElement[] = [];
121
125
 
122
126
  private settings: EditorSettings;
123
127
 
@@ -125,14 +129,14 @@ export class Editor {
125
129
  * @example
126
130
  * ```
127
131
  * const container = document.body;
128
- *
132
+ *
129
133
  * // Create an editor
130
134
  * const editor = new Editor(container, {
131
135
  * // 2e-10 and 1e12 are the default values for minimum/maximum zoom.
132
136
  * minZoom: 2e-10,
133
137
  * maxZoom: 1e12,
134
138
  * });
135
- *
139
+ *
136
140
  * // Add the default toolbar
137
141
  * const toolbar = editor.addToolbar();
138
142
  * toolbar.addActionButton({
@@ -223,7 +227,7 @@ export class Editor {
223
227
  if (oldZoom <= this.settings.maxZoom && oldZoom >= this.settings.minZoom) {
224
228
  resetTransform = evt.oldTransform;
225
229
  }
226
-
230
+
227
231
  this.viewport.resetTransform(resetTransform);
228
232
  }
229
233
  }
@@ -232,7 +236,7 @@ export class Editor {
232
236
 
233
237
  /**
234
238
  * @returns a reference to the editor's container.
235
- *
239
+ *
236
240
  * @example
237
241
  * ```
238
242
  * editor.getRootElement().style.height = '500px';
@@ -284,157 +288,292 @@ export class Editor {
284
288
  }
285
289
 
286
290
  private registerListeners() {
287
- const pointers: Record<number, Pointer> = {};
288
- const getPointerList = () => {
289
- const nowTime = (new Date()).getTime();
290
-
291
- const res: Pointer[] = [];
292
- for (const id in pointers) {
293
- const maxUnupdatedTime = 2000; // Maximum time without a pointer update (ms)
294
- if (pointers[id] && (nowTime - pointers[id].timeStamp) < maxUnupdatedTime) {
295
- res.push(pointers[id]);
291
+ this.handlePointerEventsFrom(this.renderingRegion);
292
+ this.handleKeyEventsFrom(this.renderingRegion);
293
+
294
+ this.container.addEventListener('wheel', evt => {
295
+ let delta = Vec3.of(evt.deltaX, evt.deltaY, evt.deltaZ);
296
+
297
+ // Process wheel events if the ctrl key is down, even if disabled -- we do want to handle
298
+ // pinch-zooming.
299
+ if (!evt.ctrlKey) {
300
+ if (!this.settings.wheelEventsEnabled) {
301
+ return;
302
+ } else if (this.settings.wheelEventsEnabled === 'only-if-focused') {
303
+ const focusedChild = this.container.querySelector(':focus');
304
+
305
+ if (!focusedChild) {
306
+ return;
307
+ }
296
308
  }
297
309
  }
298
- return res;
299
- };
300
310
 
301
- // May be required to prevent text selection on iOS/Safari:
302
- // See https://stackoverflow.com/a/70992717/17055750
303
- this.renderingRegion.addEventListener('touchstart', evt => evt.preventDefault());
304
- this.renderingRegion.addEventListener('contextmenu', evt => {
305
- // Don't show a context menu
306
- evt.preventDefault();
311
+ if (evt.deltaMode === WheelEvent.DOM_DELTA_LINE) {
312
+ delta = delta.times(15);
313
+ } else if (evt.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
314
+ delta = delta.times(100);
315
+ }
316
+
317
+ if (evt.ctrlKey) {
318
+ delta = Vec3.of(0, 0, evt.deltaY);
319
+ }
320
+
321
+ // Ensure that `pos` is relative to `this.container`
322
+ const bbox = this.container.getBoundingClientRect();
323
+ const pos = Vec2.of(evt.clientX, evt.clientY).minus(Vec2.of(bbox.left, bbox.top));
324
+
325
+ if (this.toolController.dispatchInputEvent({
326
+ kind: InputEvtType.WheelEvt,
327
+ delta,
328
+ screenPos: pos,
329
+ })) {
330
+ evt.preventDefault();
331
+ return true;
332
+ }
333
+ return false;
307
334
  });
308
335
 
309
- this.renderingRegion.addEventListener('pointerdown', evt => {
310
- const pointer = Pointer.ofEvent(evt, true, this.viewport);
311
- pointers[pointer.id] = pointer;
336
+ this.notifier.on(EditorEventType.DisplayResized, _event => {
337
+ this.viewport.updateScreenSize(
338
+ Vec2.of(this.display.width, this.display.height)
339
+ );
340
+ });
312
341
 
313
- this.renderingRegion.setPointerCapture(pointer.id);
342
+ window.addEventListener('resize', () => {
343
+ this.notifier.dispatch(EditorEventType.DisplayResized, {
344
+ kind: EditorEventType.DisplayResized,
345
+ newSize: Vec2.of(
346
+ this.display.width,
347
+ this.display.height
348
+ ),
349
+ });
350
+ this.queueRerender();
351
+ });
352
+
353
+ this.accessibilityControlArea.addEventListener('input', () => {
354
+ this.accessibilityControlArea.value = '';
355
+ });
356
+
357
+ document.addEventListener('copy', evt => {
358
+ if (!this.isEventSink(document.querySelector(':focus'))) {
359
+ return;
360
+ }
361
+
362
+ const clipboardData = evt.clipboardData;
363
+
364
+ if (this.toolController.dispatchInputEvent({
365
+ kind: InputEvtType.CopyEvent,
366
+ setData: (mime, data) => {
367
+ clipboardData?.setData(mime, data);
368
+ },
369
+ })) {
370
+ evt.preventDefault();
371
+ }
372
+ });
373
+
374
+ document.addEventListener('paste', evt => {
375
+ this.handlePaste(evt);
376
+ });
377
+ }
378
+
379
+ private pointers: Record<number, Pointer> = {};
380
+ private getPointerList() {
381
+ const nowTime = (new Date()).getTime();
382
+
383
+ const res: Pointer[] = [];
384
+ for (const id in this.pointers) {
385
+ const maxUnupdatedTime = 2000; // Maximum time without a pointer update (ms)
386
+ if (this.pointers[id] && (nowTime - this.pointers[id].timeStamp) < maxUnupdatedTime) {
387
+ res.push(this.pointers[id]);
388
+ }
389
+ }
390
+ return res;
391
+ }
392
+
393
+ /**
394
+ * Dispatches a `PointerEvent` to the editor. The target element for `evt` must have the same top left
395
+ * as the content of the editor.
396
+ */
397
+ public handleHTMLPointerEvent(eventType: 'pointerdown'|'pointermove'|'pointerup'|'pointercancel', evt: PointerEvent): boolean {
398
+ const eventsRelativeTo = this.renderingRegion;
399
+ const eventTarget = (evt.target as HTMLElement|null) ?? this.renderingRegion;
400
+
401
+ if (eventType === 'pointerdown') {
402
+ const pointer = Pointer.ofEvent(evt, true, this.viewport, eventsRelativeTo);
403
+ this.pointers[pointer.id] = pointer;
404
+
405
+ eventTarget.setPointerCapture(pointer.id);
314
406
  const event: PointerEvt = {
315
407
  kind: InputEvtType.PointerDownEvt,
316
408
  current: pointer,
317
- allPointers: getPointerList(),
409
+ allPointers: this.getPointerList(),
318
410
  };
319
411
  this.toolController.dispatchInputEvent(event);
320
412
 
321
413
  return true;
322
- });
323
-
324
- this.renderingRegion.addEventListener('pointermove', evt => {
414
+ }
415
+ else if (eventType === 'pointermove') {
325
416
  const pointer = Pointer.ofEvent(
326
- evt, pointers[evt.pointerId]?.down ?? false, this.viewport
417
+ evt, this.pointers[evt.pointerId]?.down ?? false, this.viewport, eventsRelativeTo
327
418
  );
328
419
  if (pointer.down) {
329
- const prevData = pointers[pointer.id];
420
+ const prevData = this.pointers[pointer.id];
330
421
 
331
422
  if (prevData) {
332
423
  const distanceMoved = pointer.screenPos.minus(prevData.screenPos).magnitude();
333
424
 
334
425
  // If the pointer moved less than two pixels, don't send a new event.
335
426
  if (distanceMoved < 2) {
336
- return;
427
+ return false;
337
428
  }
338
429
  }
339
430
 
340
- pointers[pointer.id] = pointer;
431
+ this.pointers[pointer.id] = pointer;
341
432
  if (this.toolController.dispatchInputEvent({
342
433
  kind: InputEvtType.PointerMoveEvt,
343
434
  current: pointer,
344
- allPointers: getPointerList(),
435
+ allPointers: this.getPointerList(),
345
436
  })) {
346
437
  evt.preventDefault();
347
438
  }
348
439
  }
349
- });
350
-
351
- const pointerEnd = (evt: PointerEvent) => {
352
- const pointer = Pointer.ofEvent(evt, false, this.viewport);
353
- if (!pointers[pointer.id]) {
354
- return;
440
+ return true;
441
+ }
442
+ else if (eventType === 'pointercancel' || eventType === 'pointerup') {
443
+ const pointer = Pointer.ofEvent(evt, false, this.viewport, eventsRelativeTo);
444
+ if (!this.pointers[pointer.id]) {
445
+ return false;
355
446
  }
356
447
 
357
- pointers[pointer.id] = pointer;
358
- this.renderingRegion.releasePointerCapture(pointer.id);
448
+ this.pointers[pointer.id] = pointer;
449
+ eventTarget.releasePointerCapture(pointer.id);
359
450
  if (this.toolController.dispatchInputEvent({
360
451
  kind: InputEvtType.PointerUpEvt,
361
452
  current: pointer,
362
- allPointers: getPointerList(),
453
+ allPointers: this.getPointerList(),
363
454
  })) {
364
455
  evt.preventDefault();
365
456
  }
366
- delete pointers[pointer.id];
367
- };
457
+ delete this.pointers[pointer.id];
458
+ return true;
459
+ }
368
460
 
369
- this.renderingRegion.addEventListener('pointerup', evt => {
370
- pointerEnd(evt);
371
- });
461
+ return eventType;
462
+ }
372
463
 
373
- this.renderingRegion.addEventListener('pointercancel', evt => {
374
- pointerEnd(evt);
375
- });
464
+ private isEventSink(evtTarget: Element|EventTarget|null) {
465
+ let currentElem: Element|null = evtTarget as Element|null;
466
+ while (currentElem !== null) {
467
+ for (const elem of this.eventListenerTargets) {
468
+ if (elem === currentElem) {
469
+ return true;
470
+ }
471
+ }
376
472
 
377
- this.handleKeyEventsFrom(this.renderingRegion);
473
+ currentElem = (currentElem as Element).parentElement;
474
+ }
475
+ return false;
476
+ }
378
477
 
379
- this.container.addEventListener('wheel', evt => {
380
- let delta = Vec3.of(evt.deltaX, evt.deltaY, evt.deltaZ);
478
+ private async handlePaste(evt: DragEvent|ClipboardEvent) {
479
+ const target = document.querySelector(':focus') ?? evt.target;
480
+ if (!this.isEventSink(target)) {
481
+ return;
482
+ }
381
483
 
382
- // Process wheel events if the ctrl key is down, even if disabled -- we do want to handle
383
- // pinch-zooming.
384
- if (!evt.ctrlKey) {
385
- if (!this.settings.wheelEventsEnabled) {
484
+ const clipboardData: DataTransfer = (evt as any).dataTransfer ?? (evt as any).clipboardData;
485
+ if (!clipboardData) {
486
+ return;
487
+ }
488
+
489
+ // Handle SVG files (prefer to PNG/JPEG)
490
+ for (const file of clipboardData.files) {
491
+ if (file.type.toLowerCase() === 'image/svg+xml') {
492
+ const text = await file.text();
493
+ if (this.toolController.dispatchInputEvent({
494
+ kind: InputEvtType.PasteEvent,
495
+ mime: file.type,
496
+ data: text,
497
+ })) {
498
+ evt.preventDefault();
386
499
  return;
387
- } else if (this.settings.wheelEventsEnabled === 'only-if-focused') {
388
- const focusedChild = this.container.querySelector(':focus');
500
+ }
501
+ }
502
+ }
389
503
 
390
- if (!focusedChild) {
504
+ // Handle image files.
505
+ for (const file of clipboardData.files) {
506
+ const fileType = file.type.toLowerCase();
507
+ if (fileType === 'image/png' || fileType === 'image/jpg') {
508
+ const reader = new FileReader();
509
+
510
+ this.showLoadingWarning(0);
511
+ try {
512
+ const data = await new Promise((resolve: (result: string|null)=>void, reject) => {
513
+ reader.onload = () => resolve(reader.result as string|null);
514
+ reader.onerror = reject;
515
+ reader.onabort = reject;
516
+ reader.onprogress = (evt) => {
517
+ this.showLoadingWarning(evt.loaded / evt.total);
518
+ };
519
+
520
+ reader.readAsDataURL(file);
521
+ });
522
+ if (data && this.toolController.dispatchInputEvent({
523
+ kind: InputEvtType.PasteEvent,
524
+ mime: fileType,
525
+ data: data,
526
+ })) {
527
+ evt.preventDefault();
528
+ this.hideLoadingWarning();
391
529
  return;
392
530
  }
531
+ } catch (e) {
532
+ console.error('Error reading image:', e);
393
533
  }
534
+ this.hideLoadingWarning();
394
535
  }
536
+ }
395
537
 
396
- if (evt.deltaMode === WheelEvent.DOM_DELTA_LINE) {
397
- delta = delta.times(15);
398
- } else if (evt.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
399
- delta = delta.times(100);
400
- }
538
+ // Supported MIMEs for text data, in order of preference
539
+ const supportedMIMEs = [
540
+ 'image/svg+xml',
541
+ 'text/plain',
542
+ ];
401
543
 
402
- if (evt.ctrlKey) {
403
- delta = Vec3.of(0, 0, evt.deltaY);
404
- }
544
+ for (const mime of supportedMIMEs) {
545
+ const data = clipboardData.getData(mime);
405
546
 
406
- const pos = Vec2.of(evt.offsetX, evt.offsetY);
407
- if (this.toolController.dispatchInputEvent({
408
- kind: InputEvtType.WheelEvt,
409
- delta,
410
- screenPos: pos,
547
+ if (data && this.toolController.dispatchInputEvent({
548
+ kind: InputEvtType.PasteEvent,
549
+ mime,
550
+ data,
411
551
  })) {
412
552
  evt.preventDefault();
413
- return true;
553
+ return;
414
554
  }
415
- return false;
416
- });
555
+ }
556
+ }
417
557
 
418
- this.notifier.on(EditorEventType.DisplayResized, _event => {
419
- this.viewport.updateScreenSize(
420
- Vec2.of(this.display.width, this.display.height)
421
- );
558
+ public handlePointerEventsFrom(elem: HTMLElement, filter?: HTMLPointerEventFilter) {
559
+ // May be required to prevent text selection on iOS/Safari:
560
+ // See https://stackoverflow.com/a/70992717/17055750
561
+ elem.addEventListener('touchstart', evt => evt.preventDefault());
562
+ elem.addEventListener('contextmenu', evt => {
563
+ // Don't show a context menu
564
+ evt.preventDefault();
422
565
  });
423
566
 
424
- window.addEventListener('resize', () => {
425
- this.notifier.dispatch(EditorEventType.DisplayResized, {
426
- kind: EditorEventType.DisplayResized,
427
- newSize: Vec2.of(
428
- this.display.width,
429
- this.display.height
430
- ),
431
- });
432
- this.queueRerender();
433
- });
567
+ const eventNames: HTMLPointerEventType[] = ['pointerdown', 'pointermove', 'pointerup', 'pointercancel'];
568
+ for (const eventName of eventNames) {
569
+ elem.addEventListener(eventName, evt => {
570
+ if (filter && !filter(eventName, evt)) {
571
+ return true;
572
+ }
434
573
 
435
- this.accessibilityControlArea.addEventListener('input', () => {
436
- this.accessibilityControlArea.value = '';
437
- });
574
+ return this.handleHTMLPointerEvent(eventName, evt);
575
+ });
576
+ }
438
577
  }
439
578
 
440
579
  /** Adds event listners for keypresses to `elem` and forwards those events to the editor. */
@@ -451,7 +590,7 @@ export class Editor {
451
590
  evt.preventDefault();
452
591
  } else if (evt.key === 'Escape') {
453
592
  this.renderingRegion.blur();
454
- }
593
+ }
455
594
  });
456
595
 
457
596
  elem.addEventListener('keyup', evt => {
@@ -463,6 +602,18 @@ export class Editor {
463
602
  evt.preventDefault();
464
603
  }
465
604
  });
605
+
606
+ // Allow drop.
607
+ elem.ondragover = evt => {
608
+ evt.preventDefault();
609
+ };
610
+
611
+ elem.ondrop = evt => {
612
+ evt.preventDefault();
613
+ this.handlePaste(evt);
614
+ };
615
+
616
+ this.eventListenerTargets.push(elem);
466
617
  }
467
618
 
468
619
  /** `apply` a command. `command` will be announced for accessibility. */
@@ -481,11 +632,11 @@ export class Editor {
481
632
  * Dispatches a command without announcing it. By default, does not add to history.
482
633
  * Use this to show finalized commands that don't need to have `announceForAccessibility`
483
634
  * called.
484
- *
635
+ *
485
636
  * Prefer `command.apply(editor)` for incomplete commands. `dispatchNoAnnounce` may allow
486
637
  * clients to listen for the application of commands (e.g. `SerializableCommand`s so they can
487
638
  * be sent across the network), while `apply` does not.
488
- *
639
+ *
489
640
  * @example
490
641
  * ```
491
642
  * const addToHistory = false;
@@ -509,6 +660,7 @@ export class Editor {
509
660
  public async asyncApplyOrUnapplyCommands(
510
661
  commands: Command[], apply: boolean, updateChunkSize: number
511
662
  ) {
663
+ console.assert(updateChunkSize > 0);
512
664
  this.display.setDraftMode(true);
513
665
  for (let i = 0; i < commands.length; i += updateChunkSize) {
514
666
  this.showLoadingWarning(i / commands.length);
@@ -626,6 +778,20 @@ export class Editor {
626
778
  return styleSheet;
627
779
  }
628
780
 
781
+ // Dispatch a keyboard event to the currently selected tool.
782
+ // Intended for unit testing
783
+ public sendKeyboardEvent(
784
+ eventType: InputEvtType.KeyPressEvent|InputEvtType.KeyUpEvent,
785
+ key: string,
786
+ ctrlKey: boolean = false
787
+ ) {
788
+ this.toolController.dispatchInputEvent({
789
+ kind: eventType,
790
+ key,
791
+ ctrlKey
792
+ });
793
+ }
794
+
629
795
  // Dispatch a pen event to the currently selected tool.
630
796
  // Intended primarially for unit tests.
631
797
  public sendPenEvent(
@@ -735,12 +901,12 @@ export class Editor {
735
901
 
736
902
  /**
737
903
  * Alias for loadFrom(SVGLoader.fromString).
738
- *
904
+ *
739
905
  * This is particularly useful when accessing a bundled version of the editor,
740
906
  * where `SVGLoader.fromString` is unavailable.
741
907
  */
742
- public async loadFromSVG(svgData: string) {
743
- const loader = SVGLoader.fromString(svgData);
908
+ public async loadFromSVG(svgData: string, sanitize: boolean = false) {
909
+ const loader = SVGLoader.fromString(svgData, sanitize);
744
910
  await this.loadFrom(loader);
745
911
  }
746
912
  }
@@ -81,6 +81,8 @@ export default class EditorImage {
81
81
 
82
82
  // A Command that can access private [EditorImage] functionality
83
83
  private static AddElementCommand = class extends SerializableCommand {
84
+ private serializedElem: any;
85
+
84
86
  // If [applyByFlattening], then the rendered content of this element
85
87
  // is present on the display's wet ink canvas. As such, no re-render is necessary
86
88
  // the first time this command is applied (the surfaces are joined instead).
@@ -90,6 +92,10 @@ export default class EditorImage {
90
92
  ) {
91
93
  super('add-element');
92
94
 
95
+ // Store the element's serialization --- .serializeToJSON may be called on this
96
+ // even when this is not at the top of the undo/redo stack.
97
+ this.serializedElem = element.serialize();
98
+
93
99
  if (isNaN(element.getBBox().area)) {
94
100
  throw new Error('Elements in the image cannot have NaN bounding boxes');
95
101
  }
@@ -118,7 +124,7 @@ export default class EditorImage {
118
124
 
119
125
  protected serializeToJSON() {
120
126
  return {
121
- elemData: this.element.serialize(),
127
+ elemData: this.serializedElem,
122
128
  };
123
129
  }
124
130
 
package/src/Pointer.ts CHANGED
@@ -36,9 +36,14 @@ export default class Pointer {
36
36
  ) {
37
37
  }
38
38
 
39
- // Creates a Pointer from a DOM event.
40
- public static ofEvent(evt: PointerEvent, isDown: boolean, viewport: Viewport): Pointer {
41
- const screenPos = Vec2.of(evt.offsetX, evt.offsetY);
39
+ // Creates a Pointer from a DOM event. If `relativeTo` is given, (0, 0) in screen coordinates is
40
+ // considered the top left of `relativeTo`.
41
+ public static ofEvent(evt: PointerEvent, isDown: boolean, viewport: Viewport, relativeTo?: HTMLElement): Pointer {
42
+ let screenPos = Vec2.of(evt.clientX, evt.clientY);
43
+ if (relativeTo) {
44
+ const bbox = relativeTo.getBoundingClientRect();
45
+ screenPos = screenPos.minus(Vec2.of(bbox.left, bbox.top));
46
+ }
42
47
 
43
48
  const pointerTypeToDevice: Record<string, PointerDevice> = {
44
49
  'mouse': PointerDevice.PrimaryButtonMouse,