js-draw 0.3.1 → 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 (84) hide show
  1. package/.github/ISSUE_TEMPLATE/translation.md +4 -1
  2. package/CHANGELOG.md +8 -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/builders/FreehandLineBuilder.js +2 -2
  24. package/dist/src/components/lib.d.ts +4 -2
  25. package/dist/src/components/lib.js +4 -2
  26. package/dist/src/components/localization.d.ts +2 -0
  27. package/dist/src/components/localization.js +2 -0
  28. package/dist/src/math/LineSegment2.d.ts +2 -0
  29. package/dist/src/math/LineSegment2.js +3 -0
  30. package/dist/src/rendering/localization.d.ts +3 -0
  31. package/dist/src/rendering/localization.js +3 -0
  32. package/dist/src/rendering/renderers/AbstractRenderer.d.ts +7 -0
  33. package/dist/src/rendering/renderers/CanvasRenderer.d.ts +2 -1
  34. package/dist/src/rendering/renderers/CanvasRenderer.js +7 -0
  35. package/dist/src/rendering/renderers/DummyRenderer.d.ts +3 -1
  36. package/dist/src/rendering/renderers/DummyRenderer.js +5 -0
  37. package/dist/src/rendering/renderers/SVGRenderer.d.ts +5 -2
  38. package/dist/src/rendering/renderers/SVGRenderer.js +45 -20
  39. package/dist/src/rendering/renderers/TextOnlyRenderer.d.ts +3 -1
  40. package/dist/src/rendering/renderers/TextOnlyRenderer.js +8 -1
  41. package/dist/src/tools/BaseTool.d.ts +3 -1
  42. package/dist/src/tools/BaseTool.js +6 -0
  43. package/dist/src/tools/PasteHandler.d.ts +16 -0
  44. package/dist/src/tools/PasteHandler.js +142 -0
  45. package/dist/src/tools/SelectionTool.d.ts +7 -1
  46. package/dist/src/tools/SelectionTool.js +63 -5
  47. package/dist/src/tools/ToolController.js +36 -27
  48. package/dist/src/tools/lib.d.ts +1 -0
  49. package/dist/src/tools/lib.js +1 -0
  50. package/dist/src/tools/localization.d.ts +3 -0
  51. package/dist/src/tools/localization.js +3 -0
  52. package/dist/src/types.d.ts +13 -2
  53. package/dist/src/types.js +2 -0
  54. package/package.json +1 -1
  55. package/src/Editor.ts +131 -2
  56. package/src/EditorImage.ts +7 -1
  57. package/src/SVGLoader.ts +90 -36
  58. package/src/UndoRedoHistory.test.ts +33 -0
  59. package/src/UndoRedoHistory.ts +8 -0
  60. package/src/Viewport.ts +13 -4
  61. package/src/commands/lib.ts +2 -0
  62. package/src/commands/localization.ts +2 -0
  63. package/src/commands/uniteCommands.test.ts +23 -0
  64. package/src/commands/uniteCommands.ts +121 -0
  65. package/src/components/AbstractComponent.ts +55 -9
  66. package/src/components/ImageComponent.ts +153 -0
  67. package/src/components/builders/FreehandLineBuilder.ts +2 -2
  68. package/src/components/lib.ts +7 -2
  69. package/src/components/localization.ts +4 -0
  70. package/src/math/LineSegment2.test.ts +9 -0
  71. package/src/math/LineSegment2.ts +5 -0
  72. package/src/rendering/localization.ts +6 -0
  73. package/src/rendering/renderers/AbstractRenderer.ts +16 -0
  74. package/src/rendering/renderers/CanvasRenderer.ts +10 -1
  75. package/src/rendering/renderers/DummyRenderer.ts +6 -1
  76. package/src/rendering/renderers/SVGRenderer.ts +50 -21
  77. package/src/rendering/renderers/TextOnlyRenderer.ts +10 -2
  78. package/src/tools/BaseTool.ts +9 -1
  79. package/src/tools/PasteHandler.ts +156 -0
  80. package/src/tools/SelectionTool.ts +80 -8
  81. package/src/tools/ToolController.ts +51 -44
  82. package/src/tools/lib.ts +1 -0
  83. package/src/tools/localization.ts +8 -0
  84. package/src/types.ts +16 -2
@@ -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;
@@ -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,21 @@ 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
 
140
- if (!elem) {
141
- throw new Error(`Unable to retrieve non-existent element, ${elem}`);
142
- }
143
-
144
- const transform = json.transfm as [
186
+ const transform = new Mat33(...(json.transfm as [
145
187
  number, number, number,
146
188
  number, number, number,
147
189
  number, number, number,
148
- ];
190
+ ]));
191
+
192
+ if (!elem) {
193
+ return new AbstractComponent.UnresolvedTransformElementCommand(transform, json.id);
194
+ }
149
195
 
150
196
  return new AbstractComponent.TransformElementCommand(
151
- new Mat33(...transform),
197
+ transform,
152
198
  elem,
153
199
  );
154
200
  });