js-draw 0.3.0 → 0.3.2

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 (113) hide show
  1. package/.github/ISSUE_TEMPLATE/translation.md +4 -1
  2. package/CHANGELOG.md +15 -0
  3. package/dist/bundle.js +1 -1
  4. package/dist/src/Editor.d.ts +4 -1
  5. package/dist/src/Editor.js +117 -2
  6. package/dist/src/EditorImage.js +4 -1
  7. package/dist/src/SVGLoader.d.ts +4 -1
  8. package/dist/src/SVGLoader.js +78 -33
  9. package/dist/src/UndoRedoHistory.d.ts +1 -0
  10. package/dist/src/UndoRedoHistory.js +6 -0
  11. package/dist/src/Viewport.d.ts +1 -0
  12. package/dist/src/Viewport.js +12 -4
  13. package/dist/src/commands/lib.d.ts +2 -1
  14. package/dist/src/commands/lib.js +2 -1
  15. package/dist/src/commands/localization.d.ts +1 -0
  16. package/dist/src/commands/localization.js +1 -0
  17. package/dist/src/commands/uniteCommands.d.ts +4 -0
  18. package/dist/src/commands/uniteCommands.js +105 -0
  19. package/dist/src/components/AbstractComponent.d.ts +2 -0
  20. package/dist/src/components/AbstractComponent.js +41 -5
  21. package/dist/src/components/ImageComponent.d.ts +27 -0
  22. package/dist/src/components/ImageComponent.js +129 -0
  23. package/dist/src/components/Stroke.js +11 -6
  24. package/dist/src/components/builders/FreehandLineBuilder.js +7 -7
  25. package/dist/src/components/lib.d.ts +4 -2
  26. package/dist/src/components/lib.js +4 -2
  27. package/dist/src/components/localization.d.ts +2 -0
  28. package/dist/src/components/localization.js +2 -0
  29. package/dist/src/math/LineSegment2.d.ts +4 -0
  30. package/dist/src/math/LineSegment2.js +9 -0
  31. package/dist/src/math/Path.d.ts +5 -1
  32. package/dist/src/math/Path.js +89 -7
  33. package/dist/src/math/Rect2.js +1 -1
  34. package/dist/src/math/Triangle.d.ts +11 -0
  35. package/dist/src/math/Triangle.js +19 -0
  36. package/dist/src/rendering/Display.js +2 -2
  37. package/dist/src/rendering/localization.d.ts +3 -0
  38. package/dist/src/rendering/localization.js +3 -0
  39. package/dist/src/rendering/renderers/AbstractRenderer.d.ts +9 -1
  40. package/dist/src/rendering/renderers/CanvasRenderer.d.ts +2 -1
  41. package/dist/src/rendering/renderers/CanvasRenderer.js +7 -0
  42. package/dist/src/rendering/renderers/DummyRenderer.d.ts +3 -1
  43. package/dist/src/rendering/renderers/DummyRenderer.js +5 -0
  44. package/dist/src/rendering/renderers/SVGRenderer.d.ts +14 -12
  45. package/dist/src/rendering/renderers/SVGRenderer.js +71 -87
  46. package/dist/src/rendering/renderers/TextOnlyRenderer.d.ts +3 -1
  47. package/dist/src/rendering/renderers/TextOnlyRenderer.js +8 -1
  48. package/dist/src/toolbar/HTMLToolbar.d.ts +1 -0
  49. package/dist/src/toolbar/HTMLToolbar.js +1 -0
  50. package/dist/src/toolbar/widgets/BaseWidget.d.ts +3 -0
  51. package/dist/src/toolbar/widgets/BaseWidget.js +21 -1
  52. package/dist/src/tools/BaseTool.d.ts +4 -1
  53. package/dist/src/tools/BaseTool.js +12 -0
  54. package/dist/src/tools/PasteHandler.d.ts +16 -0
  55. package/dist/src/tools/PasteHandler.js +142 -0
  56. package/dist/src/tools/Pen.d.ts +2 -1
  57. package/dist/src/tools/Pen.js +16 -0
  58. package/dist/src/tools/SelectionTool.d.ts +7 -1
  59. package/dist/src/tools/SelectionTool.js +63 -5
  60. package/dist/src/tools/ToolController.d.ts +1 -0
  61. package/dist/src/tools/ToolController.js +45 -29
  62. package/dist/src/tools/ToolSwitcherShortcut.d.ts +8 -0
  63. package/dist/src/tools/ToolSwitcherShortcut.js +26 -0
  64. package/dist/src/tools/lib.d.ts +2 -0
  65. package/dist/src/tools/lib.js +2 -0
  66. package/dist/src/tools/localization.d.ts +4 -0
  67. package/dist/src/tools/localization.js +4 -0
  68. package/dist/src/types.d.ts +21 -4
  69. package/dist/src/types.js +3 -0
  70. package/package.json +2 -2
  71. package/src/Editor.ts +131 -2
  72. package/src/EditorImage.ts +7 -1
  73. package/src/SVGLoader.ts +90 -36
  74. package/src/UndoRedoHistory.test.ts +33 -0
  75. package/src/UndoRedoHistory.ts +8 -0
  76. package/src/Viewport.ts +13 -4
  77. package/src/commands/lib.ts +2 -0
  78. package/src/commands/localization.ts +2 -0
  79. package/src/commands/uniteCommands.test.ts +23 -0
  80. package/src/commands/uniteCommands.ts +121 -0
  81. package/src/components/AbstractComponent.ts +55 -9
  82. package/src/components/ImageComponent.ts +153 -0
  83. package/src/components/Stroke.test.ts +5 -0
  84. package/src/components/Stroke.ts +13 -7
  85. package/src/components/builders/FreehandLineBuilder.ts +7 -7
  86. package/src/components/lib.ts +7 -2
  87. package/src/components/localization.ts +4 -0
  88. package/src/math/LineSegment2.test.ts +9 -0
  89. package/src/math/LineSegment2.ts +13 -0
  90. package/src/math/Path.test.ts +53 -0
  91. package/src/math/Path.toString.test.ts +4 -2
  92. package/src/math/Path.ts +109 -11
  93. package/src/math/Rect2.ts +1 -1
  94. package/src/math/Triangle.ts +29 -0
  95. package/src/rendering/Display.ts +2 -2
  96. package/src/rendering/localization.ts +6 -0
  97. package/src/rendering/renderers/AbstractRenderer.ts +17 -0
  98. package/src/rendering/renderers/CanvasRenderer.ts +10 -1
  99. package/src/rendering/renderers/DummyRenderer.ts +6 -1
  100. package/src/rendering/renderers/SVGRenderer.ts +76 -101
  101. package/src/rendering/renderers/TextOnlyRenderer.ts +10 -2
  102. package/src/toolbar/HTMLToolbar.ts +1 -1
  103. package/src/toolbar/types.ts +1 -1
  104. package/src/toolbar/widgets/BaseWidget.ts +27 -1
  105. package/src/tools/BaseTool.ts +17 -1
  106. package/src/tools/PasteHandler.ts +156 -0
  107. package/src/tools/Pen.ts +20 -1
  108. package/src/tools/SelectionTool.ts +80 -8
  109. package/src/tools/ToolController.ts +60 -46
  110. package/src/tools/ToolSwitcherShortcut.ts +34 -0
  111. package/src/tools/lib.ts +2 -0
  112. package/src/tools/localization.ts +10 -0
  113. package/src/types.ts +29 -3
package/src/Editor.ts CHANGED
@@ -118,6 +118,7 @@ export class Editor {
118
118
  private loadingWarning: HTMLElement;
119
119
  private accessibilityAnnounceArea: HTMLElement;
120
120
  private accessibilityControlArea: HTMLTextAreaElement;
121
+ private eventListenerTargets: HTMLElement[] = [];
121
122
 
122
123
  private settings: EditorSettings;
123
124
 
@@ -435,6 +436,121 @@ export class Editor {
435
436
  this.accessibilityControlArea.addEventListener('input', () => {
436
437
  this.accessibilityControlArea.value = '';
437
438
  });
439
+
440
+ document.addEventListener('copy', evt => {
441
+ if (!this.isEventSink(document.querySelector(':focus'))) {
442
+ return;
443
+ }
444
+
445
+ const clipboardData = evt.clipboardData;
446
+
447
+ if (this.toolController.dispatchInputEvent({
448
+ kind: InputEvtType.CopyEvent,
449
+ setData: (mime, data) => {
450
+ clipboardData?.setData(mime, data);
451
+ },
452
+ })) {
453
+ evt.preventDefault();
454
+ }
455
+ });
456
+
457
+ document.addEventListener('paste', evt => {
458
+ this.handlePaste(evt);
459
+ });
460
+ }
461
+
462
+ private isEventSink(evtTarget: Element|EventTarget|null) {
463
+ let currentElem: Element|null = evtTarget as Element|null;
464
+ while (currentElem !== null) {
465
+ for (const elem of this.eventListenerTargets) {
466
+ if (elem === currentElem) {
467
+ return true;
468
+ }
469
+ }
470
+
471
+ currentElem = (currentElem as Element).parentElement;
472
+ }
473
+ return false;
474
+ }
475
+
476
+ private async handlePaste(evt: DragEvent|ClipboardEvent) {
477
+ const target = document.querySelector(':focus') ?? evt.target;
478
+ if (!this.isEventSink(target)) {
479
+ return;
480
+ }
481
+
482
+ const clipboardData: DataTransfer = (evt as any).dataTransfer ?? (evt as any).clipboardData;
483
+ if (!clipboardData) {
484
+ return;
485
+ }
486
+
487
+ // Handle SVG files (prefer to PNG/JPEG)
488
+ for (const file of clipboardData.files) {
489
+ if (file.type.toLowerCase() === 'image/svg+xml') {
490
+ const text = await file.text();
491
+ if (this.toolController.dispatchInputEvent({
492
+ kind: InputEvtType.PasteEvent,
493
+ mime: file.type,
494
+ data: text,
495
+ })) {
496
+ evt.preventDefault();
497
+ return;
498
+ }
499
+ }
500
+ }
501
+
502
+ // Handle image files.
503
+ for (const file of clipboardData.files) {
504
+ const fileType = file.type.toLowerCase();
505
+ if (fileType === 'image/png' || fileType === 'image/jpg') {
506
+ const reader = new FileReader();
507
+
508
+ this.showLoadingWarning(0);
509
+ try {
510
+ const data = await new Promise((resolve: (result: string|null)=>void, reject) => {
511
+ reader.onload = () => resolve(reader.result as string|null);
512
+ reader.onerror = reject;
513
+ reader.onabort = reject;
514
+ reader.onprogress = (evt) => {
515
+ this.showLoadingWarning(evt.loaded / evt.total);
516
+ };
517
+
518
+ reader.readAsDataURL(file);
519
+ });
520
+ if (data && this.toolController.dispatchInputEvent({
521
+ kind: InputEvtType.PasteEvent,
522
+ mime: fileType,
523
+ data: data,
524
+ })) {
525
+ evt.preventDefault();
526
+ this.hideLoadingWarning();
527
+ return;
528
+ }
529
+ } catch (e) {
530
+ console.error('Error reading image:', e);
531
+ }
532
+ this.hideLoadingWarning();
533
+ }
534
+ }
535
+
536
+ // Supported MIMEs for text data, in order of preference
537
+ const supportedMIMEs = [
538
+ 'image/svg+xml',
539
+ 'text/plain',
540
+ ];
541
+
542
+ for (const mime of supportedMIMEs) {
543
+ const data = clipboardData.getData(mime);
544
+
545
+ if (data && this.toolController.dispatchInputEvent({
546
+ kind: InputEvtType.PasteEvent,
547
+ mime,
548
+ data,
549
+ })) {
550
+ evt.preventDefault();
551
+ return;
552
+ }
553
+ }
438
554
  }
439
555
 
440
556
  /** Adds event listners for keypresses to `elem` and forwards those events to the editor. */
@@ -463,6 +579,18 @@ export class Editor {
463
579
  evt.preventDefault();
464
580
  }
465
581
  });
582
+
583
+ // Allow drop.
584
+ elem.ondragover = evt => {
585
+ evt.preventDefault();
586
+ };
587
+
588
+ elem.ondrop = evt => {
589
+ evt.preventDefault();
590
+ this.handlePaste(evt);
591
+ };
592
+
593
+ this.eventListenerTargets.push(elem);
466
594
  }
467
595
 
468
596
  /** `apply` a command. `command` will be announced for accessibility. */
@@ -509,6 +637,7 @@ export class Editor {
509
637
  public async asyncApplyOrUnapplyCommands(
510
638
  commands: Command[], apply: boolean, updateChunkSize: number
511
639
  ) {
640
+ console.assert(updateChunkSize > 0);
512
641
  this.display.setDraftMode(true);
513
642
  for (let i = 0; i < commands.length; i += updateChunkSize) {
514
643
  this.showLoadingWarning(i / commands.length);
@@ -739,8 +868,8 @@ export class Editor {
739
868
  * This is particularly useful when accessing a bundled version of the editor,
740
869
  * where `SVGLoader.fromString` is unavailable.
741
870
  */
742
- public async loadFromSVG(svgData: string) {
743
- const loader = SVGLoader.fromString(svgData);
871
+ public async loadFromSVG(svgData: string, sanitize: boolean = false) {
872
+ const loader = SVGLoader.fromString(svgData, sanitize);
744
873
  await this.loadFrom(loader);
745
874
  }
746
875
  }
@@ -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/SVGLoader.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import Color4 from './Color4';
2
2
  import AbstractComponent from './components/AbstractComponent';
3
+ import ImageComponent from './components/ImageComponent';
3
4
  import Stroke from './components/Stroke';
4
5
  import SVGGlobalAttributesObject from './components/SVGGlobalAttributesObject';
5
6
  import Text, { TextStyle } from './components/Text';
@@ -36,7 +37,8 @@ export default class SVGLoader implements ImageLoader {
36
37
  private totalToProcess: number = 0;
37
38
  private rootViewBox: Rect2|null;
38
39
 
39
- private constructor(private source: SVGSVGElement, private onFinish?: OnFinishListener) {
40
+ private constructor(
41
+ private source: SVGSVGElement, private onFinish?: OnFinishListener, private readonly storeUnknown: boolean = true) {
40
42
  }
41
43
 
42
44
  private getStyle(node: SVGElement) {
@@ -108,6 +110,10 @@ export default class SVGLoader implements ImageLoader {
108
110
  supportedAttrs: Set<string>,
109
111
  supportedStyleAttrs?: Set<string>
110
112
  ) {
113
+ if (!this.storeUnknown) {
114
+ return;
115
+ }
116
+
111
117
  for (const attr of node.getAttributeNames()) {
112
118
  if (supportedAttrs.has(attr) || (attr === 'style' && supportedStyleAttrs)) {
113
119
  continue;
@@ -161,11 +167,49 @@ export default class SVGLoader implements ImageLoader {
161
167
  '\nAdding as an unknown object.'
162
168
  );
163
169
 
164
- elem = new UnknownSVGObject(node);
170
+ if (this.storeUnknown) {
171
+ elem = new UnknownSVGObject(node);
172
+ } else {
173
+ return;
174
+ }
165
175
  }
166
176
  this.onAddComponent?.(elem);
167
177
  }
168
178
 
179
+ // If given, 'supportedAttrs' will have x, y, etc. attributes that were used in computing the transform added to it,
180
+ // to prevent storing duplicate transform information when saving the component.
181
+ private getTransform(elem: SVGElement, supportedAttrs?: string[], computedStyles?: CSSStyleDeclaration): Mat33 {
182
+ computedStyles ??= window.getComputedStyle(elem);
183
+
184
+ let transformProperty = computedStyles.transform;
185
+ if (transformProperty === '' || transformProperty === 'none') {
186
+ transformProperty = elem.style.transform || 'none';
187
+ }
188
+
189
+ // Prefer the actual .style.transform
190
+ // to the computed stylesheet -- in some browsers, the computedStyles version
191
+ // can have lower precision.
192
+ let transform;
193
+ try {
194
+ transform = Mat33.fromCSSMatrix(elem.style.transform);
195
+ } catch(_e) {
196
+ transform = Mat33.fromCSSMatrix(transformProperty);
197
+ }
198
+
199
+ const elemX = elem.getAttribute('x');
200
+ const elemY = elem.getAttribute('y');
201
+ if (elemX && elemY) {
202
+ const x = parseFloat(elemX);
203
+ const y = parseFloat(elemY);
204
+ if (!isNaN(x) && !isNaN(y)) {
205
+ supportedAttrs?.push('x', 'y');
206
+ transform = transform.rightMul(Mat33.translation(Vec2.of(x, y)));
207
+ }
208
+ }
209
+
210
+ return transform;
211
+ }
212
+
169
213
  private makeText(elem: SVGTextElement|SVGTSpanElement): Text {
170
214
  const contentList: Array<Text|string> = [];
171
215
  for (const child of elem.childNodes) {
@@ -205,33 +249,8 @@ export default class SVGLoader implements ImageLoader {
205
249
  },
206
250
  };
207
251
 
208
- let transformProperty = computedStyles.transform;
209
- if (transformProperty === '' || transformProperty === 'none') {
210
- transformProperty = elem.style.transform || 'none';
211
- }
212
-
213
- // Compute transform matrix. Prefer the actual .style.transform
214
- // to the computed stylesheet -- in some browsers, the computedStyles version
215
- // can have lower precision.
216
- let transform;
217
- try {
218
- transform = Mat33.fromCSSMatrix(elem.style.transform);
219
- } catch(_e) {
220
- transform = Mat33.fromCSSMatrix(transformProperty);
221
- }
222
-
223
- const supportedAttrs = [];
224
- const elemX = elem.getAttribute('x');
225
- const elemY = elem.getAttribute('y');
226
- if (elemX && elemY) {
227
- const x = parseFloat(elemX);
228
- const y = parseFloat(elemY);
229
- if (!isNaN(x) && !isNaN(y)) {
230
- supportedAttrs.push('x', 'y');
231
- transform = transform.rightMul(Mat33.translation(Vec2.of(x, y)));
232
- }
233
- }
234
-
252
+ const supportedAttrs: string[] = [];
253
+ const transform = this.getTransform(elem, supportedAttrs, computedStyles);
235
254
  const result = new Text(contentList, transform, style);
236
255
  this.attachUnrecognisedAttrs(
237
256
  result,
@@ -248,14 +267,38 @@ export default class SVGLoader implements ImageLoader {
248
267
  const textElem = this.makeText(elem);
249
268
  this.onAddComponent?.(textElem);
250
269
  } catch (e) {
251
- console.error('Invalid text object in node', elem, '. Adding as an unknown object. Error:', e);
270
+ console.error('Invalid text object in node', elem, '. Continuing.... Error:', e);
271
+ this.addUnknownNode(elem);
272
+ }
273
+ }
274
+
275
+ private async addImage(elem: SVGImageElement) {
276
+ const image = new Image();
277
+ image.src = elem.getAttribute('xlink:href') ?? elem.href.baseVal;
278
+
279
+ try {
280
+ const supportedAttrs: string[] = [];
281
+ const transform = this.getTransform(elem, supportedAttrs);
282
+ const imageElem = await ImageComponent.fromImage(image, transform);
283
+ this.attachUnrecognisedAttrs(
284
+ imageElem,
285
+ elem,
286
+ new Set(supportedAttrs),
287
+ new Set([ 'transform' ])
288
+ );
289
+
290
+ this.onAddComponent?.(imageElem);
291
+ } catch (e) {
292
+ console.error('Error loading image:', e, '. Element: ', elem, '. Continuing...');
252
293
  this.addUnknownNode(elem);
253
294
  }
254
295
  }
255
296
 
256
297
  private addUnknownNode(node: SVGElement) {
257
- const component = new UnknownSVGObject(node);
258
- this.onAddComponent?.(component);
298
+ if (this.storeUnknown) {
299
+ const component = new UnknownSVGObject(node);
300
+ this.onAddComponent?.(component);
301
+ }
259
302
  }
260
303
 
261
304
  private updateViewBox(node: SVGSVGElement) {
@@ -280,7 +323,9 @@ export default class SVGLoader implements ImageLoader {
280
323
  }
281
324
 
282
325
  private updateSVGAttrs(node: SVGSVGElement) {
283
- this.onAddComponent?.(new SVGGlobalAttributesObject(this.getSourceAttrs(node)));
326
+ if (this.storeUnknown) {
327
+ this.onAddComponent?.(new SVGGlobalAttributesObject(this.getSourceAttrs(node)));
328
+ }
284
329
  }
285
330
 
286
331
  private async visit(node: Element) {
@@ -298,6 +343,12 @@ export default class SVGLoader implements ImageLoader {
298
343
  this.addText(node as SVGTextElement);
299
344
  visitChildren = false;
300
345
  break;
346
+ case 'image':
347
+ await this.addImage(node as SVGImageElement);
348
+
349
+ // Images should not have children.
350
+ visitChildren = false;
351
+ break;
301
352
  case 'svg':
302
353
  this.updateViewBox(node as SVGSVGElement);
303
354
  this.updateSVGAttrs(node as SVGSVGElement);
@@ -305,7 +356,9 @@ export default class SVGLoader implements ImageLoader {
305
356
  default:
306
357
  console.warn('Unknown SVG element,', node);
307
358
  if (!(node instanceof SVGElement)) {
308
- console.warn('Element', node, 'is not an SVGElement! Continuing anyway.');
359
+ console.warn(
360
+ 'Element', node, 'is not an SVGElement!', this.storeUnknown ? 'Continuing anyway.' : 'Skipping.'
361
+ );
309
362
  }
310
363
 
311
364
  this.addUnknownNode(node as SVGElement);
@@ -354,7 +407,8 @@ export default class SVGLoader implements ImageLoader {
354
407
  }
355
408
 
356
409
  // TODO: Handling unsafe data! Tripple-check that this is secure!
357
- public static fromString(text: string): SVGLoader {
410
+ // @param sanitize - if `true`, don't store unknown attributes.
411
+ public static fromString(text: string, sanitize: boolean = false): SVGLoader {
358
412
  const sandbox = document.createElement('iframe');
359
413
  sandbox.src = 'about:blank';
360
414
  sandbox.setAttribute('sandbox', 'allow-same-origin');
@@ -400,6 +454,6 @@ export default class SVGLoader implements ImageLoader {
400
454
  return new SVGLoader(svgElem, () => {
401
455
  svgElem.remove();
402
456
  sandbox.remove();
403
- });
457
+ }, !sanitize);
404
458
  }
405
459
  }
@@ -0,0 +1,33 @@
1
+
2
+ import { Color4, EditorImage, Path, Stroke, Mat33, Vec2 } from './lib';
3
+ import createEditor from './testing/createEditor';
4
+
5
+ describe('UndoRedoHistory', () => {
6
+ it('should keep history size below maximum', () => {
7
+ const editor = createEditor();
8
+ const stroke = new Stroke([ Path.fromString('m0,0 10,10').toRenderable({ fill: Color4.red }) ]);
9
+ editor.dispatch(EditorImage.addElement(stroke));
10
+
11
+ for (let i = 0; i < editor.history['maxUndoRedoStackSize'] + 10; i++) {
12
+ editor.dispatch(stroke.transformBy(Mat33.translation(Vec2.of(1, 1))));
13
+ }
14
+
15
+ expect(editor.history.undoStackSize).toBeLessThan(editor.history['maxUndoRedoStackSize']);
16
+ expect(editor.history.undoStackSize).toBeGreaterThan(editor.history['maxUndoRedoStackSize'] / 10);
17
+ expect(editor.history.redoStackSize).toBe(0);
18
+
19
+ const origUndoStackSize = editor.history.undoStackSize;
20
+ while (editor.history.undoStackSize > 0) {
21
+ editor.history.undo();
22
+ }
23
+
24
+ // After undoing as much as possible, the stroke should still be present
25
+ expect(editor.image.findParent(stroke)).not.toBe(null);
26
+
27
+ // Undoing again shouldn't cause issues.
28
+ editor.history.undo();
29
+ expect(editor.image.findParent(stroke)).not.toBe(null);
30
+
31
+ expect(editor.history.redoStackSize).toBe(origUndoStackSize);
32
+ });
33
+ });
@@ -9,6 +9,8 @@ class UndoRedoHistory {
9
9
  private undoStack: Command[];
10
10
  private redoStack: Command[];
11
11
 
12
+ private maxUndoRedoStackSize: number = 700;
13
+
12
14
  // @internal
13
15
  public constructor(
14
16
  private readonly editor: Editor,
@@ -39,6 +41,12 @@ class UndoRedoHistory {
39
41
  }
40
42
  this.redoStack = [];
41
43
 
44
+ if (this.undoStack.length > this.maxUndoRedoStackSize) {
45
+ const removeAtOnceCount = 10;
46
+ const removedElements = this.undoStack.splice(0, removeAtOnceCount);
47
+ removedElements.forEach(elem => elem.onDrop(this.editor));
48
+ }
49
+
42
50
  this.fireUpdateEvent();
43
51
  this.editor.notifier.dispatch(EditorEventType.CommandDone, {
44
52
  kind: EditorEventType.CommandDone,
package/src/Viewport.ts CHANGED
@@ -92,6 +92,7 @@ export class Viewport {
92
92
  this.screenRect = this.screenRect.resizedTo(screenSize);
93
93
  }
94
94
 
95
+ // Get the screen's visible region transformed into canvas space.
95
96
  public get visibleRect(): Rect2 {
96
97
  return this.screenRect.transformedBoundingBox(this.inverseTransform);
97
98
  }
@@ -180,10 +181,8 @@ export class Viewport {
180
181
  return Viewport.roundPoint(point, 1 / this.getScaleFactor());
181
182
  }
182
183
 
183
- // Returns a Command that transforms the view such that [rect] is visible, and perhaps
184
- // centered in the viewport.
185
- // Returns null if no transformation is necessary
186
- public zoomTo(toMakeVisible: Rect2, allowZoomIn: boolean = true, allowZoomOut: boolean = true): Command {
184
+ // Computes and returns an affine transformation that makes `toMakeVisible` visible and roughly centered on the screen.
185
+ public computeZoomToTransform(toMakeVisible: Rect2, allowZoomIn: boolean = true, allowZoomOut: boolean = true): Mat33 {
187
186
  let transform = Mat33.identity;
188
187
 
189
188
  if (toMakeVisible.w === 0 || toMakeVisible.h === 0) {
@@ -237,6 +236,16 @@ export class Viewport {
237
236
  transform = Mat33.identity;
238
237
  }
239
238
 
239
+ return transform;
240
+ }
241
+
242
+ // Returns a Command that transforms the view such that [rect] is visible, and perhaps
243
+ // centered in the viewport.
244
+ // Returns null if no transformation is necessary
245
+ //
246
+ // @see {@link computeZoomToTransform}
247
+ public zoomTo(toMakeVisible: Rect2, allowZoomIn: boolean = true, allowZoomOut: boolean = true): Command {
248
+ const transform = this.computeZoomToTransform(toMakeVisible, allowZoomIn, allowZoomOut);
240
249
  return new Viewport.ViewportTransform(transform);
241
250
  }
242
251
  }
@@ -3,6 +3,7 @@ import Duplicate from './Duplicate';
3
3
  import Erase from './Erase';
4
4
  import invertCommand from './invertCommand';
5
5
  import SerializableCommand from './SerializableCommand';
6
+ import uniteCommands from './uniteCommands';
6
7
 
7
8
  export {
8
9
  Command,
@@ -11,4 +12,5 @@ export {
11
12
  SerializableCommand,
12
13
 
13
14
  invertCommand,
15
+ uniteCommands,
14
16
  };
@@ -18,6 +18,7 @@ export interface CommandLocalization {
18
18
  eraseAction: (elemDescription: string, numElems: number) => string;
19
19
  duplicateAction: (elemDescription: string, count: number)=> string;
20
20
  inverseOf: (actionDescription: string)=> string;
21
+ unionOf: (actionDescription: string, actionCount: number)=> string;
21
22
 
22
23
  selectedElements: (count: number)=>string;
23
24
  }
@@ -29,6 +30,7 @@ export const defaultCommandLocalization: CommandLocalization = {
29
30
  addElementAction: (componentDescription: string) => `Added ${componentDescription}`,
30
31
  eraseAction: (componentDescription: string, numElems: number) => `Erased ${numElems} ${componentDescription}`,
31
32
  duplicateAction: (componentDescription: string, numElems: number) => `Duplicated ${numElems} ${componentDescription}`,
33
+ unionOf: (actionDescription: string, actionCount: number) => `Union: ${actionCount} ${actionDescription}`,
32
34
  inverseOf: (actionDescription: string) => `Inverse of ${actionDescription}`,
33
35
  elements: 'Elements',
34
36
  erasedNoElements: 'Erased nothing',
@@ -0,0 +1,23 @@
1
+
2
+ import { Color4, EditorImage, Mat33, Path, SerializableCommand, StrokeComponent, Vec2 } from '../lib';
3
+ import uniteCommands from './uniteCommands';
4
+ import createEditor from '../testing/createEditor';
5
+
6
+ describe('uniteCommands', () => {
7
+ it('should be serializable and deserializable', () => {
8
+ const editor = createEditor();
9
+ const stroke = new StrokeComponent([ Path.fromString('m0,0 l10,10 h-2 z').toRenderable({ fill: Color4.red }) ]);
10
+ const union = uniteCommands([
11
+ EditorImage.addElement(stroke),
12
+ stroke.transformBy(Mat33.translation(Vec2.of(1, 10))),
13
+ ]);
14
+ const deserialized = SerializableCommand.deserialize(union.serialize(), editor);
15
+
16
+ deserialized.apply(editor);
17
+
18
+ const lookupResult = editor.image.lookupElement(stroke.getId());
19
+ expect(lookupResult).not.toBeNull();
20
+ expect(lookupResult?.getBBox().topLeft).toMatchObject(Vec2.of(1, 10));
21
+ expect(lookupResult?.getBBox().bottomRight).toMatchObject(Vec2.of(11, 20));
22
+ });
23
+ });
@@ -0,0 +1,121 @@
1
+ import Editor from '../Editor';
2
+ import { EditorLocalization } from '../localization';
3
+ import Command from './Command';
4
+ import SerializableCommand from './SerializableCommand';
5
+
6
+
7
+ class NonSerializableUnion extends Command {
8
+ public constructor(private commands: Command[], private applyChunkSize: number|undefined) {
9
+ super();
10
+ }
11
+
12
+ public apply(editor: Editor) {
13
+ if (this.applyChunkSize === undefined) {
14
+ for (const command of this.commands) {
15
+ command.apply(editor);
16
+ }
17
+ } else {
18
+ editor.asyncApplyCommands(this.commands, this.applyChunkSize);
19
+ }
20
+ }
21
+
22
+ public unapply(editor: Editor) {
23
+ if (this.applyChunkSize === undefined) {
24
+ for (const command of this.commands) {
25
+ command.unapply(editor);
26
+ }
27
+ } else {
28
+ editor.asyncUnapplyCommands(this.commands, this.applyChunkSize);
29
+ }
30
+ }
31
+
32
+ public description(editor: Editor, localizationTable: EditorLocalization) {
33
+ const descriptions: string[] = [];
34
+
35
+ let lastDescription: string|null = null;
36
+ let duplicateDescriptionCount: number = 0;
37
+ for (const part of this.commands) {
38
+ const description = part.description(editor, localizationTable);
39
+ if (description !== lastDescription && lastDescription !== null) {
40
+ descriptions.push(localizationTable.unionOf(lastDescription, duplicateDescriptionCount));
41
+ lastDescription = null;
42
+ duplicateDescriptionCount = 0;
43
+ }
44
+
45
+ duplicateDescriptionCount ++;
46
+ lastDescription ??= description;
47
+ }
48
+
49
+ if (duplicateDescriptionCount > 1) {
50
+ descriptions.push(localizationTable.unionOf(lastDescription!, duplicateDescriptionCount));
51
+ } else if (duplicateDescriptionCount === 1) {
52
+ descriptions.push(lastDescription!);
53
+ }
54
+
55
+ return descriptions.join(', ');
56
+ }
57
+ }
58
+
59
+ class SerializableUnion extends SerializableCommand {
60
+ private nonserializableCommand: NonSerializableUnion;
61
+ public constructor(private commands: SerializableCommand[], private applyChunkSize: number|undefined) {
62
+ super('union');
63
+ this.nonserializableCommand = new NonSerializableUnion(commands, applyChunkSize);
64
+ }
65
+
66
+ protected serializeToJSON() {
67
+ return {
68
+ applyChunkSize: this.applyChunkSize,
69
+ data: this.commands.map(command => command.serialize()),
70
+ };
71
+ }
72
+
73
+ public apply(editor: Editor) {
74
+ this.nonserializableCommand.apply(editor);
75
+ }
76
+
77
+ public unapply(editor: Editor) {
78
+ this.nonserializableCommand.unapply(editor);
79
+ }
80
+
81
+ public description(editor: Editor, localizationTable: EditorLocalization): string {
82
+ return this.nonserializableCommand.description(editor, localizationTable);
83
+ }
84
+ }
85
+
86
+ const uniteCommands = <T extends Command> (commands: T[], applyChunkSize?: number): T extends SerializableCommand ? SerializableCommand : Command => {
87
+ let allSerializable = true;
88
+ for (const command of commands) {
89
+ if (!(command instanceof SerializableCommand)) {
90
+ allSerializable = false;
91
+ break;
92
+ }
93
+ }
94
+
95
+ if (!allSerializable) {
96
+ return new NonSerializableUnion(commands, applyChunkSize) as any;
97
+ } else {
98
+ const castedCommands = commands as any[] as SerializableCommand[];
99
+ return new SerializableUnion(castedCommands, applyChunkSize);
100
+ }
101
+ };
102
+
103
+ SerializableCommand.register('union', (data: any, editor) => {
104
+ if (typeof data.data.length !== 'number') {
105
+ throw new Error('Unions of commands must serialize to lists of serialization data.');
106
+ }
107
+ const applyChunkSize: number|undefined = data.applyChunkSize;
108
+ if (typeof applyChunkSize !== 'number' && applyChunkSize !== undefined) {
109
+ throw new Error('serialized applyChunkSize is neither undefined nor a number.');
110
+ }
111
+
112
+ const commands: SerializableCommand[] = [];
113
+ for (const part of data.data as any[]) {
114
+ commands.push(SerializableCommand.deserialize(part, editor));
115
+ }
116
+
117
+ return uniteCommands(commands, applyChunkSize);
118
+ });
119
+
120
+
121
+ export default uniteCommands;