js-draw 0.15.1 → 0.16.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 (117) hide show
  1. package/.github/ISSUE_TEMPLATE/translation.yml +56 -0
  2. package/CHANGELOG.md +13 -0
  3. package/dist/bundle.js +1 -1
  4. package/dist/src/Color4.d.ts +1 -1
  5. package/dist/src/Color4.js +5 -1
  6. package/dist/src/Editor.d.ts +11 -2
  7. package/dist/src/Editor.js +66 -33
  8. package/dist/src/EditorImage.d.ts +28 -3
  9. package/dist/src/EditorImage.js +109 -18
  10. package/dist/src/EventDispatcher.d.ts +4 -3
  11. package/dist/src/SVGLoader.d.ts +1 -0
  12. package/dist/src/SVGLoader.js +15 -1
  13. package/dist/src/Viewport.d.ts +8 -3
  14. package/dist/src/Viewport.js +15 -8
  15. package/dist/src/components/AbstractComponent.d.ts +6 -1
  16. package/dist/src/components/AbstractComponent.js +15 -2
  17. package/dist/src/components/ImageBackground.d.ts +42 -0
  18. package/dist/src/components/ImageBackground.js +139 -0
  19. package/dist/src/components/ImageComponent.js +2 -0
  20. package/dist/src/components/builders/ArrowBuilder.d.ts +3 -1
  21. package/dist/src/components/builders/ArrowBuilder.js +43 -40
  22. package/dist/src/components/builders/LineBuilder.d.ts +3 -1
  23. package/dist/src/components/builders/LineBuilder.js +25 -28
  24. package/dist/src/components/builders/RectangleBuilder.js +1 -1
  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 +2 -0
  28. package/dist/src/components/localization.js +2 -0
  29. package/dist/src/localizations/es.js +1 -1
  30. package/dist/src/math/Mat33.js +43 -5
  31. package/dist/src/math/Path.d.ts +5 -0
  32. package/dist/src/math/Path.js +80 -28
  33. package/dist/src/math/Vec3.js +1 -1
  34. package/dist/src/rendering/Display.js +1 -1
  35. package/dist/src/rendering/renderers/AbstractRenderer.d.ts +13 -1
  36. package/dist/src/rendering/renderers/AbstractRenderer.js +18 -3
  37. package/dist/src/rendering/renderers/CanvasRenderer.d.ts +2 -1
  38. package/dist/src/rendering/renderers/CanvasRenderer.js +12 -2
  39. package/dist/src/rendering/renderers/SVGRenderer.d.ts +1 -1
  40. package/dist/src/rendering/renderers/SVGRenderer.js +8 -2
  41. package/dist/src/testing/sendTouchEvent.d.ts +6 -0
  42. package/dist/src/testing/sendTouchEvent.js +26 -0
  43. package/dist/src/toolbar/HTMLToolbar.d.ts +25 -2
  44. package/dist/src/toolbar/HTMLToolbar.js +127 -15
  45. package/dist/src/toolbar/IconProvider.d.ts +2 -0
  46. package/dist/src/toolbar/IconProvider.js +45 -2
  47. package/dist/src/toolbar/localization.d.ts +5 -0
  48. package/dist/src/toolbar/localization.js +5 -0
  49. package/dist/src/toolbar/widgets/ActionButtonWidget.d.ts +3 -1
  50. package/dist/src/toolbar/widgets/ActionButtonWidget.js +5 -1
  51. package/dist/src/toolbar/widgets/BaseToolWidget.d.ts +1 -1
  52. package/dist/src/toolbar/widgets/BaseToolWidget.js +2 -1
  53. package/dist/src/toolbar/widgets/BaseWidget.d.ts +7 -2
  54. package/dist/src/toolbar/widgets/BaseWidget.js +23 -1
  55. package/dist/src/toolbar/widgets/DocumentPropertiesWidget.d.ts +19 -0
  56. package/dist/src/toolbar/widgets/DocumentPropertiesWidget.js +135 -0
  57. package/dist/src/toolbar/widgets/HandToolWidget.js +1 -1
  58. package/dist/src/toolbar/widgets/OverflowWidget.d.ts +25 -0
  59. package/dist/src/toolbar/widgets/OverflowWidget.js +65 -0
  60. package/dist/src/toolbar/widgets/lib.d.ts +1 -0
  61. package/dist/src/toolbar/widgets/lib.js +1 -0
  62. package/dist/src/tools/Eraser.js +5 -2
  63. package/dist/src/tools/PanZoom.js +12 -0
  64. package/dist/src/tools/PasteHandler.js +2 -2
  65. package/dist/src/tools/SelectionTool/Selection.d.ts +2 -1
  66. package/dist/src/tools/SelectionTool/Selection.js +3 -2
  67. package/dist/src/tools/SelectionTool/SelectionTool.js +5 -1
  68. package/package.json +1 -1
  69. package/src/Color4.test.ts +6 -0
  70. package/src/Color4.ts +6 -1
  71. package/src/Editor.loadFrom.test.ts +24 -0
  72. package/src/Editor.ts +73 -39
  73. package/src/EditorImage.ts +136 -21
  74. package/src/EventDispatcher.ts +4 -1
  75. package/src/SVGLoader.ts +12 -1
  76. package/src/Viewport.ts +17 -7
  77. package/src/components/AbstractComponent.ts +17 -1
  78. package/src/components/ImageBackground.test.ts +35 -0
  79. package/src/components/ImageBackground.ts +176 -0
  80. package/src/components/ImageComponent.ts +2 -0
  81. package/src/components/builders/ArrowBuilder.ts +44 -41
  82. package/src/components/builders/LineBuilder.ts +26 -28
  83. package/src/components/builders/RectangleBuilder.ts +1 -1
  84. package/src/components/lib.ts +2 -0
  85. package/src/components/localization.ts +4 -0
  86. package/src/localizations/es.ts +8 -0
  87. package/src/math/Mat33.test.ts +47 -3
  88. package/src/math/Mat33.ts +47 -5
  89. package/src/math/Path.ts +87 -28
  90. package/src/math/Vec3.test.ts +4 -0
  91. package/src/math/Vec3.ts +1 -1
  92. package/src/rendering/Display.ts +1 -1
  93. package/src/rendering/renderers/AbstractRenderer.ts +20 -3
  94. package/src/rendering/renderers/CanvasRenderer.ts +17 -4
  95. package/src/rendering/renderers/DummyRenderer.test.ts +1 -2
  96. package/src/rendering/renderers/SVGRenderer.ts +8 -1
  97. package/src/testing/sendTouchEvent.ts +43 -0
  98. package/src/toolbar/HTMLToolbar.ts +164 -16
  99. package/src/toolbar/IconProvider.ts +47 -2
  100. package/src/toolbar/localization.ts +10 -0
  101. package/src/toolbar/toolbar.css +2 -0
  102. package/src/toolbar/widgets/ActionButtonWidget.ts +5 -0
  103. package/src/toolbar/widgets/BaseToolWidget.ts +3 -1
  104. package/src/toolbar/widgets/BaseWidget.ts +34 -2
  105. package/src/toolbar/widgets/DocumentPropertiesWidget.ts +185 -0
  106. package/src/toolbar/widgets/HandToolWidget.ts +1 -1
  107. package/src/toolbar/widgets/OverflowWidget.css +9 -0
  108. package/src/toolbar/widgets/OverflowWidget.ts +83 -0
  109. package/src/toolbar/widgets/lib.ts +2 -1
  110. package/src/tools/Eraser.test.ts +24 -1
  111. package/src/tools/Eraser.ts +6 -2
  112. package/src/tools/PanZoom.test.ts +267 -23
  113. package/src/tools/PanZoom.ts +15 -1
  114. package/src/tools/PasteHandler.ts +3 -2
  115. package/src/tools/SelectionTool/Selection.ts +3 -2
  116. package/src/tools/SelectionTool/SelectionTool.ts +6 -1
  117. package/src/types.ts +1 -0
@@ -0,0 +1,24 @@
1
+ import Color4 from './Color4';
2
+ import { imageBackgroundCSSClassName } from './components/ImageBackground';
3
+ import { RestyleableComponent } from './lib';
4
+ import SVGLoader from './SVGLoader';
5
+ import createEditor from './testing/createEditor';
6
+
7
+ describe('Editor.loadFrom', () => {
8
+ it('should remove existing BackgroundComponents when loading new BackgroundComponents', async () => {
9
+ const editor = createEditor();
10
+ await editor.dispatch(editor.setBackgroundColor(Color4.red));
11
+
12
+ let backgroundComponents = editor.image.getBackgroundComponents();
13
+ expect(backgroundComponents).toHaveLength(1);
14
+ expect((backgroundComponents[0] as RestyleableComponent).getStyle().color).objEq(Color4.red);
15
+
16
+ await editor.loadFrom(SVGLoader.fromString(`<svg viewBox='0 0 100 100'>
17
+ <path class='${imageBackgroundCSSClassName}' d='m0,0 L100,0 L100,100 L0,100 z' fill='#000'/>
18
+ </svg>`, true));
19
+
20
+ backgroundComponents = editor.image.getBackgroundComponents();
21
+ expect(backgroundComponents).toHaveLength(1);
22
+ expect((backgroundComponents[0] as RestyleableComponent).getStyle().color).objEq(Color4.black);
23
+ });
24
+ });
package/src/Editor.ts CHANGED
@@ -26,6 +26,8 @@ import fileToBase64 from './util/fileToBase64';
26
26
  import uniteCommands from './commands/uniteCommands';
27
27
  import SelectionTool from './tools/SelectionTool/SelectionTool';
28
28
  import AbstractComponent from './components/AbstractComponent';
29
+ import Erase from './commands/Erase';
30
+ import ImageBackground, { BackgroundType } from './components/ImageBackground';
29
31
 
30
32
  type HTMLPointerEventType = 'pointerdown'|'pointermove'|'pointerup'|'pointercancel';
31
33
  type HTMLPointerEventFilter = (eventName: HTMLPointerEventType, event: PointerEvent)=>boolean;
@@ -109,9 +111,6 @@ export class Editor {
109
111
  */
110
112
  public readonly image: EditorImage;
111
113
 
112
- /** Viewport for the exported/imported image. */
113
- private importExportViewport: Viewport;
114
-
115
114
  /**
116
115
  * Allows transforming the view and querying information about
117
116
  * what is currently visible.
@@ -215,8 +214,13 @@ export class Editor {
215
214
  this.renderingRegion.setAttribute('alt', '');
216
215
 
217
216
  this.notifier = new EventDispatcher();
218
- this.importExportViewport = new Viewport(this.notifier);
219
- this.viewport = new Viewport(this.notifier);
217
+ this.viewport = new Viewport((oldTransform, newTransform) => {
218
+ this.notifier.dispatch(EditorEventType.ViewportChanged, {
219
+ kind: EditorEventType.ViewportChanged,
220
+ newTransform,
221
+ oldTransform,
222
+ });
223
+ });
220
224
  this.display = new Display(this, this.settings.renderingMode, this.renderingRegion);
221
225
  this.image = new EditorImage();
222
226
  this.history = new UndoRedoHistory(this, this.announceRedoCallback, this.announceUndoCallback);
@@ -224,9 +228,6 @@ export class Editor {
224
228
 
225
229
  parent.appendChild(this.container);
226
230
 
227
- // Default to a 500x500 image
228
- this.importExportViewport.updateScreenSize(Vec2.of(500, 500));
229
-
230
231
  this.viewport.updateScreenSize(
231
232
  Vec2.of(this.display.width, this.display.height)
232
233
  );
@@ -356,9 +357,10 @@ export class Editor {
356
357
  this.viewport.updateScreenSize(
357
358
  Vec2.of(this.display.width, this.display.height)
358
359
  );
360
+ this.queueRerender();
359
361
  });
360
362
 
361
- window.addEventListener('resize', () => {
363
+ const handleResize = () => {
362
364
  this.notifier.dispatch(EditorEventType.DisplayResized, {
363
365
  kind: EditorEventType.DisplayResized,
364
366
  newSize: Vec2.of(
@@ -366,8 +368,14 @@ export class Editor {
366
368
  this.display.height
367
369
  ),
368
370
  });
369
- this.queueRerender();
370
- });
371
+ };
372
+
373
+ if ('ResizeObserver' in (window as any)) {
374
+ const resizeObserver = new ResizeObserver(handleResize);
375
+ resizeObserver.observe(this.container);
376
+ } else {
377
+ addEventListener('resize', handleResize);
378
+ }
371
379
 
372
380
  this.accessibilityControlArea.addEventListener('input', () => {
373
381
  this.accessibilityControlArea.value = '';
@@ -773,7 +781,7 @@ export class Editor {
773
781
  const exportRectFill = { fill: Color4.fromHex('#44444455') };
774
782
  const exportRectStrokeWidth = 5 * this.viewport.getSizeOfPixelOnCanvas();
775
783
  renderer.drawRect(
776
- this.importExportViewport.visibleRect,
784
+ this.getImportExportRect(),
777
785
  exportRectStrokeWidth,
778
786
  exportRectFill
779
787
  );
@@ -920,13 +928,14 @@ export class Editor {
920
928
  public toDataURL(format: 'image/png'|'image/jpeg'|'image/webp' = 'image/png'): string {
921
929
  const canvas = document.createElement('canvas');
922
930
 
923
- const resolution = this.importExportViewport.getScreenRectSize();
931
+ const importExportViewport = this.image.getImportExportViewport();
932
+ const resolution = importExportViewport.getScreenRectSize();
924
933
 
925
934
  canvas.width = resolution.x;
926
935
  canvas.height = resolution.y;
927
936
 
928
937
  const ctx = canvas.getContext('2d')!;
929
- const renderer = new CanvasRenderer(ctx, this.importExportViewport);
938
+ const renderer = new CanvasRenderer(ctx, importExportViewport);
930
939
 
931
940
  this.image.renderAll(renderer);
932
941
 
@@ -935,12 +944,12 @@ export class Editor {
935
944
  }
936
945
 
937
946
  public toSVG(): SVGElement {
938
- const importExportViewport = this.importExportViewport;
947
+ const importExportViewport = this.image.getImportExportViewport().getTemporaryClone();
939
948
  const svgNameSpace = 'http://www.w3.org/2000/svg';
940
949
  const result = document.createElementNS(svgNameSpace, 'svg');
941
950
  const renderer = new SVGRenderer(result, importExportViewport);
942
951
 
943
- const origTransform = this.importExportViewport.canvasToScreenTransform;
952
+ const origTransform = importExportViewport.canvasToScreenTransform;
944
953
  // Render with (0,0) at (0,0) — we'll handle translation with
945
954
  // the viewBox property.
946
955
  importExportViewport.resetTransform(Mat33.identity);
@@ -966,10 +975,18 @@ export class Editor {
966
975
  return result;
967
976
  }
968
977
 
978
+ /**
979
+ * Load editor data from an `ImageLoader` (e.g. an {@link SVGLoader}).
980
+ *
981
+ * @see loadFromSVG
982
+ */
969
983
  public async loadFrom(loader: ImageLoader) {
970
984
  this.showLoadingWarning(0);
971
985
  this.display.setDraftMode(true);
972
986
 
987
+ const originalBackgrounds = this.image.getBackgroundComponents();
988
+ const eraseBackgroundCommand = new Erase(originalBackgrounds);
989
+
973
990
  await loader.start(async (component) => {
974
991
  await this.dispatchNoAnnounce(EditorImage.addElement(component));
975
992
  }, (countProcessed: number, totalToProcess: number) => {
@@ -984,41 +1001,58 @@ export class Editor {
984
1001
  this.dispatchNoAnnounce(this.setImportExportRect(importExportRect), false);
985
1002
  this.dispatchNoAnnounce(this.viewport.zoomTo(importExportRect), false);
986
1003
  });
1004
+
1005
+ // Ensure that we don't have multiple overlapping BackgroundComponents. Remove
1006
+ // old BackgroundComponents.
1007
+ // Overlapping BackgroundComponents may cause changing the background color to
1008
+ // not work properly.
1009
+ if (this.image.getBackgroundComponents().length !== originalBackgrounds.length) {
1010
+ await this.dispatchNoAnnounce(eraseBackgroundCommand);
1011
+ }
1012
+
987
1013
  this.hideLoadingWarning();
988
1014
 
989
1015
  this.display.setDraftMode(false);
990
1016
  this.queueRerender();
991
1017
  }
992
1018
 
1019
+ private getTopmostBackgroundComponent(): ImageBackground|null {
1020
+ let background: ImageBackground|null = null;
1021
+
1022
+ // Find a background component, if one exists.
1023
+ // Use the last (topmost) background component if there are multiple.
1024
+ for (const component of this.image.getBackgroundComponents()) {
1025
+ if (component instanceof ImageBackground) {
1026
+ background = component;
1027
+ }
1028
+ }
1029
+
1030
+ return background;
1031
+ }
1032
+
1033
+ /**
1034
+ * Set the background color of the image.
1035
+ */
1036
+ public setBackgroundColor(color: Color4): Command {
1037
+ let background = this.getTopmostBackgroundComponent();
1038
+
1039
+ if (!background) {
1040
+ const backgroundType = color.eq(Color4.transparent) ? BackgroundType.None : BackgroundType.SolidColor;
1041
+ background = new ImageBackground(backgroundType, color);
1042
+ return this.image.addElement(background);
1043
+ } else {
1044
+ return background.updateStyle({ color });
1045
+ }
1046
+ }
1047
+
993
1048
  // Returns the size of the visible region of the output SVG
994
1049
  public getImportExportRect(): Rect2 {
995
- return this.importExportViewport.visibleRect;
1050
+ return this.image.getImportExportViewport().visibleRect;
996
1051
  }
997
1052
 
998
1053
  // Resize the output SVG to match `imageRect`.
999
1054
  public setImportExportRect(imageRect: Rect2): Command {
1000
- const origSize = this.importExportViewport.visibleRect.size;
1001
- const origTransform = this.importExportViewport.canvasToScreenTransform;
1002
-
1003
- return new class extends Command {
1004
- public apply(editor: Editor) {
1005
- const viewport = editor.importExportViewport;
1006
- viewport.updateScreenSize(imageRect.size);
1007
- viewport.resetTransform(Mat33.translation(imageRect.topLeft.times(-1)));
1008
- editor.queueRerender();
1009
- }
1010
-
1011
- public unapply(editor: Editor) {
1012
- const viewport = editor.importExportViewport;
1013
- viewport.updateScreenSize(origSize);
1014
- viewport.resetTransform(origTransform);
1015
- editor.queueRerender();
1016
- }
1017
-
1018
- public description(_editor: Editor, localizationTable: EditorLocalization) {
1019
- return localizationTable.resizeOutputCommand(imageRect);
1020
- }
1021
- };
1055
+ return this.image.setImportExportRect(imageRect);
1022
1056
  }
1023
1057
 
1024
1058
  /**
@@ -6,32 +6,105 @@ import Rect2 from './math/Rect2';
6
6
  import { EditorLocalization } from './localization';
7
7
  import RenderingCache from './rendering/caching/RenderingCache';
8
8
  import SerializableCommand from './commands/SerializableCommand';
9
+ import EventDispatcher from './EventDispatcher';
10
+ import { Vec2 } from './math/Vec2';
11
+ import Command from './commands/Command';
12
+ import Mat33 from './math/Mat33';
9
13
 
10
14
  // @internal Sort by z-index, low to high
11
15
  export const sortLeavesByZIndex = (leaves: Array<ImageNode>) => {
12
16
  leaves.sort((a, b) => a.getContent()!.getZIndex() - b.getContent()!.getZIndex());
13
17
  };
14
18
 
19
+ export enum EditorImageEventType {
20
+ ExportViewportChanged
21
+ }
22
+
23
+ export type EditorImageNotifier = EventDispatcher<EditorImageEventType, { image: EditorImage }>;
24
+
15
25
  // Handles lookup/storage of elements in the image
16
26
  export default class EditorImage {
17
27
  private root: ImageNode;
28
+ private background: ImageNode;
18
29
  private componentsById: Record<string, AbstractComponent>;
19
30
 
31
+ /** Viewport for the exported/imported image. */
32
+ private importExportViewport: Viewport;
33
+
34
+ // @internal
35
+ public readonly notifier: EditorImageNotifier;
36
+
20
37
  // @internal
21
38
  public constructor() {
22
39
  this.root = new ImageNode();
40
+ this.background = new ImageNode();
23
41
  this.componentsById = {};
42
+
43
+ this.notifier = new EventDispatcher();
44
+ this.importExportViewport = new Viewport(() => {
45
+ this.notifier.dispatch(EditorImageEventType.ExportViewportChanged, {
46
+ image: this,
47
+ });
48
+ });
49
+
50
+ // Default to a 500x500 image
51
+ this.importExportViewport.updateScreenSize(Vec2.of(500, 500));
24
52
  }
25
53
 
26
- // Returns the parent of the given element, if it exists.
27
- public findParent(elem: AbstractComponent): ImageNode|null {
28
- const candidates = this.root.getLeavesIntersectingRegion(elem.getBBox());
29
- for (const candidate of candidates) {
30
- if (candidate.getContent() === elem) {
31
- return candidate;
54
+ /**
55
+ * @returns a `Viewport` for rendering the image when importing/exporting.
56
+ */
57
+ public getImportExportViewport() {
58
+ return this.importExportViewport;
59
+ }
60
+
61
+ public setImportExportRect(imageRect: Rect2): Command {
62
+ const importExportViewport = this.getImportExportViewport();
63
+ const origSize = importExportViewport.visibleRect.size;
64
+ const origTransform = importExportViewport.canvasToScreenTransform;
65
+
66
+ return new class extends Command {
67
+ public apply(editor: Editor) {
68
+ const viewport = editor.image.getImportExportViewport();
69
+ viewport.updateScreenSize(imageRect.size);
70
+ viewport.resetTransform(Mat33.translation(imageRect.topLeft.times(-1)));
71
+ editor.queueRerender();
72
+ }
73
+
74
+ public unapply(editor: Editor) {
75
+ const viewport = editor.image.getImportExportViewport();
76
+ viewport.updateScreenSize(origSize);
77
+ viewport.resetTransform(origTransform);
78
+ editor.queueRerender();
79
+ }
80
+
81
+ public description(_editor: Editor, localizationTable: EditorLocalization) {
82
+ return localizationTable.resizeOutputCommand(imageRect);
83
+ }
84
+ };
85
+ }
86
+
87
+ // Returns all components that make up the background of this image. These
88
+ // components are rendered below all other components.
89
+ public getBackgroundComponents(): AbstractComponent[] {
90
+ const result = [];
91
+
92
+ const leaves = this.background.getLeaves();
93
+ sortLeavesByZIndex(leaves);
94
+
95
+ for (const leaf of leaves) {
96
+ const content = leaf.getContent();
97
+
98
+ if (content) {
99
+ result.push(content);
32
100
  }
33
101
  }
34
- return null;
102
+ return result;
103
+ }
104
+
105
+ // Returns the parent of the given element, if it exists.
106
+ public findParent(elem: AbstractComponent): ImageNode|null {
107
+ return this.background.getChildWithContent(elem) ?? this.root.getChildWithContent(elem);
35
108
  }
36
109
 
37
110
  // Forces a re-render of `elem` when the image is next re-rendered as a whole.
@@ -49,22 +122,22 @@ export default class EditorImage {
49
122
 
50
123
  /** @internal */
51
124
  public renderWithCache(screenRenderer: AbstractRenderer, cache: RenderingCache, viewport: Viewport) {
125
+ this.background.render(screenRenderer, viewport.visibleRect);
52
126
  cache.render(screenRenderer, this.root, viewport);
53
127
  }
54
128
 
55
- /** @internal */
56
- public render(renderer: AbstractRenderer, viewport: Viewport) {
57
- this.root.render(renderer, viewport.visibleRect);
129
+ /**
130
+ * Renders all nodes visible from `viewport` (or all nodes if `viewport = null`)
131
+ * @internal
132
+ */
133
+ public render(renderer: AbstractRenderer, viewport: Viewport|null) {
134
+ this.background.render(renderer, viewport?.visibleRect);
135
+ this.root.render(renderer, viewport?.visibleRect);
58
136
  }
59
137
 
60
138
  /** Renders all nodes, even ones not within the viewport. @internal */
61
139
  public renderAll(renderer: AbstractRenderer) {
62
- const leaves = this.root.getLeaves();
63
- sortLeavesByZIndex(leaves);
64
-
65
- for (const leaf of leaves) {
66
- leaf.getContent()!.render(renderer, leaf.getBBox());
67
- }
140
+ this.render(renderer, null);
68
141
  }
69
142
 
70
143
  /** @returns all elements in the image, sorted by z-index. This can be slow for large images. */
@@ -98,8 +171,26 @@ export default class EditorImage {
98
171
  }
99
172
 
100
173
  private addElementDirectly(elem: AbstractComponent): ImageNode {
174
+ elem.onAddToImage(this);
175
+
101
176
  this.componentsById[elem.getId()] = elem;
102
- return this.root.addLeaf(elem);
177
+
178
+ // If a background component, add to the background. Else,
179
+ // add to the normal component tree.
180
+ const parentTree = elem.isBackground() ? this.background : this.root;
181
+ return parentTree.addLeaf(elem);
182
+ }
183
+
184
+ private removeElementDirectly(element: AbstractComponent) {
185
+ const container = this.findParent(element);
186
+ container?.remove();
187
+
188
+ if (container) {
189
+ this.onDestroyElement(element);
190
+ return true;
191
+ }
192
+
193
+ return false;
103
194
  }
104
195
 
105
196
  /**
@@ -113,6 +204,11 @@ export default class EditorImage {
113
204
  return new EditorImage.AddElementCommand(elem, applyByFlattening);
114
205
  }
115
206
 
207
+ /** @see EditorImage.addElement */
208
+ public addElement(elem: AbstractComponent, applyByFlattening?: boolean) {
209
+ return EditorImage.addElement(elem, applyByFlattening);
210
+ }
211
+
116
212
  // A Command that can access private [EditorImage] functionality
117
213
  private static AddElementCommand = class extends SerializableCommand {
118
214
  private serializedElem: any;
@@ -147,8 +243,7 @@ export default class EditorImage {
147
243
  }
148
244
 
149
245
  public unapply(editor: Editor) {
150
- const container = editor.image.findParent(this.element);
151
- container?.remove();
246
+ editor.image.removeElementDirectly(this.element);
152
247
  editor.queueRerender();
153
248
  }
154
249
 
@@ -255,6 +350,19 @@ export class ImageNode {
255
350
  return result;
256
351
  }
257
352
 
353
+ // Returns the child of this with the target content or `null` if no
354
+ // such child exists.
355
+ public getChildWithContent(target: AbstractComponent): ImageNode|null {
356
+ const candidates = this.getLeavesIntersectingRegion(target.getBBox());
357
+ for (const candidate of candidates) {
358
+ if (candidate.getContent() === target) {
359
+ return candidate;
360
+ }
361
+ }
362
+
363
+ return null;
364
+ }
365
+
258
366
  // Returns a list of leaves with this as an ancestor.
259
367
  // Like getLeavesInRegion, but does not check whether ancestors are in a given rectangle
260
368
  public getLeaves(): ImageNode[] {
@@ -388,6 +496,8 @@ export class ImageNode {
388
496
 
389
497
  // Remove this node and all of its children
390
498
  public remove() {
499
+ this.content?.onRemoveFromImage();
500
+
391
501
  if (!this.parent) {
392
502
  this.content = null;
393
503
  this.children = [];
@@ -417,8 +527,13 @@ export class ImageNode {
417
527
  this.children = [];
418
528
  }
419
529
 
420
- public render(renderer: AbstractRenderer, visibleRect: Rect2) {
421
- const leaves = this.getLeavesIntersectingRegion(visibleRect, rect => renderer.isTooSmallToRender(rect));
530
+ public render(renderer: AbstractRenderer, visibleRect?: Rect2) {
531
+ let leaves;
532
+ if (visibleRect) {
533
+ leaves = this.getLeavesIntersectingRegion(visibleRect, rect => renderer.isTooSmallToRender(rect));
534
+ } else {
535
+ leaves = this.getLeaves();
536
+ }
422
537
  sortLeavesByZIndex(leaves);
423
538
 
424
539
  for (const leaf of leaves) {
@@ -20,6 +20,9 @@
20
20
 
21
21
  type Listener<Value> = (data: Value)=> void;
22
22
  type CallbackHandler<EventType> = (data: EventType)=> void;
23
+ export interface DispatcherEventListener {
24
+ remove: ()=>void;
25
+ }
23
26
 
24
27
  // { @inheritDoc EventDispatcher! }
25
28
  export default class EventDispatcher<EventKeyType extends string|symbol|number, EventMessageType> {
@@ -38,7 +41,7 @@ export default class EventDispatcher<EventKeyType extends string|symbol|number,
38
41
  }
39
42
  }
40
43
 
41
- public on(eventName: EventKeyType, callback: CallbackHandler<EventMessageType>) {
44
+ public on(eventName: EventKeyType, callback: CallbackHandler<EventMessageType>): DispatcherEventListener {
42
45
  if (!this.listeners[eventName]) this.listeners[eventName] = [];
43
46
  this.listeners[eventName]!.push(callback);
44
47
 
package/src/SVGLoader.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import Color4 from './Color4';
2
2
  import AbstractComponent from './components/AbstractComponent';
3
+ import ImageBackground, { BackgroundType, imageBackgroundCSSClassName } from './components/ImageBackground';
3
4
  import ImageComponent from './components/ImageComponent';
4
5
  import Stroke from './components/Stroke';
5
6
  import SVGGlobalAttributesObject from './components/SVGGlobalAttributesObject';
@@ -186,6 +187,12 @@ export default class SVGLoader implements ImageLoader {
186
187
  await this.onAddComponent?.(elem);
187
188
  }
188
189
 
190
+ private async addBackground(node: SVGPathElement) {
191
+ const fill = Color4.fromString(node.getAttribute('fill') ?? node.style.fill ?? 'black');
192
+ const elem = new ImageBackground(BackgroundType.SolidColor, fill);
193
+ await this.onAddComponent?.(elem);
194
+ }
195
+
189
196
  // If given, 'supportedAttrs' will have x, y, etc. attributes that were used in computing the transform added to it,
190
197
  // to prevent storing duplicate transform information when saving the component.
191
198
  private getTransform(elem: SVGElement, supportedAttrs?: string[], computedStyles?: CSSStyleDeclaration): Mat33 {
@@ -359,7 +366,11 @@ export default class SVGLoader implements ImageLoader {
359
366
  // Continue -- visit the node's children.
360
367
  break;
361
368
  case 'path':
362
- await this.addPath(node as SVGPathElement);
369
+ if (node.classList.contains(imageBackgroundCSSClassName)) {
370
+ await this.addBackground(node as SVGPathElement);
371
+ } else {
372
+ await this.addPath(node as SVGPathElement);
373
+ }
363
374
  break;
364
375
  case 'text':
365
376
  await this.addText(node as SVGTextElement);
package/src/Viewport.ts CHANGED
@@ -6,7 +6,6 @@ import Rect2 from './math/Rect2';
6
6
  import { Point2, Vec2 } from './math/Vec2';
7
7
  import Vec3 from './math/Vec3';
8
8
  import { StrokeDataPoint } from './types';
9
- import { EditorEventType, EditorNotifier } from './types';
10
9
 
11
10
  // Returns the base type of some type of point/number
12
11
  type PointDataType<T extends Point2|StrokeDataPoint|number> = T extends Point2 ? Point2 : number;
@@ -15,6 +14,8 @@ export abstract class ViewportTransform extends Command {
15
14
  public abstract readonly transform: Mat33;
16
15
  }
17
16
 
17
+ type TransformChangeCallback = (oldTransform: Mat33, newTransform: Mat33)=> void;
18
+
18
19
  export class Viewport {
19
20
  // Command that translates/scales the viewport.
20
21
  private static ViewportTransform = class extends ViewportTransform {
@@ -82,11 +83,24 @@ export class Viewport {
82
83
  private screenRect: Rect2;
83
84
 
84
85
  // @internal
85
- public constructor(private notifier: EditorNotifier) {
86
+ public constructor(private onTransformChangeCallback: TransformChangeCallback) {
86
87
  this.resetTransform(Mat33.identity);
87
88
  this.screenRect = Rect2.empty;
88
89
  }
89
90
 
91
+ /**
92
+ * @returns a temporary copy of `this` that does not notify when modified. This is
93
+ * useful when rendering with a temporarily different viewport.
94
+ */
95
+ public getTemporaryClone(): Viewport {
96
+ const result = new Viewport(() => {});
97
+ result.transform = this.transform;
98
+ result.inverseTransform = this.inverseTransform;
99
+ result.screenRect = this.screenRect;
100
+
101
+ return result;
102
+ }
103
+
90
104
  // @internal
91
105
  public updateScreenSize(screenSize: Vec2) {
92
106
  this.screenRect = this.screenRect.resizedTo(screenSize);
@@ -120,11 +134,7 @@ export class Viewport {
120
134
  const oldTransform = this.transform;
121
135
  this.transform = newTransform;
122
136
  this.inverseTransform = newTransform.inverse();
123
- this.notifier.dispatch(EditorEventType.ViewportChanged, {
124
- kind: EditorEventType.ViewportChanged,
125
- newTransform,
126
- oldTransform,
127
- });
137
+ this.onTransformChangeCallback?.(oldTransform, newTransform);
128
138
  }
129
139
 
130
140
  public get screenToCanvasTransform(): Mat33 {
@@ -37,9 +37,15 @@ export default abstract class AbstractComponent {
37
37
  protected constructor(
38
38
  // A unique identifier for the type of component
39
39
  private readonly componentKind: string,
40
+ initialZIndex?: number,
40
41
  ) {
41
42
  this.lastChangedTime = (new Date()).getTime();
42
- this.zIndex = AbstractComponent.zIndexCounter++;
43
+
44
+ if (initialZIndex !== undefined) {
45
+ this.zIndex = initialZIndex;
46
+ } else {
47
+ this.zIndex = AbstractComponent.zIndexCounter++;
48
+ }
43
49
 
44
50
  // Create a unique ID.
45
51
  this.id = `${new Date().getTime()}-${Math.random()}`;
@@ -96,6 +102,10 @@ export default abstract class AbstractComponent {
96
102
  return this.contentBBox;
97
103
  }
98
104
 
105
+ /** Called when this component is added to the given image. */
106
+ public onAddToImage(_image: EditorImage): void { }
107
+ public onRemoveFromImage(): void { }
108
+
99
109
  public abstract render(canvas: AbstractRenderer, visibleRect?: Rect2): void;
100
110
 
101
111
  /** @return true if `lineSegment` intersects this component. */
@@ -146,6 +156,12 @@ export default abstract class AbstractComponent {
146
156
  return true;
147
157
  }
148
158
 
159
+ // @returns true iff this component should be added to the background, rather than the
160
+ // foreground of the image.
161
+ public isBackground(): boolean {
162
+ return false;
163
+ }
164
+
149
165
  // @returns an approximation of the proportional time it takes to render this component.
150
166
  // This is intended to be a rough estimate, but, for example, a stroke with two points sould have
151
167
  // a renderingWeight approximately twice that of a stroke with one point.
@@ -0,0 +1,35 @@
1
+ import Color4 from '../Color4';
2
+ import { Path, Rect2 } from '../math/lib';
3
+ import createEditor from '../testing/createEditor';
4
+ import ImageBackground, { BackgroundType, imageBackgroundCSSClassName } from './ImageBackground';
5
+
6
+ describe('ImageBackground', () => {
7
+ it('should render to fill exported SVG', () => {
8
+ const editor = createEditor();
9
+ const background = new ImageBackground(BackgroundType.SolidColor, Color4.green);
10
+ editor.image.addElement(
11
+ background
12
+ ).apply(editor);
13
+
14
+ const expectedImportExportRect = new Rect2(-10, 10, 15, 20);
15
+ editor.setImportExportRect(expectedImportExportRect).apply(editor);
16
+ expect(editor.getImportExportRect()).objEq(expectedImportExportRect);
17
+
18
+ expect(background.getBBox()).objEq(expectedImportExportRect);
19
+
20
+ const rendered = editor.toSVG();
21
+ const renderedBackground = rendered.querySelector(`.${imageBackgroundCSSClassName}`);
22
+
23
+ if (renderedBackground === null) {
24
+ throw new Error('ImageBackground did not render in exported SVG');
25
+ }
26
+
27
+ expect(renderedBackground.tagName.toLowerCase()).toBe('path');
28
+
29
+ const pathString = renderedBackground.getAttribute('d')!;
30
+ expect(pathString).not.toBeNull();
31
+
32
+ const path = Path.fromString(pathString);
33
+ expect(path.bbox).objEq(editor.getImportExportRect());
34
+ });
35
+ });