js-draw 0.13.1 → 0.15.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 (100) hide show
  1. package/.github/ISSUE_TEMPLATE/translation.yml +8 -0
  2. package/CHANGELOG.md +15 -0
  3. package/README.md +1 -1
  4. package/dist/bundle.js +1 -1
  5. package/dist/src/Color4.d.ts +4 -0
  6. package/dist/src/Color4.js +22 -0
  7. package/dist/src/Editor.d.ts +2 -1
  8. package/dist/src/Editor.js +14 -5
  9. package/dist/src/EditorImage.d.ts +1 -0
  10. package/dist/src/EditorImage.js +11 -0
  11. package/dist/src/SVGLoader.js +8 -2
  12. package/dist/src/Viewport.d.ts +1 -0
  13. package/dist/src/Viewport.js +6 -3
  14. package/dist/src/commands/UnresolvedCommand.d.ts +14 -0
  15. package/dist/src/commands/UnresolvedCommand.js +22 -0
  16. package/dist/src/commands/uniteCommands.js +4 -2
  17. package/dist/src/components/AbstractComponent.d.ts +0 -1
  18. package/dist/src/components/AbstractComponent.js +30 -50
  19. package/dist/src/components/RestylableComponent.d.ts +24 -0
  20. package/dist/src/components/RestylableComponent.js +80 -0
  21. package/dist/src/components/Stroke.d.ts +8 -1
  22. package/dist/src/components/Stroke.js +49 -1
  23. package/dist/src/components/TextComponent.d.ts +10 -10
  24. package/dist/src/components/TextComponent.js +46 -13
  25. package/dist/src/components/lib.d.ts +2 -1
  26. package/dist/src/components/lib.js +2 -1
  27. package/dist/src/components/localization.d.ts +1 -0
  28. package/dist/src/components/localization.js +1 -0
  29. package/dist/src/math/Path.js +10 -3
  30. package/dist/src/rendering/TextRenderingStyle.d.ts +23 -0
  31. package/dist/src/rendering/TextRenderingStyle.js +20 -0
  32. package/dist/src/rendering/renderers/AbstractRenderer.d.ts +1 -1
  33. package/dist/src/rendering/renderers/CanvasRenderer.d.ts +1 -1
  34. package/dist/src/rendering/renderers/DummyRenderer.d.ts +1 -1
  35. package/dist/src/rendering/renderers/SVGRenderer.d.ts +1 -1
  36. package/dist/src/rendering/renderers/TextOnlyRenderer.d.ts +1 -1
  37. package/dist/src/toolbar/IconProvider.d.ts +30 -3
  38. package/dist/src/toolbar/IconProvider.js +37 -2
  39. package/dist/src/toolbar/localization.d.ts +1 -0
  40. package/dist/src/toolbar/localization.js +1 -0
  41. package/dist/src/toolbar/widgets/BaseWidget.js +10 -4
  42. package/dist/src/toolbar/widgets/InsertImageWidget.js +2 -1
  43. package/dist/src/toolbar/widgets/SelectionToolWidget.js +77 -1
  44. package/dist/src/tools/Pen.js +2 -2
  45. package/dist/src/tools/SelectionTool/SelectAllShortcutHandler.d.ts +8 -0
  46. package/dist/src/tools/SelectionTool/SelectAllShortcutHandler.js +22 -0
  47. package/dist/src/tools/SelectionTool/Selection.d.ts +6 -0
  48. package/dist/src/tools/SelectionTool/Selection.js +13 -4
  49. package/dist/src/tools/SelectionTool/SelectionTool.js +9 -12
  50. package/dist/src/tools/SelectionTool/TransformMode.js +1 -1
  51. package/dist/src/tools/TextTool.d.ts +1 -1
  52. package/dist/src/tools/ToolController.js +2 -0
  53. package/dist/src/tools/lib.d.ts +1 -0
  54. package/dist/src/tools/lib.js +1 -0
  55. package/dist/src/tools/localization.d.ts +1 -0
  56. package/dist/src/tools/localization.js +1 -0
  57. package/package.json +1 -1
  58. package/src/Color4.test.ts +4 -0
  59. package/src/Color4.ts +26 -0
  60. package/src/Editor.toSVG.test.ts +1 -1
  61. package/src/Editor.ts +16 -5
  62. package/src/EditorImage.ts +13 -0
  63. package/src/SVGLoader.ts +11 -3
  64. package/src/Viewport.ts +7 -3
  65. package/src/commands/UnresolvedCommand.ts +37 -0
  66. package/src/commands/uniteCommands.ts +5 -2
  67. package/src/components/AbstractComponent.ts +36 -61
  68. package/src/components/RestylableComponent.ts +142 -0
  69. package/src/components/Stroke.test.ts +68 -0
  70. package/src/components/Stroke.ts +68 -2
  71. package/src/components/TextComponent.test.ts +56 -2
  72. package/src/components/TextComponent.ts +63 -25
  73. package/src/components/lib.ts +4 -1
  74. package/src/components/localization.ts +3 -0
  75. package/src/math/Path.toString.test.ts +10 -0
  76. package/src/math/Path.ts +11 -3
  77. package/src/math/Rect2.test.ts +18 -6
  78. package/src/rendering/TextRenderingStyle.ts +38 -0
  79. package/src/rendering/renderers/AbstractRenderer.ts +1 -1
  80. package/src/rendering/renderers/CanvasRenderer.ts +2 -1
  81. package/src/rendering/renderers/DummyRenderer.ts +1 -1
  82. package/src/rendering/renderers/SVGRenderer.ts +1 -1
  83. package/src/rendering/renderers/TextOnlyRenderer.ts +1 -1
  84. package/src/toolbar/IconProvider.ts +40 -7
  85. package/src/toolbar/localization.ts +2 -0
  86. package/src/toolbar/toolbar.css +3 -0
  87. package/src/toolbar/widgets/BaseWidget.ts +12 -4
  88. package/src/toolbar/widgets/InsertImageWidget.ts +2 -1
  89. package/src/toolbar/widgets/SelectionToolWidget.ts +95 -1
  90. package/src/tools/PanZoom.test.ts +2 -1
  91. package/src/tools/PasteHandler.ts +1 -1
  92. package/src/tools/Pen.ts +2 -2
  93. package/src/tools/SelectionTool/SelectAllShortcutHandler.ts +28 -0
  94. package/src/tools/SelectionTool/Selection.ts +17 -6
  95. package/src/tools/SelectionTool/SelectionTool.ts +9 -13
  96. package/src/tools/SelectionTool/TransformMode.ts +1 -1
  97. package/src/tools/TextTool.ts +2 -1
  98. package/src/tools/ToolController.ts +2 -0
  99. package/src/tools/lib.ts +1 -0
  100. package/src/tools/localization.ts +2 -0
@@ -77,6 +77,17 @@ export default class Selection {
77
77
  const scaleAndTranslateMat = this.transform.rightMul(rotationMatrix.inverse());
78
78
  return this.originalRegion.transformedBoundingBox(scaleAndTranslateMat);
79
79
  }
80
+ /**
81
+ * Computes and returns the bounding box of the selection without
82
+ * any additional padding. Computes directly from the elements that are selected.
83
+ * @internal
84
+ */
85
+ computeTightBoundingBox() {
86
+ const bbox = this.selectedElems.reduce((accumulator, elem) => {
87
+ return (accumulator !== null && accumulator !== void 0 ? accumulator : elem.getBBox()).union(elem.getBBox());
88
+ }, null);
89
+ return bbox !== null && bbox !== void 0 ? bbox : Rect2.empty;
90
+ }
80
91
  get regionRotation() {
81
92
  return this.transform.transformVec3(Vec2.unitX).angle();
82
93
  }
@@ -161,9 +172,7 @@ export default class Selection {
161
172
  // Recompute this' region from the selected elements.
162
173
  // Returns false if the selection is empty.
163
174
  recomputeRegion() {
164
- const newRegion = this.selectedElems.reduce((accumulator, elem) => {
165
- return (accumulator !== null && accumulator !== void 0 ? accumulator : elem.getBBox()).union(elem.getBBox());
166
- }, null);
175
+ const newRegion = this.computeTightBoundingBox();
167
176
  if (!newRegion) {
168
177
  this.cancelSelection();
169
178
  return false;
@@ -431,7 +440,7 @@ Selection.ApplyTransformationCommand = class extends SerializableCommand {
431
440
  this.resolveToElems(editor);
432
441
  (_b = this.selection) === null || _b === void 0 ? void 0 : _b.setTransform(this.fullTransform.inverse(), false);
433
442
  (_c = this.selection) === null || _c === void 0 ? void 0 : _c.updateUI();
434
- yield editor.asyncUnapplyCommands(this.transformCommands, updateChunkSize);
443
+ yield editor.asyncUnapplyCommands(this.transformCommands, updateChunkSize, true);
435
444
  (_d = this.selection) === null || _d === void 0 ? void 0 : _d.setTransform(Mat33.identity);
436
445
  (_e = this.selection) === null || _e === void 0 ? void 0 : _e.recomputeRegion();
437
446
  (_f = this.selection) === null || _f === void 0 ? void 0 : _f.updateUI();
@@ -47,10 +47,12 @@ export default class SelectionTool extends BaseTool {
47
47
  snapSelectionToGrid() {
48
48
  if (!this.selectionBox)
49
49
  throw new Error('No selection to snap!');
50
- const topLeftOfBBox = this.selectionBox.region.topLeft;
51
- const snapDistance = this.editor.viewport.snapToGrid(topLeftOfBBox).minus(topLeftOfBBox);
50
+ // Snap the top left corner of what we have selected.
51
+ const topLeftOfBBox = this.selectionBox.computeTightBoundingBox().topLeft;
52
+ const snappedTopLeft = this.editor.viewport.snapToGrid(topLeftOfBBox);
53
+ const snapDelta = snappedTopLeft.minus(topLeftOfBBox);
52
54
  const oldTransform = this.selectionBox.getTransform();
53
- this.selectionBox.setTransform(oldTransform.rightMul(Mat33.translation(snapDistance)));
55
+ this.selectionBox.setTransform(oldTransform.rightMul(Mat33.translation(snapDelta)));
54
56
  this.selectionBox.finalizeTransform();
55
57
  }
56
58
  onPointerDown({ allPointers, current }) {
@@ -169,7 +171,7 @@ export default class SelectionTool extends BaseTool {
169
171
  this.expandingSelectionBox = false;
170
172
  }
171
173
  onKeyPress(event) {
172
- if (event.key === 'Control') {
174
+ if (event.key === 'Control' || event.key === 'Meta') {
173
175
  this.ctrlKeyPressed = true;
174
176
  return true;
175
177
  }
@@ -179,8 +181,7 @@ export default class SelectionTool extends BaseTool {
179
181
  return true;
180
182
  }
181
183
  else if (event.key === 'a' && event.ctrlKey) {
182
- // Handle ctrl+A on key up.
183
- // Return early to prevent 'a' from moving the selection/view.
184
+ this.setSelection(this.editor.image.getAllElements());
184
185
  return true;
185
186
  }
186
187
  else if (event.ctrlKey) {
@@ -266,7 +267,7 @@ export default class SelectionTool extends BaseTool {
266
267
  return handled;
267
268
  }
268
269
  onKeyUp(evt) {
269
- if (evt.key === 'Control') {
270
+ if (evt.key === 'Control' || evt.key === 'Meta') {
270
271
  this.ctrlKeyPressed = false;
271
272
  return true;
272
273
  }
@@ -281,10 +282,6 @@ export default class SelectionTool extends BaseTool {
281
282
  });
282
283
  return true;
283
284
  }
284
- else if (evt.key === 'a') {
285
- this.setSelection(this.editor.image.getAllElements());
286
- return true;
287
- }
288
285
  }
289
286
  if (this.selectionBox && SelectionTool.handleableKeys.some(key => key === evt.key)) {
290
287
  this.selectionBox.finalizeTransform();
@@ -392,5 +389,5 @@ SelectionTool.handleableKeys = [
392
389
  'e', 'j', 'ArrowDown',
393
390
  'r', 'R',
394
391
  'i', 'I', 'o', 'O',
395
- 'Control',
392
+ 'Control', 'Meta',
396
393
  ];
@@ -51,7 +51,7 @@ export class ResizeTransformer {
51
51
  // Round: If this isn't done, scaling can create numbers with long decimal representations.
52
52
  // long decimal representations => large file sizes.
53
53
  scale = scale.map(component => Viewport.roundScaleRatio(component, 2));
54
- if (scale.x > 0 && scale.y > 0) {
54
+ if (scale.x !== 0 && scale.y !== 0) {
55
55
  const origin = this.editor.viewport.roundPoint(this.selection.preTransformRegion.topLeft);
56
56
  this.selection.setTransform(Mat33.scaling2D(scale, origin));
57
57
  }
@@ -1,9 +1,9 @@
1
1
  import Color4 from '../Color4';
2
- import { TextStyle } from '../components/TextComponent';
3
2
  import Editor from '../Editor';
4
3
  import { PointerEvt } from '../types';
5
4
  import BaseTool from './BaseTool';
6
5
  import { ToolLocalization } from './localization';
6
+ import TextStyle from '../rendering/TextRenderingStyle';
7
7
  export default class TextTool extends BaseTool {
8
8
  private editor;
9
9
  private localizationTable;
@@ -13,6 +13,7 @@ import PasteHandler from './PasteHandler';
13
13
  import ToolbarShortcutHandler from './ToolbarShortcutHandler';
14
14
  import { makePressureSensitiveFreehandLineBuilder } from '../components/builders/PressureSensitiveFreehandLineBuilder';
15
15
  import FindTool from './FindTool';
16
+ import SelectAllShortcutHandler from './SelectionTool/SelectAllShortcutHandler';
16
17
  export default class ToolController {
17
18
  /** @internal */
18
19
  constructor(editor, localization) {
@@ -43,6 +44,7 @@ export default class ToolController {
43
44
  new ToolSwitcherShortcut(editor),
44
45
  new FindTool(editor),
45
46
  new PasteHandler(editor),
47
+ new SelectAllShortcutHandler(editor),
46
48
  ];
47
49
  primaryTools.forEach(tool => tool.setToolGroup(primaryToolGroup));
48
50
  panZoomTool.setEnabled(true);
@@ -10,6 +10,7 @@ export { default as PanZoomTool, PanZoomMode } from './PanZoom';
10
10
  export { default as PenTool, PenStyle } from './Pen';
11
11
  export { default as TextTool } from './TextTool';
12
12
  export { default as SelectionTool } from './SelectionTool/SelectionTool';
13
+ export { default as SelectAllShortcutHandler } from './SelectionTool/SelectAllShortcutHandler';
13
14
  export { default as EraserTool } from './Eraser';
14
15
  export { default as PasteHandler } from './PasteHandler';
15
16
  export { default as ToolbarShortcutHandler } from './ToolbarShortcutHandler';
@@ -10,6 +10,7 @@ export { default as PanZoomTool, PanZoomMode } from './PanZoom';
10
10
  export { default as PenTool } from './Pen';
11
11
  export { default as TextTool } from './TextTool';
12
12
  export { default as SelectionTool } from './SelectionTool/SelectionTool';
13
+ export { default as SelectAllShortcutHandler } from './SelectionTool/SelectAllShortcutHandler';
13
14
  export { default as EraserTool } from './Eraser';
14
15
  export { default as PasteHandler } from './PasteHandler';
15
16
  export { default as ToolbarShortcutHandler } from './ToolbarShortcutHandler';
@@ -2,6 +2,7 @@ export interface ToolLocalization {
2
2
  keyboardPanZoom: string;
3
3
  penTool: (penId: number) => string;
4
4
  selectionTool: string;
5
+ selectAllTool: string;
5
6
  eraserTool: string;
6
7
  touchPanTool: string;
7
8
  twoFingerPanZoomTool: string;
@@ -1,6 +1,7 @@
1
1
  export const defaultToolLocalization = {
2
2
  penTool: (penId) => `Pen ${penId}`,
3
3
  selectionTool: 'Selection',
4
+ selectAllTool: 'Select all shortcut',
4
5
  eraserTool: 'Eraser',
5
6
  touchPanTool: 'Touch panning',
6
7
  twoFingerPanZoomTool: 'Panning and zooming',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "js-draw",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "description": "Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript. ",
5
5
  "main": "./dist/src/lib.d.ts",
6
6
  "types": "./dist/src/lib.js",
@@ -27,4 +27,8 @@ describe('Color4', () => {
27
27
  Color4.ofRGB(0.7, 0.3, 0)
28
28
  );
29
29
  });
30
+
31
+ it('should mix red with nothing and get red', () => {
32
+ expect(Color4.average([ Color4.red ])).objEq(Color4.red);
33
+ });
30
34
  });
package/src/Color4.ts CHANGED
@@ -151,6 +151,32 @@ export default class Color4 {
151
151
  );
152
152
  }
153
153
 
154
+ /**
155
+ * @returns the component-wise average of `colors`, or `Color4.transparent` if `colors` is empty.
156
+ */
157
+ public static average(colors: Color4[]) {
158
+ let averageA = 0;
159
+ let averageR = 0;
160
+ let averageG = 0;
161
+ let averageB = 0;
162
+
163
+ for (const color of colors) {
164
+ averageA += color.a;
165
+ averageR += color.r;
166
+ averageG += color.g;
167
+ averageB += color.b;
168
+ }
169
+
170
+ if (colors.length > 0) {
171
+ averageA /= colors.length;
172
+ averageR /= colors.length;
173
+ averageG /= colors.length;
174
+ averageB /= colors.length;
175
+ }
176
+
177
+ return new Color4(averageR, averageG, averageB, averageA);
178
+ }
179
+
154
180
  private hexString: string|null = null;
155
181
 
156
182
  /**
@@ -1,5 +1,5 @@
1
- import { TextStyle } from './components/TextComponent';
2
1
  import { Color4, Mat33, Rect2, TextComponent, EditorImage, Vec2 } from './lib';
2
+ import TextStyle from './rendering/TextRenderingStyle';
3
3
  import SVGLoader from './SVGLoader';
4
4
  import createEditor from './testing/createEditor';
5
5
 
package/src/Editor.ts CHANGED
@@ -315,7 +315,7 @@ export class Editor {
315
315
 
316
316
  // Process wheel events if the ctrl key is down, even if disabled -- we do want to handle
317
317
  // pinch-zooming.
318
- if (!evt.ctrlKey) {
318
+ if (!evt.ctrlKey && !evt.metaKey) {
319
319
  if (!this.settings.wheelEventsEnabled) {
320
320
  return;
321
321
  } else if (this.settings.wheelEventsEnabled === 'only-if-focused') {
@@ -333,7 +333,7 @@ export class Editor {
333
333
  delta = delta.times(100);
334
334
  }
335
335
 
336
- if (evt.ctrlKey) {
336
+ if (evt.ctrlKey || evt.metaKey) {
337
337
  delta = Vec3.of(0, 0, evt.deltaY);
338
338
  }
339
339
 
@@ -598,7 +598,7 @@ export class Editor {
598
598
  } else if (this.toolController.dispatchInputEvent({
599
599
  kind: InputEvtType.KeyPressEvent,
600
600
  key: evt.key,
601
- ctrlKey: evt.ctrlKey,
601
+ ctrlKey: evt.ctrlKey || evt.metaKey,
602
602
  altKey: evt.altKey,
603
603
  })) {
604
604
  evt.preventDefault();
@@ -611,7 +611,7 @@ export class Editor {
611
611
  if (this.toolController.dispatchInputEvent({
612
612
  kind: InputEvtType.KeyUpEvent,
613
613
  key: evt.key,
614
- ctrlKey: evt.ctrlKey,
614
+ ctrlKey: evt.ctrlKey || evt.metaKey,
615
615
  altKey: evt.altKey,
616
616
  })) {
617
617
  evt.preventDefault();
@@ -704,8 +704,14 @@ export class Editor {
704
704
  return this.asyncApplyOrUnapplyCommands(commands, true, chunkSize);
705
705
  }
706
706
 
707
+ // If `unapplyInReverseOrder`, commands are reversed before unapplying.
707
708
  // @see {@link #asyncApplyOrUnapplyCommands }
708
- public asyncUnapplyCommands(commands: Command[], chunkSize: number) {
709
+ public asyncUnapplyCommands(commands: Command[], chunkSize: number, unapplyInReverseOrder: boolean = false) {
710
+ if (unapplyInReverseOrder) {
711
+ commands = [ ...commands ]; // copy
712
+ commands.reverse();
713
+ }
714
+
709
715
  return this.asyncApplyOrUnapplyCommands(commands, false, chunkSize);
710
716
  }
711
717
 
@@ -745,6 +751,11 @@ export class Editor {
745
751
  });
746
752
  }
747
753
 
754
+ // @internal
755
+ public isRerenderQueued() {
756
+ return this.rerenderQueued;
757
+ }
758
+
748
759
  public rerender(showImageBounds: boolean = true) {
749
760
  this.display.startRerender();
750
761
 
@@ -34,6 +34,19 @@ export default class EditorImage {
34
34
  return null;
35
35
  }
36
36
 
37
+ // Forces a re-render of `elem` when the image is next re-rendered as a whole.
38
+ // Does nothing if `elem` is not in this.
39
+ public queueRerenderOf(elem: AbstractComponent) {
40
+ // TODO: Make more efficient (e.g. increase IDs of all parents,
41
+ // make cache take into account last modified time instead of IDs, etc.)
42
+ const parent = this.findParent(elem);
43
+
44
+ if (parent) {
45
+ parent.remove();
46
+ this.addElementDirectly(elem);
47
+ }
48
+ }
49
+
37
50
  /** @internal */
38
51
  public renderWithCache(screenRenderer: AbstractRenderer, cache: RenderingCache, viewport: Viewport) {
39
52
  cache.render(screenRenderer, this.root, viewport);
package/src/SVGLoader.ts CHANGED
@@ -3,7 +3,7 @@ import AbstractComponent from './components/AbstractComponent';
3
3
  import ImageComponent from './components/ImageComponent';
4
4
  import Stroke from './components/Stroke';
5
5
  import SVGGlobalAttributesObject from './components/SVGGlobalAttributesObject';
6
- import TextComponent, { TextStyle } from './components/TextComponent';
6
+ import TextComponent from './components/TextComponent';
7
7
  import UnknownSVGObject from './components/UnknownSVGObject';
8
8
  import Mat33 from './math/Mat33';
9
9
  import Path from './math/Path';
@@ -11,6 +11,7 @@ import Rect2 from './math/Rect2';
11
11
  import { Vec2 } from './math/Vec2';
12
12
  import { RenderablePathSpec } from './rendering/renderers/AbstractRenderer';
13
13
  import RenderingStyle from './rendering/RenderingStyle';
14
+ import TextStyle from './rendering/TextRenderingStyle';
14
15
  import { ComponentAddedListener, ImageLoader, OnDetermineExportRectListener, OnProgressListener } from './types';
15
16
 
16
17
  type OnFinishListener = ()=> void;
@@ -244,7 +245,14 @@ export default class SVGLoader implements ImageLoader {
244
245
 
245
246
  // Compute styles.
246
247
  const computedStyles = window.getComputedStyle(elem);
247
- const fontSizeMatch = /^([-0-9.e]+)px/i.exec(computedStyles.fontSize);
248
+ const fontSizeExp = /^([-0-9.e]+)px/i;
249
+
250
+ // In some environments, computedStyles.fontSize can be increased by the system.
251
+ // Thus, to prevent text from growing on load/save, prefer .style.fontSize.
252
+ let fontSizeMatch = fontSizeExp.exec(elem.style.fontSize);
253
+ if (!fontSizeMatch) {
254
+ fontSizeMatch = fontSizeExp.exec(computedStyles.fontSize);
255
+ }
248
256
 
249
257
  const supportedStyleAttrs = [
250
258
  'fontFamily',
@@ -455,7 +463,7 @@ export default class SVGLoader implements ImageLoader {
455
463
  <meta name='viewport' conent='width=device-width,initial-scale=1.0'/>
456
464
  <meta charset='utf-8'/>
457
465
  </head>
458
- <body>
466
+ <body style='font-size: 12px;'>
459
467
  <script>
460
468
  console.error('JavaScript should not be able to run here!');
461
469
  throw new Error(
package/src/Viewport.ts CHANGED
@@ -157,14 +157,18 @@ export class Viewport {
157
157
  * should return `100` because `100` is the nearest power of 10 to 101.
158
158
  */
159
159
  public getScaleFactorToNearestPowerOfTen() {
160
+ return this.getScaleFactorToNearestPowerOf(10);
161
+ }
162
+
163
+ private getScaleFactorToNearestPowerOf(powerOf: number) {
160
164
  const scaleFactor = this.getScaleFactor();
161
- return Math.pow(10, Math.round(Math.log10(scaleFactor)));
165
+ return Math.pow(powerOf, Math.round(Math.log(scaleFactor) / Math.log(powerOf)));
162
166
  }
163
167
 
164
168
  public snapToGrid(canvasPos: Point2) {
165
169
  const snapCoordinate = (coordinate: number) => {
166
- const scaleFactor = this.getScaleFactorToNearestPowerOfTen();
167
- const roundFactor = scaleFactor / 100;
170
+ const scaleFactor = this.getScaleFactorToNearestPowerOf(2);
171
+ const roundFactor = scaleFactor / 50;
168
172
  const snapped = Math.round(coordinate * roundFactor) / roundFactor;
169
173
 
170
174
  return snapped;
@@ -0,0 +1,37 @@
1
+ import EditorImage from '../EditorImage';
2
+ import AbstractComponent from '../components/AbstractComponent';
3
+ import SerializableCommand from './SerializableCommand';
4
+
5
+ export type ResolveFromComponentCallback = () => SerializableCommand;
6
+
7
+ /**
8
+ * A command that requires a component that may or may not be present in the editor when
9
+ * the command is created.
10
+ */
11
+ export default abstract class UnresolvedSerializableCommand extends SerializableCommand {
12
+ protected component: AbstractComponent|null;
13
+ protected readonly componentID: string;
14
+
15
+ protected constructor(
16
+ commandId: string,
17
+ componentID: string,
18
+ component?: AbstractComponent
19
+ ) {
20
+ super(commandId);
21
+ this.component = component ?? null;
22
+ this.componentID = componentID;
23
+ }
24
+
25
+ protected resolveComponent(image: EditorImage) {
26
+ if (this.component) {
27
+ return;
28
+ }
29
+
30
+ const component = image.lookupElement(this.componentID);
31
+ if (!component) {
32
+ throw new Error(`Unable to resolve component with ID ${this.componentID}`);
33
+ }
34
+
35
+ this.component = component;
36
+ }
37
+ }
@@ -32,11 +32,14 @@ class NonSerializableUnion extends Command {
32
32
  }
33
33
 
34
34
  public unapply(editor: Editor) {
35
+ const commands = [ ...this.commands ];
36
+ commands.reverse();
37
+
35
38
  if (this.applyChunkSize === undefined) {
36
- const results = this.commands.map(cmd => cmd.unapply(editor));
39
+ const results = commands.map(cmd => cmd.unapply(editor));
37
40
  return NonSerializableUnion.waitForAll(results);
38
41
  } else {
39
- return editor.asyncUnapplyCommands(this.commands, this.applyChunkSize);
42
+ return editor.asyncUnapplyCommands(commands, this.applyChunkSize, false);
40
43
  }
41
44
  }
42
45
 
@@ -7,6 +7,7 @@ import Rect2 from '../math/Rect2';
7
7
  import { EditorLocalization } from '../localization';
8
8
  import AbstractRenderer from '../rendering/renderers/AbstractRenderer';
9
9
  import { ImageComponentLocalization } from './localization';
10
+ import UnresolvedSerializableCommand from '../commands/UnresolvedCommand';
10
11
 
11
12
  export type LoadSaveData = (string[]|Record<symbol, string|number>);
12
13
  export type LoadSaveDataTable = Record<string, Array<LoadSaveData>>;
@@ -132,12 +133,12 @@ export default abstract class AbstractComponent {
132
133
  // Returns a command that, when applied, transforms this by [affineTransfm] and
133
134
  // updates the editor.
134
135
  public transformBy(affineTransfm: Mat33): SerializableCommand {
135
- return new AbstractComponent.TransformElementCommand(affineTransfm, this);
136
+ return new AbstractComponent.TransformElementCommand(affineTransfm, this.getId(), this);
136
137
  }
137
138
 
138
139
  // Returns a command that updates this component's z-index.
139
140
  public setZIndex(newZIndex: number): SerializableCommand {
140
- return new AbstractComponent.TransformElementCommand(Mat33.identity, this, newZIndex);
141
+ return new AbstractComponent.TransformElementCommand(Mat33.identity, this.getId(), this, newZIndex);
141
142
  }
142
143
 
143
144
  // @returns true iff this component can be selected (e.g. by the selection tool.)
@@ -154,69 +155,42 @@ export default abstract class AbstractComponent {
154
155
 
155
156
  private static transformElementCommandId = 'transform-element';
156
157
 
157
- private static UnresolvedTransformElementCommand = class extends SerializableCommand {
158
- private command: SerializableCommand|null = null;
158
+ private static TransformElementCommand = class extends UnresolvedSerializableCommand {
159
+ private origZIndex: number|null = null;
160
+ private targetZIndex: number;
159
161
 
162
+ // Construct a new TransformElementCommand. `component`, while optional, should
163
+ // be provided if available. If not provided, it will be fetched from the editor's
164
+ // document when the command is applied.
160
165
  public constructor(
161
166
  private affineTransfm: Mat33,
162
- private componentID: string,
163
- private targetZIndex?: number,
167
+ componentID: string,
168
+ component?: AbstractComponent,
169
+ targetZIndex?: number,
164
170
  ) {
165
- super(AbstractComponent.transformElementCommandId);
166
- }
167
-
168
- private resolveCommand(editor: Editor) {
169
- if (this.command) {
170
- return;
171
- }
171
+ super(AbstractComponent.transformElementCommandId, componentID, component);
172
+ this.targetZIndex = targetZIndex ?? AbstractComponent.zIndexCounter++;
172
173
 
173
- const component = editor.image.lookupElement(this.componentID);
174
- if (!component) {
175
- throw new Error(`Unable to resolve component with ID ${this.componentID}`);
174
+ // Ensure that we keep drawing on top even after changing the z-index.
175
+ if (this.targetZIndex >= AbstractComponent.zIndexCounter) {
176
+ AbstractComponent.zIndexCounter = this.targetZIndex + 1;
176
177
  }
177
- this.command = new AbstractComponent.TransformElementCommand(
178
- this.affineTransfm, component, this.targetZIndex
179
- );
180
- }
181
-
182
- public apply(editor: Editor) {
183
- this.resolveCommand(editor);
184
- this.command!.apply(editor);
185
- }
186
-
187
- public unapply(editor: Editor) {
188
- this.resolveCommand(editor);
189
- this.command!.unapply(editor);
190
- }
191
-
192
- public description(_editor: Editor, localizationTable: EditorLocalization) {
193
- return localizationTable.transformedElements(1);
194
- }
195
-
196
- protected serializeToJSON() {
197
- return {
198
- id: this.componentID,
199
- transfm: this.affineTransfm.toArray(),
200
- targetZIndex: this.targetZIndex,
201
- };
202
178
  }
203
- };
204
179
 
205
- private static TransformElementCommand = class extends SerializableCommand {
206
- private origZIndex: number;
207
- private targetZIndex: number;
180
+ protected resolveComponent(image: EditorImage): void {
181
+ if (this.component) {
182
+ return;
183
+ }
208
184
 
209
- public constructor(
210
- private affineTransfm: Mat33,
211
- private component: AbstractComponent,
212
- targetZIndex?: number,
213
- ) {
214
- super(AbstractComponent.transformElementCommandId);
215
- this.origZIndex = component.zIndex;
216
- this.targetZIndex = targetZIndex ?? AbstractComponent.zIndexCounter++;
185
+ super.resolveComponent(image);
186
+ this.origZIndex = this.component!.getZIndex();
217
187
  }
218
188
 
219
189
  private updateTransform(editor: Editor, newTransfm: Mat33) {
190
+ if (!this.component) {
191
+ throw new Error('this.component is undefined or null!');
192
+ }
193
+
220
194
  // Any parent should have only one direct child.
221
195
  const parent = editor.image.findParent(this.component);
222
196
  let hadParent = false;
@@ -235,13 +209,17 @@ export default abstract class AbstractComponent {
235
209
  }
236
210
 
237
211
  public apply(editor: Editor) {
238
- this.component.zIndex = this.targetZIndex;
212
+ this.resolveComponent(editor.image);
213
+
214
+ this.component!.zIndex = this.targetZIndex;
239
215
  this.updateTransform(editor, this.affineTransfm);
240
216
  editor.queueRerender();
241
217
  }
242
218
 
243
219
  public unapply(editor: Editor) {
244
- this.component.zIndex = this.origZIndex;
220
+ this.resolveComponent(editor.image);
221
+
222
+ this.component!.zIndex = this.origZIndex!;
245
223
  this.updateTransform(editor, this.affineTransfm.inverse());
246
224
  editor.queueRerender();
247
225
  }
@@ -252,16 +230,13 @@ export default abstract class AbstractComponent {
252
230
 
253
231
  static {
254
232
  SerializableCommand.register(AbstractComponent.transformElementCommandId, (json: any, editor: Editor) => {
255
- const elem = editor.image.lookupElement(json.id);
233
+ const elem = editor.image.lookupElement(json.id) ?? undefined;
256
234
  const transform = new Mat33(...(json.transfm as Mat33Array));
257
235
  const targetZIndex = json.targetZIndex;
258
236
 
259
- if (!elem) {
260
- return new AbstractComponent.UnresolvedTransformElementCommand(transform, json.id, targetZIndex);
261
- }
262
-
263
237
  return new AbstractComponent.TransformElementCommand(
264
238
  transform,
239
+ json.id,
265
240
  elem,
266
241
  targetZIndex,
267
242
  );
@@ -270,7 +245,7 @@ export default abstract class AbstractComponent {
270
245
 
271
246
  protected serializeToJSON() {
272
247
  return {
273
- id: this.component.getId(),
248
+ id: this.componentID,
274
249
  transfm: this.affineTransfm.toArray(),
275
250
  targetZIndex: this.targetZIndex,
276
251
  };