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/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
  }
@@ -145,7 +146,8 @@ export class Viewport {
145
146
  return 1/this.getScaleFactor();
146
147
  }
147
148
 
148
- // Returns the angle of the canvas in radians
149
+ // Returns the angle of the canvas in radians.
150
+ // This is the angle by which the canvas is rotated relative to the screen.
149
151
  public getRotationAngle(): number {
150
152
  return this.transform.transformVec3(Vec3.unitX).angle();
151
153
  }
@@ -174,16 +176,28 @@ export class Viewport {
174
176
  return point.map(roundComponent);
175
177
  }
176
178
 
177
-
178
179
  // Round a point with a tolerance of ±1 screen unit.
179
180
  public roundPoint(point: Point2): Point2 {
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
+ // `roundAmount`: An integer >= 0, larger numbers cause less rounding. Smaller numbers cause more
185
+ // (as such `roundAmount = 0` does the most rounding).
186
+ public static roundScaleRatio(scaleRatio: number, roundAmount: number = 1): number {
187
+ if (Math.abs(scaleRatio) <= 1e-12) {
188
+ return 0;
189
+ }
190
+
191
+ // Represent as k 10ⁿ for some n, k ∈ ℤ.
192
+ const decimalComponent = 10 ** Math.floor(Math.log10(Math.abs(scaleRatio)));
193
+ const roundAnountFactor = 2 ** roundAmount;
194
+ scaleRatio = Math.round(scaleRatio / decimalComponent * roundAnountFactor) / roundAnountFactor * decimalComponent;
195
+
196
+ return scaleRatio;
197
+ }
198
+
199
+ // Computes and returns an affine transformation that makes `toMakeVisible` visible and roughly centered on the screen.
200
+ public computeZoomToTransform(toMakeVisible: Rect2, allowZoomIn: boolean = true, allowZoomOut: boolean = true): Mat33 {
187
201
  let transform = Mat33.identity;
188
202
 
189
203
  if (toMakeVisible.w === 0 || toMakeVisible.h === 0) {
@@ -237,6 +251,16 @@ export class Viewport {
237
251
  transform = Mat33.identity;
238
252
  }
239
253
 
254
+ return transform;
255
+ }
256
+
257
+ // Returns a Command that transforms the view such that [rect] is visible, and perhaps
258
+ // centered in the viewport.
259
+ // Returns null if no transformation is necessary
260
+ //
261
+ // @see {@link computeZoomToTransform}
262
+ public zoomTo(toMakeVisible: Rect2, allowZoomIn: boolean = true, allowZoomOut: boolean = true): Command {
263
+ const transform = this.computeZoomToTransform(toMakeVisible, allowZoomIn, allowZoomOut);
240
264
  return new Viewport.ViewportTransform(transform);
241
265
  }
242
266
  }
@@ -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;
@@ -2,7 +2,7 @@ import SerializableCommand from '../commands/SerializableCommand';
2
2
  import Editor from '../Editor';
3
3
  import EditorImage from '../EditorImage';
4
4
  import LineSegment2 from '../math/LineSegment2';
5
- import Mat33 from '../math/Mat33';
5
+ import Mat33, { Mat33Array } from '../math/Mat33';
6
6
  import Rect2 from '../math/Rect2';
7
7
  import { EditorLocalization } from '../localization';
8
8
  import AbstractRenderer from '../rendering/renderers/AbstractRenderer';
@@ -89,6 +89,52 @@ export default abstract class AbstractComponent {
89
89
  return new AbstractComponent.TransformElementCommand(affineTransfm, this);
90
90
  }
91
91
 
92
+ private static transformElementCommandId = 'transform-element';
93
+
94
+ private static UnresolvedTransformElementCommand = class extends SerializableCommand {
95
+ private command: SerializableCommand|null = null;
96
+
97
+ public constructor(
98
+ private affineTransfm: Mat33,
99
+ private componentID: string,
100
+ ) {
101
+ super(AbstractComponent.transformElementCommandId);
102
+ }
103
+
104
+ private resolveCommand(editor: Editor) {
105
+ if (this.command) {
106
+ return;
107
+ }
108
+
109
+ const component = editor.image.lookupElement(this.componentID);
110
+ if (!component) {
111
+ throw new Error(`Unable to resolve component with ID ${this.componentID}`);
112
+ }
113
+ this.command = new AbstractComponent.TransformElementCommand(this.affineTransfm, component);
114
+ }
115
+
116
+ public apply(editor: Editor) {
117
+ this.resolveCommand(editor);
118
+ this.command!.apply(editor);
119
+ }
120
+
121
+ public unapply(editor: Editor) {
122
+ this.resolveCommand(editor);
123
+ this.command!.unapply(editor);
124
+ }
125
+
126
+ public description(_editor: Editor, localizationTable: EditorLocalization) {
127
+ return localizationTable.transformedElements(1);
128
+ }
129
+
130
+ protected serializeToJSON() {
131
+ return {
132
+ id: this.componentID,
133
+ transfm: this.affineTransfm.toArray(),
134
+ };
135
+ }
136
+ };
137
+
92
138
  private static TransformElementCommand = class extends SerializableCommand {
93
139
  private origZIndex: number;
94
140
 
@@ -96,7 +142,7 @@ export default abstract class AbstractComponent {
96
142
  private affineTransfm: Mat33,
97
143
  private component: AbstractComponent,
98
144
  ) {
99
- super('transform-element');
145
+ super(AbstractComponent.transformElementCommandId);
100
146
  this.origZIndex = component.zIndex;
101
147
  }
102
148
 
@@ -134,21 +180,17 @@ export default abstract class AbstractComponent {
134
180
  }
135
181
 
136
182
  static {
137
- SerializableCommand.register('transform-element', (json: any, editor: Editor) => {
183
+ SerializableCommand.register(AbstractComponent.transformElementCommandId, (json: any, editor: Editor) => {
138
184
  const elem = editor.image.lookupElement(json.id);
139
185
 
186
+ const transform = new Mat33(...(json.transfm as Mat33Array));
187
+
140
188
  if (!elem) {
141
- throw new Error(`Unable to retrieve non-existent element, ${elem}`);
189
+ return new AbstractComponent.UnresolvedTransformElementCommand(transform, json.id);
142
190
  }
143
191
 
144
- const transform = json.transfm as [
145
- number, number, number,
146
- number, number, number,
147
- number, number, number,
148
- ];
149
-
150
192
  return new AbstractComponent.TransformElementCommand(
151
- new Mat33(...transform),
193
+ transform,
152
194
  elem,
153
195
  );
154
196
  });