js-draw 0.1.5 → 0.1.8

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 (139) hide show
  1. package/.eslintrc.js +1 -0
  2. package/CHANGELOG.md +16 -0
  3. package/README.md +2 -2
  4. package/dist/bundle.js +1 -1
  5. package/dist/src/Color4.js +6 -2
  6. package/dist/src/Editor.d.ts +1 -0
  7. package/dist/src/Editor.js +24 -9
  8. package/dist/src/EditorImage.d.ts +8 -13
  9. package/dist/src/EditorImage.js +51 -29
  10. package/dist/src/SVGLoader.js +6 -2
  11. package/dist/src/Viewport.d.ts +10 -2
  12. package/dist/src/Viewport.js +8 -6
  13. package/dist/src/commands/Command.d.ts +9 -8
  14. package/dist/src/commands/Command.js +15 -14
  15. package/dist/src/commands/Duplicate.d.ts +14 -0
  16. package/dist/src/commands/Duplicate.js +34 -0
  17. package/dist/src/commands/Erase.d.ts +5 -2
  18. package/dist/src/commands/Erase.js +28 -9
  19. package/dist/src/commands/SerializableCommand.d.ts +13 -0
  20. package/dist/src/commands/SerializableCommand.js +28 -0
  21. package/dist/src/commands/localization.d.ts +2 -0
  22. package/dist/src/commands/localization.js +2 -0
  23. package/dist/src/components/AbstractComponent.d.ts +15 -2
  24. package/dist/src/components/AbstractComponent.js +122 -26
  25. package/dist/src/components/SVGGlobalAttributesObject.d.ts +6 -1
  26. package/dist/src/components/SVGGlobalAttributesObject.js +23 -1
  27. package/dist/src/components/Stroke.d.ts +5 -0
  28. package/dist/src/components/Stroke.js +32 -1
  29. package/dist/src/components/Text.d.ts +11 -4
  30. package/dist/src/components/Text.js +57 -3
  31. package/dist/src/components/UnknownSVGObject.d.ts +2 -0
  32. package/dist/src/components/UnknownSVGObject.js +12 -1
  33. package/dist/src/components/builders/RectangleBuilder.d.ts +3 -1
  34. package/dist/src/components/builders/RectangleBuilder.js +17 -8
  35. package/dist/src/components/util/describeComponentList.d.ts +4 -0
  36. package/dist/src/components/util/describeComponentList.js +14 -0
  37. package/dist/src/geometry/Path.d.ts +4 -1
  38. package/dist/src/geometry/Path.js +4 -0
  39. package/dist/src/rendering/Display.d.ts +3 -0
  40. package/dist/src/rendering/Display.js +13 -0
  41. package/dist/src/rendering/RenderingStyle.d.ts +24 -0
  42. package/dist/src/rendering/RenderingStyle.js +32 -0
  43. package/dist/src/rendering/caching/RenderingCacheNode.js +5 -1
  44. package/dist/src/rendering/renderers/AbstractRenderer.d.ts +1 -8
  45. package/dist/src/rendering/renderers/AbstractRenderer.js +1 -6
  46. package/dist/src/rendering/renderers/CanvasRenderer.d.ts +2 -1
  47. package/dist/src/rendering/renderers/DummyRenderer.d.ts +2 -1
  48. package/dist/src/rendering/renderers/SVGRenderer.d.ts +2 -1
  49. package/dist/src/rendering/renderers/TextOnlyRenderer.d.ts +2 -1
  50. package/dist/src/toolbar/HTMLToolbar.d.ts +1 -1
  51. package/dist/src/toolbar/HTMLToolbar.js +52 -534
  52. package/dist/src/toolbar/icons.d.ts +5 -0
  53. package/dist/src/toolbar/icons.js +186 -13
  54. package/dist/src/toolbar/localization.d.ts +4 -0
  55. package/dist/src/toolbar/localization.js +4 -0
  56. package/dist/src/toolbar/makeColorInput.d.ts +5 -0
  57. package/dist/src/toolbar/makeColorInput.js +81 -0
  58. package/dist/src/toolbar/widgets/BaseToolWidget.d.ts +12 -0
  59. package/dist/src/toolbar/widgets/BaseToolWidget.js +44 -0
  60. package/dist/src/toolbar/widgets/BaseWidget.d.ts +32 -0
  61. package/dist/src/toolbar/widgets/BaseWidget.js +148 -0
  62. package/dist/src/toolbar/widgets/EraserWidget.d.ts +6 -0
  63. package/dist/src/toolbar/widgets/EraserWidget.js +14 -0
  64. package/dist/src/toolbar/widgets/HandToolWidget.d.ts +13 -0
  65. package/dist/src/toolbar/widgets/HandToolWidget.js +133 -0
  66. package/dist/src/toolbar/widgets/PenWidget.d.ts +20 -0
  67. package/dist/src/toolbar/widgets/PenWidget.js +131 -0
  68. package/dist/src/toolbar/widgets/SelectionWidget.d.ts +11 -0
  69. package/dist/src/toolbar/widgets/SelectionWidget.js +56 -0
  70. package/dist/src/toolbar/widgets/TextToolWidget.d.ts +13 -0
  71. package/dist/src/toolbar/widgets/TextToolWidget.js +72 -0
  72. package/dist/src/tools/Pen.js +1 -1
  73. package/dist/src/tools/PipetteTool.d.ts +20 -0
  74. package/dist/src/tools/PipetteTool.js +40 -0
  75. package/dist/src/tools/SelectionTool.d.ts +2 -0
  76. package/dist/src/tools/SelectionTool.js +41 -23
  77. package/dist/src/tools/TextTool.js +1 -1
  78. package/dist/src/tools/ToolController.d.ts +3 -1
  79. package/dist/src/tools/ToolController.js +4 -0
  80. package/dist/src/tools/localization.d.ts +2 -1
  81. package/dist/src/tools/localization.js +3 -2
  82. package/dist/src/types.d.ts +7 -2
  83. package/dist/src/types.js +1 -0
  84. package/jest.config.js +2 -0
  85. package/package.json +6 -6
  86. package/src/Color4.ts +9 -3
  87. package/src/Editor.ts +29 -12
  88. package/src/EditorImage.test.ts +5 -5
  89. package/src/EditorImage.ts +61 -20
  90. package/src/SVGLoader.ts +9 -3
  91. package/src/Viewport.ts +7 -6
  92. package/src/commands/Command.ts +21 -19
  93. package/src/commands/Duplicate.ts +49 -0
  94. package/src/commands/Erase.ts +34 -13
  95. package/src/commands/SerializableCommand.ts +41 -0
  96. package/src/commands/localization.ts +5 -0
  97. package/src/components/AbstractComponent.ts +168 -26
  98. package/src/components/SVGGlobalAttributesObject.ts +34 -2
  99. package/src/components/Stroke.test.ts +53 -0
  100. package/src/components/Stroke.ts +37 -2
  101. package/src/components/Text.test.ts +38 -0
  102. package/src/components/Text.ts +80 -5
  103. package/src/components/UnknownSVGObject.test.ts +10 -0
  104. package/src/components/UnknownSVGObject.ts +15 -1
  105. package/src/components/builders/FreehandLineBuilder.ts +2 -1
  106. package/src/components/builders/RectangleBuilder.ts +23 -8
  107. package/src/components/util/describeComponentList.ts +18 -0
  108. package/src/geometry/Path.ts +8 -1
  109. package/src/rendering/Display.ts +17 -1
  110. package/src/rendering/RenderingStyle.test.ts +68 -0
  111. package/src/rendering/RenderingStyle.ts +46 -0
  112. package/src/rendering/caching/RenderingCache.test.ts +1 -1
  113. package/src/rendering/caching/RenderingCacheNode.ts +6 -1
  114. package/src/rendering/renderers/AbstractRenderer.ts +1 -15
  115. package/src/rendering/renderers/CanvasRenderer.ts +2 -1
  116. package/src/rendering/renderers/DummyRenderer.ts +2 -1
  117. package/src/rendering/renderers/SVGRenderer.ts +2 -1
  118. package/src/rendering/renderers/TextOnlyRenderer.ts +2 -1
  119. package/src/toolbar/HTMLToolbar.ts +58 -660
  120. package/src/toolbar/icons.ts +205 -13
  121. package/src/toolbar/localization.ts +10 -2
  122. package/src/toolbar/makeColorInput.ts +105 -0
  123. package/src/toolbar/toolbar.css +116 -78
  124. package/src/toolbar/widgets/BaseToolWidget.ts +53 -0
  125. package/src/toolbar/widgets/BaseWidget.ts +175 -0
  126. package/src/toolbar/widgets/EraserWidget.ts +16 -0
  127. package/src/toolbar/widgets/HandToolWidget.ts +186 -0
  128. package/src/toolbar/widgets/PenWidget.ts +165 -0
  129. package/src/toolbar/widgets/SelectionWidget.ts +72 -0
  130. package/src/toolbar/widgets/TextToolWidget.ts +90 -0
  131. package/src/tools/Pen.ts +1 -1
  132. package/src/tools/PipetteTool.ts +56 -0
  133. package/src/tools/SelectionTool.test.ts +2 -4
  134. package/src/tools/SelectionTool.ts +47 -27
  135. package/src/tools/TextTool.ts +1 -1
  136. package/src/tools/ToolController.ts +10 -6
  137. package/src/tools/UndoRedoShortcut.test.ts +1 -1
  138. package/src/tools/localization.ts +6 -3
  139. package/src/types.ts +12 -1
@@ -1,18 +1,23 @@
1
1
  import AbstractComponent from '../components/AbstractComponent';
2
+ import describeComponentList from '../components/util/describeComponentList';
2
3
  import Editor from '../Editor';
3
4
  import EditorImage from '../EditorImage';
4
5
  import { EditorLocalization } from '../localization';
5
- import Command from './Command';
6
+ import SerializableCommand from './SerializableCommand';
6
7
 
7
- export default class Erase implements Command {
8
+ export default class Erase extends SerializableCommand {
8
9
  private toRemove: AbstractComponent[];
10
+ private applied: boolean;
9
11
 
10
12
  public constructor(toRemove: AbstractComponent[]) {
13
+ super('erase');
14
+
11
15
  // Clone the list
12
16
  this.toRemove = toRemove.map(elem => elem);
17
+ this.applied = false;
13
18
  }
14
19
 
15
- public apply(editor: Editor): void {
20
+ public apply(editor: Editor) {
16
21
  for (const part of this.toRemove) {
17
22
  const parent = editor.image.findParent(part);
18
23
 
@@ -21,32 +26,48 @@ export default class Erase implements Command {
21
26
  }
22
27
  }
23
28
 
29
+ this.applied = true;
24
30
  editor.queueRerender();
25
31
  }
26
32
 
27
- public unapply(editor: Editor): void {
33
+ public unapply(editor: Editor) {
28
34
  for (const part of this.toRemove) {
29
35
  if (!editor.image.findParent(part)) {
30
- new EditorImage.AddElementCommand(part).apply(editor);
36
+ EditorImage.addElement(part).apply(editor);
31
37
  }
32
38
  }
33
39
 
40
+ this.applied = false;
34
41
  editor.queueRerender();
35
42
  }
36
43
 
44
+ public onDrop(editor: Editor) {
45
+ if (this.applied) {
46
+ for (const part of this.toRemove) {
47
+ editor.image.onDestroyElement(part);
48
+ }
49
+ }
50
+ }
51
+
37
52
  public description(localizationTable: EditorLocalization): string {
38
53
  if (this.toRemove.length === 0) {
39
54
  return localizationTable.erasedNoElements;
40
55
  }
41
56
 
42
- let description = this.toRemove[0].description(localizationTable);
43
- for (const elem of this.toRemove) {
44
- if (elem.description(localizationTable) !== description) {
45
- description = localizationTable.elements;
46
- break;
47
- }
48
- }
49
-
57
+ const description = describeComponentList(localizationTable, this.toRemove) ?? localizationTable.elements;
50
58
  return localizationTable.eraseAction(description, this.toRemove.length);
51
59
  }
60
+
61
+ protected serializeToString() {
62
+ const elemIds = this.toRemove.map(elem => elem.getId());
63
+ return JSON.stringify(elemIds);
64
+ }
65
+
66
+ static {
67
+ SerializableCommand.register('erase', (data: string, editor: Editor) => {
68
+ const json = JSON.parse(data);
69
+ const elems = json.map((elemId: string) => editor.image.lookupElement(elemId));
70
+ return new Erase(elems);
71
+ });
72
+ }
52
73
  }
@@ -0,0 +1,41 @@
1
+ import Editor from '../Editor';
2
+ import Command from './Command';
3
+
4
+ type DeserializationCallback = (data: string, editor: Editor) => SerializableCommand;
5
+
6
+ export default abstract class SerializableCommand extends Command {
7
+ public constructor(private commandTypeId: string) {
8
+ super();
9
+
10
+ if (!(commandTypeId in SerializableCommand.deserializationCallbacks)) {
11
+ throw new Error(
12
+ `Command ${commandTypeId} must have a registered deserialization callback. To do this, call SerializableCommand.register.`
13
+ );
14
+ }
15
+ }
16
+
17
+ protected abstract serializeToString(): string;
18
+ private static deserializationCallbacks: Record<string, DeserializationCallback> = {};
19
+
20
+ public serialize(): string {
21
+ return JSON.stringify({
22
+ data: this.serializeToString(),
23
+ commandType: this.commandTypeId,
24
+ });
25
+ }
26
+
27
+ public static deserialize(data: string, editor: Editor): SerializableCommand {
28
+ const json = JSON.parse(data);
29
+ const commandType = json.commandType as string;
30
+
31
+ if (!(commandType in SerializableCommand.deserializationCallbacks)) {
32
+ throw new Error(`Unrecognised command type ${commandType}!`);
33
+ }
34
+
35
+ return SerializableCommand.deserializationCallbacks[commandType](json.data as string, editor);
36
+ }
37
+
38
+ public static register(commandTypeId: string, deserialize: DeserializationCallback) {
39
+ SerializableCommand.deserializationCallbacks[commandTypeId] = deserialize;
40
+ }
41
+ }
@@ -9,12 +9,14 @@ export interface CommandLocalization {
9
9
  zoomedOut: string;
10
10
  zoomedIn: string;
11
11
  erasedNoElements: string;
12
+ duplicatedNoElements: string;
12
13
  elements: string;
13
14
  updatedViewport: string;
14
15
  transformedElements: (elemCount: number) => string;
15
16
  resizeOutputCommand: (newSize: Rect2) => string;
16
17
  addElementAction: (elemDescription: string) => string;
17
18
  eraseAction: (elemDescription: string, numElems: number) => string;
19
+ duplicateAction: (elemDescription: string, count: number)=> string;
18
20
 
19
21
  selectedElements: (count: number)=>string;
20
22
  }
@@ -25,8 +27,11 @@ export const defaultCommandLocalization: CommandLocalization = {
25
27
  resizeOutputCommand: (newSize: Rect2) => `Resized image to ${newSize.w}x${newSize.h}`,
26
28
  addElementAction: (componentDescription: string) => `Added ${componentDescription}`,
27
29
  eraseAction: (componentDescription: string, numElems: number) => `Erased ${numElems} ${componentDescription}`,
30
+ duplicateAction: (componentDescription: string, numElems: number) => `Duplicated ${numElems} ${componentDescription}`,
28
31
  elements: 'Elements',
29
32
  erasedNoElements: 'Erased nothing',
33
+ duplicatedNoElements: 'Duplicated nothing',
34
+
30
35
  rotatedBy: (degrees) => `Rotated by ${Math.abs(degrees)} degrees ${degrees < 0 ? 'clockwise' : 'counter-clockwise'}`,
31
36
  movedLeft: 'Moved left',
32
37
  movedUp: 'Moved up',
@@ -1,26 +1,57 @@
1
1
  import Command from '../commands/Command';
2
+ import SerializableCommand from '../commands/SerializableCommand';
2
3
  import Editor from '../Editor';
3
4
  import EditorImage from '../EditorImage';
4
5
  import LineSegment2 from '../geometry/LineSegment2';
5
6
  import Mat33 from '../geometry/Mat33';
6
7
  import Rect2 from '../geometry/Rect2';
8
+ import { EditorLocalization } from '../localization';
7
9
  import AbstractRenderer from '../rendering/renderers/AbstractRenderer';
8
10
  import { ImageComponentLocalization } from './localization';
9
11
 
10
- type LoadSaveData = unknown;
12
+ type LoadSaveData = (string[]|Record<symbol, string|number>);
11
13
  export type LoadSaveDataTable = Record<string, Array<LoadSaveData>>;
14
+ type DeserializeCallback = (data: string)=>AbstractComponent;
15
+ type ComponentId = string;
12
16
 
13
17
  export default abstract class AbstractComponent {
14
18
  protected lastChangedTime: number;
15
19
  protected abstract contentBBox: Rect2;
16
20
  private zIndex: number;
21
+ private id: string;
17
22
 
18
23
  // Topmost z-index
19
24
  private static zIndexCounter: number = 0;
20
25
 
21
- protected constructor() {
26
+ protected constructor(
27
+ // A unique identifier for the type of component
28
+ private readonly componentKind: string,
29
+ ) {
22
30
  this.lastChangedTime = (new Date()).getTime();
23
31
  this.zIndex = AbstractComponent.zIndexCounter++;
32
+
33
+ // Create a unique ID.
34
+ this.id = `${new Date().getTime()}-${Math.random()}`;
35
+
36
+ if (AbstractComponent.deserializationCallbacks[componentKind] === undefined) {
37
+ throw new Error(`Component ${componentKind} has not been registered using AbstractComponent.registerComponent`);
38
+ }
39
+ }
40
+
41
+ public getId() {
42
+ return this.id;
43
+ }
44
+
45
+ private static deserializationCallbacks: Record<ComponentId, DeserializeCallback|null> = {};
46
+
47
+ // Store the deserialization callback (or lack of it) for [componentKind].
48
+ // If components are registered multiple times (as may be done in automated tests),
49
+ // the most recent deserialization callback is used.
50
+ public static registerComponent(
51
+ componentKind: string,
52
+ deserialize: DeserializeCallback|null,
53
+ ) {
54
+ this.deserializationCallbacks[componentKind] = deserialize ?? null;
24
55
  }
25
56
 
26
57
  // Get and manage data attached by a loader.
@@ -45,48 +76,159 @@ export default abstract class AbstractComponent {
45
76
  public abstract render(canvas: AbstractRenderer, visibleRect?: Rect2): void;
46
77
  public abstract intersects(lineSegment: LineSegment2): boolean;
47
78
 
79
+ // Return null iff this object cannot be safely serialized/deserialized.
80
+ protected abstract serializeToString(): string|null;
81
+
48
82
  // Private helper for transformBy: Apply the given transformation to all points of this.
49
83
  protected abstract applyTransformation(affineTransfm: Mat33): void;
50
84
 
51
85
  // Returns a command that, when applied, transforms this by [affineTransfm] and
52
86
  // updates the editor.
53
87
  public transformBy(affineTransfm: Mat33): Command {
54
- const updateTransform = (editor: Editor, newTransfm: Mat33) => {
88
+ return new AbstractComponent.TransformElementCommand(affineTransfm, this);
89
+ }
90
+
91
+ private static TransformElementCommand = class extends SerializableCommand {
92
+ private origZIndex: number;
93
+
94
+ public constructor(
95
+ private affineTransfm: Mat33,
96
+ private component: AbstractComponent,
97
+ ) {
98
+ super('transform-element');
99
+ this.origZIndex = component.zIndex;
100
+ }
101
+
102
+ private updateTransform(editor: Editor, newTransfm: Mat33) {
55
103
  // Any parent should have only one direct child.
56
- const parent = editor.image.findParent(this);
104
+ const parent = editor.image.findParent(this.component);
57
105
  let hadParent = false;
58
106
  if (parent) {
59
107
  parent.remove();
60
108
  hadParent = true;
61
109
  }
62
110
 
63
- this.applyTransformation(newTransfm);
111
+ this.component.applyTransformation(newTransfm);
64
112
 
65
113
  // Add the element back to the document.
66
114
  if (hadParent) {
67
- new EditorImage.AddElementCommand(this).apply(editor);
115
+ EditorImage.addElement(this.component).apply(editor);
68
116
  }
69
- };
70
- const origZIndex = this.zIndex;
71
-
72
- return {
73
- apply: (editor: Editor) => {
74
- this.zIndex = AbstractComponent.zIndexCounter++;
75
- updateTransform(editor, affineTransfm);
76
- editor.queueRerender();
77
- },
78
- unapply: (editor: Editor): void => {
79
- this.zIndex = origZIndex;
80
- updateTransform(
81
- editor, affineTransfm.inverse()
117
+ }
118
+
119
+ public apply(editor: Editor) {
120
+ this.component.zIndex = AbstractComponent.zIndexCounter++;
121
+ this.updateTransform(editor, this.affineTransfm);
122
+ editor.queueRerender();
123
+ }
124
+
125
+ public unapply(editor: Editor) {
126
+ this.component.zIndex = this.origZIndex;
127
+ this.updateTransform(editor, this.affineTransfm.inverse());
128
+ editor.queueRerender();
129
+ }
130
+
131
+ public description(localizationTable: EditorLocalization) {
132
+ return localizationTable.transformedElements(1);
133
+ }
134
+
135
+ static {
136
+ SerializableCommand.register('transform-element', (data: string, editor: Editor) => {
137
+ const json = JSON.parse(data);
138
+ const elem = editor.image.lookupElement(json.id);
139
+
140
+ if (!elem) {
141
+ throw new Error(`Unable to retrieve non-existent element, ${elem}`);
142
+ }
143
+
144
+ const transform = json.transfm as [
145
+ number, number, number,
146
+ number, number, number,
147
+ number, number, number,
148
+ ];
149
+
150
+ return new AbstractComponent.TransformElementCommand(
151
+ new Mat33(...transform),
152
+ elem,
82
153
  );
83
- editor.queueRerender();
84
- },
85
- description(localizationTable) {
86
- return localizationTable.transformedElements(1);
87
- },
88
- };
89
- }
154
+ });
155
+ }
156
+
157
+ protected serializeToString(): string {
158
+ return JSON.stringify({
159
+ id: this.component.getId(),
160
+ transfm: this.affineTransfm.toArray(),
161
+ });
162
+ }
163
+ };
90
164
 
91
165
  public abstract description(localizationTable: ImageComponentLocalization): string;
166
+
167
+ protected abstract createClone(): AbstractComponent;
168
+
169
+ public clone() {
170
+ const clone = this.createClone();
171
+
172
+ for (const attachmentKey in this.loadSaveData) {
173
+ for (const val of this.loadSaveData[attachmentKey]) {
174
+ clone.attachLoadSaveData(attachmentKey, val);
175
+ }
176
+ }
177
+
178
+ return clone;
179
+ }
180
+
181
+ public serialize() {
182
+ const data = this.serializeToString();
183
+
184
+ if (data === null) {
185
+ throw new Error(`${this} cannot be serialized.`);
186
+ }
187
+
188
+ return JSON.stringify({
189
+ name: this.componentKind,
190
+ zIndex: this.zIndex,
191
+ id: this.id,
192
+ loadSaveData: this.loadSaveData,
193
+ data,
194
+ });
195
+ }
196
+
197
+ // Returns true if [data] is not deserializable. May return false even if [data]
198
+ // is not deserializable.
199
+ private static isNotDeserializable(data: string) {
200
+ const json = JSON.parse(data);
201
+
202
+ if (typeof json !== 'object') {
203
+ return true;
204
+ }
205
+
206
+ if (!this.deserializationCallbacks[json?.name]) {
207
+ return true;
208
+ }
209
+
210
+ if (!json.data) {
211
+ return true;
212
+ }
213
+
214
+ return false;
215
+ }
216
+
217
+ public static deserialize(data: string): AbstractComponent {
218
+ if (AbstractComponent.isNotDeserializable(data)) {
219
+ throw new Error(`Element with data ${data} cannot be deserialized.`);
220
+ }
221
+
222
+ const json = JSON.parse(data);
223
+ const instance = this.deserializationCallbacks[json.name]!(json.data);
224
+ instance.zIndex = json.zIndex;
225
+ instance.id = json.id;
226
+
227
+ // TODO: What should we do with json.loadSaveData?
228
+ // If we attach it to [instance], we create a potential security risk — loadSaveData
229
+ // is often used to store unrecognised attributes so they can be preserved on output.
230
+ // ...but what if we're deserializing data sent across the network?
231
+
232
+ return instance;
233
+ }
92
234
  }
@@ -6,11 +6,15 @@ import SVGRenderer from '../rendering/renderers/SVGRenderer';
6
6
  import AbstractComponent from './AbstractComponent';
7
7
  import { ImageComponentLocalization } from './localization';
8
8
 
9
+ type GlobalAttrsList = Array<[string, string|null]>;
10
+
11
+ const componentKind = 'svg-global-attributes';
12
+
9
13
  // Stores global SVG attributes (e.g. namespace identifiers.)
10
14
  export default class SVGGlobalAttributesObject extends AbstractComponent {
11
15
  protected contentBBox: Rect2;
12
- public constructor(private readonly attrs: Array<[string, string|null]>) {
13
- super();
16
+ public constructor(private readonly attrs: GlobalAttrsList) {
17
+ super(componentKind);
14
18
  this.contentBBox = Rect2.empty;
15
19
  }
16
20
 
@@ -32,7 +36,35 @@ export default class SVGGlobalAttributesObject extends AbstractComponent {
32
36
  protected applyTransformation(_affineTransfm: Mat33): void {
33
37
  }
34
38
 
39
+ protected createClone() {
40
+ return new SVGGlobalAttributesObject(this.attrs);
41
+ }
42
+
35
43
  public description(localization: ImageComponentLocalization): string {
36
44
  return localization.svgObject;
37
45
  }
46
+
47
+ protected serializeToString(): string | null {
48
+ return JSON.stringify(this.attrs);
49
+ }
50
+
51
+ public static deserializeFromString(data: string): AbstractComponent {
52
+ const json = JSON.parse(data) as GlobalAttrsList;
53
+ const attrs: GlobalAttrsList = [];
54
+
55
+ const numericAndSpaceContentExp = /^[ \t\n0-9.-eE]+$/;
56
+
57
+ // Don't deserialize all attributes, just those that should be safe.
58
+ for (const [ key, val ] of json) {
59
+ if (key === 'viewBox' || key === 'width' || key === 'height') {
60
+ if (val && numericAndSpaceContentExp.exec(val)) {
61
+ attrs.push([key, val]);
62
+ }
63
+ }
64
+ }
65
+
66
+ return new SVGGlobalAttributesObject(attrs);
67
+ }
38
68
  }
69
+
70
+ AbstractComponent.registerComponent(componentKind, SVGGlobalAttributesObject.deserializeFromString);
@@ -1,6 +1,14 @@
1
+ /* @jest-environment jsdom */
2
+
1
3
  import Color4 from '../Color4';
4
+ import Path from '../geometry/Path';
2
5
  import { Vec2 } from '../geometry/Vec2';
3
6
  import Stroke from './Stroke';
7
+ import { loadExpectExtensions } from '../testing/loadExpectExtensions';
8
+ import createEditor from '../testing/createEditor';
9
+ import Mat33 from '../geometry/Mat33';
10
+
11
+ loadExpectExtensions();
4
12
 
5
13
  describe('Stroke', () => {
6
14
  it('empty stroke should have an empty bounding box', () => {
@@ -15,4 +23,49 @@ describe('Stroke', () => {
15
23
  x: 0, y: 0, w: 0, h: 0,
16
24
  });
17
25
  });
26
+
27
+ it('cloned strokes should have the same points', () => {
28
+ const stroke = new Stroke([
29
+ Path.fromString('m1,1 2,2 3,3 z').toRenderable({ fill: Color4.red })
30
+ ]);
31
+ const clone = stroke.clone();
32
+
33
+ expect(
34
+ (clone as Stroke).getPath().toString()
35
+ ).toBe(
36
+ stroke.getPath().toString()
37
+ );
38
+ });
39
+
40
+ it('transforming a cloned stroke should not affect the original', () => {
41
+ const editor = createEditor();
42
+ const stroke = new Stroke([
43
+ Path.fromString('m1,1 2,2 3,3 z').toRenderable({ fill: Color4.red })
44
+ ]);
45
+ const origBBox = stroke.getBBox();
46
+ expect(origBBox).toMatchObject({
47
+ x: 1, y: 1,
48
+ w: 5, h: 5,
49
+ });
50
+
51
+ const copy = stroke.clone();
52
+ expect(copy.getBBox()).objEq(origBBox);
53
+
54
+ stroke.transformBy(
55
+ Mat33.scaling2D(Vec2.of(10, 10))
56
+ ).apply(editor);
57
+
58
+ expect(stroke.getBBox()).not.objEq(origBBox);
59
+ expect(copy.getBBox()).objEq(origBBox);
60
+ });
61
+
62
+ it('strokes should deserialize from JSON data', () => {
63
+ const deserialized = Stroke.deserializeFromString(`[
64
+ {
65
+ "style": { "fill": "#f00" },
66
+ "path": "m0,0 l10,10z"
67
+ }
68
+ ]`);
69
+ expect(deserialized.getPath().toString()).toBe('M0,0L10,10L0,0');
70
+ });
18
71
  });
@@ -2,7 +2,8 @@ import LineSegment2 from '../geometry/LineSegment2';
2
2
  import Mat33 from '../geometry/Mat33';
3
3
  import Path from '../geometry/Path';
4
4
  import Rect2 from '../geometry/Rect2';
5
- import AbstractRenderer, { RenderablePathSpec, RenderingStyle } from '../rendering/renderers/AbstractRenderer';
5
+ import AbstractRenderer, { RenderablePathSpec } from '../rendering/renderers/AbstractRenderer';
6
+ import RenderingStyle, { styleFromJSON, styleToJSON } from '../rendering/RenderingStyle';
6
7
  import AbstractComponent from './AbstractComponent';
7
8
  import { ImageComponentLocalization } from './localization';
8
9
 
@@ -16,7 +17,7 @@ export default class Stroke extends AbstractComponent {
16
17
  protected contentBBox: Rect2;
17
18
 
18
19
  public constructor(parts: RenderablePathSpec[]) {
19
- super();
20
+ super('stroke');
20
21
 
21
22
  this.parts = parts.map(section => {
22
23
  const path = Path.fromRenderable(section);
@@ -96,7 +97,41 @@ export default class Stroke extends AbstractComponent {
96
97
  });
97
98
  }
98
99
 
100
+ public getPath() {
101
+ return this.parts.reduce((accumulator: Path|null, current: StrokePart) => {
102
+ return accumulator?.union(current.path) ?? current.path;
103
+ }, null) ?? Path.empty;
104
+ }
105
+
99
106
  public description(localization: ImageComponentLocalization): string {
100
107
  return localization.stroke;
101
108
  }
109
+
110
+ protected createClone(): AbstractComponent {
111
+ return new Stroke(this.parts);
112
+ }
113
+
114
+ protected serializeToString(): string | null {
115
+ return JSON.stringify(this.parts.map(part => {
116
+ return {
117
+ style: styleToJSON(part.style),
118
+ path: part.path.serialize(),
119
+ };
120
+ }));
121
+ }
122
+
123
+ public static deserializeFromString(data: string): Stroke {
124
+ const json = JSON.parse(data);
125
+ if (typeof json !== 'object' || typeof json.length !== 'number') {
126
+ throw new Error(`${data} is missing required field, parts, or parts is of the wrong type.`);
127
+ }
128
+
129
+ const pathSpec: RenderablePathSpec[] = json.map((part: any) => {
130
+ const style = styleFromJSON(part.style);
131
+ return Path.fromString(part.path).toRenderable(style);
132
+ });
133
+ return new Stroke(pathSpec);
134
+ }
102
135
  }
136
+
137
+ AbstractComponent.registerComponent('stroke', Stroke.deserializeFromString);
@@ -0,0 +1,38 @@
1
+ import Color4 from '../Color4';
2
+ import Mat33 from '../geometry/Mat33';
3
+ import Rect2 from '../geometry/Rect2';
4
+ import AbstractComponent from './AbstractComponent';
5
+ import Text, { TextStyle } from './Text';
6
+ import { loadExpectExtensions } from '../testing/loadExpectExtensions';
7
+
8
+ loadExpectExtensions();
9
+
10
+ const estimateTextBounds = (text: string, style: TextStyle): Rect2 => {
11
+ const widthEst = text.length * style.size;
12
+ const heightEst = style.size;
13
+
14
+ // Text is drawn with (0, 0) as its baseline. As such, the majority of the text's height should
15
+ // be above (0, 0).
16
+ return new Rect2(0, -heightEst * 2/3, widthEst, heightEst);
17
+ };
18
+
19
+ // Don't use the default Canvas-based text bounding code. The canvas-based code may not work
20
+ // with jsdom.
21
+ AbstractComponent.registerComponent('text', (data: string) => Text.deserializeFromString(data, estimateTextBounds));
22
+
23
+ describe('Text', () => {
24
+ it('should be serializable', () => {
25
+ const style: TextStyle = {
26
+ size: 12,
27
+ fontFamily: 'serif',
28
+ renderingStyle: { fill: Color4.black },
29
+ };
30
+ const text = new Text(
31
+ [ 'Foo' ], Mat33.identity, style, estimateTextBounds
32
+ );
33
+ const serialized = text.serialize();
34
+ const deserialized = AbstractComponent.deserialize(serialized) as Text;
35
+ expect(deserialized.getBBox()).objEq(text.getBBox());
36
+ expect(deserialized['getText']()).toContain('Foo');
37
+ });
38
+ });