js-draw 0.14.0 → 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 (88) hide show
  1. package/.github/ISSUE_TEMPLATE/translation.yml +8 -0
  2. package/CHANGELOG.md +8 -1
  3. package/dist/bundle.js +1 -1
  4. package/dist/src/Color4.d.ts +4 -0
  5. package/dist/src/Color4.js +22 -0
  6. package/dist/src/Editor.d.ts +2 -1
  7. package/dist/src/Editor.js +10 -1
  8. package/dist/src/EditorImage.d.ts +1 -0
  9. package/dist/src/EditorImage.js +11 -0
  10. package/dist/src/commands/UnresolvedCommand.d.ts +14 -0
  11. package/dist/src/commands/UnresolvedCommand.js +22 -0
  12. package/dist/src/commands/uniteCommands.js +4 -2
  13. package/dist/src/components/AbstractComponent.d.ts +0 -1
  14. package/dist/src/components/AbstractComponent.js +30 -50
  15. package/dist/src/components/RestylableComponent.d.ts +24 -0
  16. package/dist/src/components/RestylableComponent.js +80 -0
  17. package/dist/src/components/Stroke.d.ts +8 -1
  18. package/dist/src/components/Stroke.js +49 -1
  19. package/dist/src/components/TextComponent.d.ts +10 -10
  20. package/dist/src/components/TextComponent.js +46 -13
  21. package/dist/src/components/lib.d.ts +2 -1
  22. package/dist/src/components/lib.js +2 -1
  23. package/dist/src/components/localization.d.ts +1 -0
  24. package/dist/src/components/localization.js +1 -0
  25. package/dist/src/rendering/TextRenderingStyle.d.ts +23 -0
  26. package/dist/src/rendering/TextRenderingStyle.js +20 -0
  27. package/dist/src/rendering/renderers/AbstractRenderer.d.ts +1 -1
  28. package/dist/src/rendering/renderers/CanvasRenderer.d.ts +1 -1
  29. package/dist/src/rendering/renderers/DummyRenderer.d.ts +1 -1
  30. package/dist/src/rendering/renderers/SVGRenderer.d.ts +1 -1
  31. package/dist/src/rendering/renderers/TextOnlyRenderer.d.ts +1 -1
  32. package/dist/src/toolbar/IconProvider.d.ts +2 -1
  33. package/dist/src/toolbar/IconProvider.js +10 -0
  34. package/dist/src/toolbar/localization.d.ts +1 -0
  35. package/dist/src/toolbar/localization.js +1 -0
  36. package/dist/src/toolbar/widgets/BaseWidget.js +10 -4
  37. package/dist/src/toolbar/widgets/InsertImageWidget.js +2 -1
  38. package/dist/src/toolbar/widgets/SelectionToolWidget.js +77 -1
  39. package/dist/src/tools/Pen.js +2 -2
  40. package/dist/src/tools/SelectionTool/SelectAllShortcutHandler.d.ts +8 -0
  41. package/dist/src/tools/SelectionTool/SelectAllShortcutHandler.js +22 -0
  42. package/dist/src/tools/SelectionTool/Selection.js +1 -1
  43. package/dist/src/tools/SelectionTool/SelectionTool.js +4 -9
  44. package/dist/src/tools/TextTool.d.ts +1 -1
  45. package/dist/src/tools/ToolController.js +2 -0
  46. package/dist/src/tools/lib.d.ts +1 -0
  47. package/dist/src/tools/lib.js +1 -0
  48. package/dist/src/tools/localization.d.ts +1 -0
  49. package/dist/src/tools/localization.js +1 -0
  50. package/package.json +1 -1
  51. package/src/Color4.test.ts +4 -0
  52. package/src/Color4.ts +26 -0
  53. package/src/Editor.toSVG.test.ts +1 -1
  54. package/src/Editor.ts +12 -1
  55. package/src/EditorImage.ts +13 -0
  56. package/src/SVGLoader.ts +2 -1
  57. package/src/commands/UnresolvedCommand.ts +37 -0
  58. package/src/commands/uniteCommands.ts +5 -2
  59. package/src/components/AbstractComponent.ts +36 -61
  60. package/src/components/RestylableComponent.ts +142 -0
  61. package/src/components/Stroke.test.ts +68 -0
  62. package/src/components/Stroke.ts +68 -2
  63. package/src/components/TextComponent.test.ts +56 -2
  64. package/src/components/TextComponent.ts +63 -25
  65. package/src/components/lib.ts +4 -1
  66. package/src/components/localization.ts +3 -0
  67. package/src/math/Rect2.test.ts +18 -6
  68. package/src/rendering/TextRenderingStyle.ts +38 -0
  69. package/src/rendering/renderers/AbstractRenderer.ts +1 -1
  70. package/src/rendering/renderers/CanvasRenderer.ts +2 -1
  71. package/src/rendering/renderers/DummyRenderer.ts +1 -1
  72. package/src/rendering/renderers/SVGRenderer.ts +1 -1
  73. package/src/rendering/renderers/TextOnlyRenderer.ts +1 -1
  74. package/src/toolbar/IconProvider.ts +12 -1
  75. package/src/toolbar/localization.ts +2 -0
  76. package/src/toolbar/widgets/BaseWidget.ts +12 -4
  77. package/src/toolbar/widgets/InsertImageWidget.ts +2 -1
  78. package/src/toolbar/widgets/SelectionToolWidget.ts +95 -1
  79. package/src/tools/PanZoom.test.ts +2 -1
  80. package/src/tools/PasteHandler.ts +1 -1
  81. package/src/tools/Pen.ts +2 -2
  82. package/src/tools/SelectionTool/SelectAllShortcutHandler.ts +28 -0
  83. package/src/tools/SelectionTool/Selection.ts +1 -1
  84. package/src/tools/SelectionTool/SelectionTool.ts +4 -9
  85. package/src/tools/TextTool.ts +2 -1
  86. package/src/tools/ToolController.ts +2 -0
  87. package/src/tools/lib.ts +1 -0
  88. package/src/tools/localization.ts +2 -0
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;
@@ -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
  };
@@ -0,0 +1,142 @@
1
+ import Color4 from '../Color4';
2
+ import SerializableCommand from '../commands/SerializableCommand';
3
+ import UnresolvedSerializableCommand from '../commands/UnresolvedCommand';
4
+ import Editor from '../Editor';
5
+ import { EditorLocalization } from '../localization';
6
+ import TextStyle, { textStyleFromJSON, textStyleToJSON } from '../rendering/TextRenderingStyle';
7
+ import AbstractComponent from './AbstractComponent';
8
+
9
+ export interface ComponentStyle {
10
+ color?: Color4;
11
+ textStyle?: TextStyle;
12
+ }
13
+
14
+ const serializeComponentStyle = (style: ComponentStyle) => {
15
+ const result: Record<string, any> = { };
16
+
17
+ if (style.color) {
18
+ result.color = style.color.toHexString();
19
+ }
20
+
21
+ if (style.textStyle) {
22
+ result.textStyle = textStyleToJSON(style.textStyle);
23
+ }
24
+
25
+ return result;
26
+ };
27
+
28
+ const deserializeComponentStyle = (json: Record<string, any>|any): ComponentStyle => {
29
+ const color = json.color ? Color4.fromHex(json.color) : undefined;
30
+ const textStyle = json.textStyle ? textStyleFromJSON(json.textStyle) : undefined;
31
+
32
+ return {
33
+ color,
34
+ textStyle,
35
+ };
36
+ };
37
+
38
+ // For internal use by Components implementing `updateStyle`:
39
+ export const createRestyleComponentCommand = (
40
+ initialStyle: ComponentStyle,
41
+ newStyle: ComponentStyle,
42
+ component: RestyleableComponent,
43
+ ): SerializableCommand => {
44
+ return new DefaultRestyleComponentCommand(
45
+ initialStyle, newStyle, component.getId(), component
46
+ );
47
+ };
48
+
49
+
50
+ // Returns true if `component` is a `RestylableComponent`.
51
+ export const isRestylableComponent = (component: AbstractComponent): component is RestyleableComponent => {
52
+ const hasMethods = 'getStyle' in component && 'updateStyle' in component && 'forceStyle' in component;
53
+ if (!hasMethods) {
54
+ return false;
55
+ }
56
+
57
+ if (!('isRestylableComponent' in component) || !(component as any)['isRestylableComponent']) {
58
+ return false;
59
+ }
60
+
61
+ return true;
62
+ };
63
+
64
+ export interface RestyleableComponent extends AbstractComponent {
65
+ getStyle(): ComponentStyle;
66
+
67
+ updateStyle(style: ComponentStyle): SerializableCommand;
68
+
69
+ /**
70
+ * Set the style of this component in a way that can't be undone/redone
71
+ * (does not create a command).
72
+ *
73
+ * Prefer `updateStyle(style).apply(editor)`.
74
+ */
75
+ forceStyle(style: ComponentStyle, editor: Editor|null): void;
76
+
77
+ isRestylableComponent: true;
78
+ }
79
+
80
+ export default RestyleableComponent;
81
+
82
+
83
+ const defaultRestyleComponentCommandId = 'default-restyle-element';
84
+
85
+ class DefaultRestyleComponentCommand extends UnresolvedSerializableCommand {
86
+ public constructor(
87
+ private originalStyle: ComponentStyle,
88
+ private newStyle: ComponentStyle,
89
+ componentID: string,
90
+ component?: RestyleableComponent,
91
+ ) {
92
+ super(defaultRestyleComponentCommandId, componentID, component);
93
+ }
94
+
95
+ private getComponent(editor: Editor): RestyleableComponent {
96
+ this.resolveComponent(editor.image);
97
+
98
+ const component = this.component as any;
99
+ if (!component || !component['forceStyle'] || !component['updateStyle']) {
100
+ throw new Error('this.component is missing forceStyle and/or updateStyle methods!');
101
+ }
102
+
103
+ return component;
104
+ }
105
+
106
+ public apply(editor: Editor) {
107
+ this.getComponent(editor).forceStyle(this.newStyle, editor);
108
+ }
109
+
110
+ public unapply(editor: Editor) {
111
+ this.getComponent(editor).forceStyle(this.originalStyle, editor);
112
+ }
113
+
114
+ public description(_editor: Editor, localizationTable: EditorLocalization): string {
115
+ return localizationTable.restyledElements;
116
+ }
117
+
118
+ protected serializeToJSON() {
119
+ return {
120
+ id: this.componentID,
121
+ originalStyle: serializeComponentStyle(this.originalStyle),
122
+ newStyle: serializeComponentStyle(this.newStyle),
123
+ };
124
+ }
125
+
126
+ static {
127
+ SerializableCommand.register(defaultRestyleComponentCommandId, (json: any, _editor: Editor) => {
128
+ const origStyle = deserializeComponentStyle(json.originalStyle);
129
+ const newStyle = deserializeComponentStyle(json.newStyle);
130
+ const id = json.id;
131
+ if (typeof json.id !== 'string') {
132
+ throw new Error(`json.id is of type ${(typeof json.id)}, not string.`);
133
+ }
134
+
135
+ return new DefaultRestyleComponentCommand(
136
+ origStyle,
137
+ newStyle,
138
+ id,
139
+ );
140
+ });
141
+ }
142
+ }
@@ -4,6 +4,9 @@ import { Vec2 } from '../math/Vec2';
4
4
  import Stroke from './Stroke';
5
5
  import createEditor from '../testing/createEditor';
6
6
  import Mat33 from '../math/Mat33';
7
+ import EditorImage from '../EditorImage';
8
+ import AbstractComponent from './AbstractComponent';
9
+ import { DummyRenderer, SerializableCommand } from '../lib';
7
10
 
8
11
  describe('Stroke', () => {
9
12
  it('empty stroke should have an empty bounding box', () => {
@@ -68,4 +71,69 @@ describe('Stroke', () => {
68
71
  path['cachedStringVersion'] = null;
69
72
  expect(deserialized.getPath().toString()).toBe('M0,0L10,10L0,0');
70
73
  });
74
+
75
+ it('strokes should load from just-serialized JSON data', () => {
76
+ const deserialized = Stroke.deserializeFromJSON(`[
77
+ {
78
+ "style": { "fill": "#f00" },
79
+ "path": "m0,0 l10,10z"
80
+ }
81
+ ]`);
82
+
83
+ const redeserialized = AbstractComponent.deserialize(deserialized.serialize()) as Stroke;
84
+ expect(redeserialized.getPath().toString()).toBe(deserialized.getPath().toString());
85
+ expect(redeserialized.getStyle().color).objEq(deserialized.getStyle().color!);
86
+ });
87
+
88
+ it('strokes should be restylable', () => {
89
+ const stroke = Stroke.deserializeFromJSON(`[
90
+ {
91
+ "style": { "fill": "#f00" },
92
+ "path": "m0,0 l10,10z"
93
+ },
94
+ {
95
+ "style": { "fill": "#f00" },
96
+ "path": "m10,10 l100,10z"
97
+ }
98
+ ]`);
99
+
100
+ expect(stroke.getStyle().color).objEq(Color4.fromHex('#f00'));
101
+
102
+ // Should restyle even if no editor
103
+ stroke.forceStyle({
104
+ color: Color4.fromHex('#0f0')
105
+ }, null);
106
+
107
+ expect(stroke.getStyle().color).objEq(Color4.fromHex('#0f0'));
108
+
109
+ const editor = createEditor();
110
+ EditorImage.addElement(stroke).apply(editor);
111
+
112
+ // Re-rendering should render with the new color
113
+ const renderer = new DummyRenderer(editor.viewport);
114
+ stroke.render(renderer);
115
+ expect(renderer.lastFillStyle!.fill).toBe(stroke.getStyle().color);
116
+
117
+ // Calling updateStyle should have similar results.
118
+ const updateStyleCommand = stroke.updateStyle({
119
+ color: Color4.fromHex('#00f'),
120
+ });
121
+ expect(stroke.getStyle().color).objEq(Color4.fromHex('#0f0'));
122
+
123
+ updateStyleCommand.apply(editor);
124
+ expect(editor.isRerenderQueued()).toBe(true);
125
+
126
+ // Should do and undo correclty
127
+ expect(stroke.getStyle().color).objEq(Color4.fromHex('#00f'));
128
+ updateStyleCommand.unapply(editor);
129
+ expect(stroke.getStyle().color).objEq(Color4.fromHex('#0f0'));
130
+
131
+ // As should a deserialized updateStyle.
132
+ const deserializedUpdateStyle = SerializableCommand.deserialize(updateStyleCommand.serialize(), editor);
133
+ deserializedUpdateStyle.apply(editor);
134
+
135
+ expect(stroke.getStyle().color).objEq(Color4.fromHex('#00f'));
136
+ updateStyleCommand.unapply(editor);
137
+ expect(stroke.getStyle().color).objEq(Color4.fromHex('#0f0'));
138
+ });
71
139
  });
@@ -1,24 +1,31 @@
1
+ import SerializableCommand from '../commands/SerializableCommand';
1
2
  import LineSegment2 from '../math/LineSegment2';
2
3
  import Mat33 from '../math/Mat33';
3
4
  import Path from '../math/Path';
4
5
  import Rect2 from '../math/Rect2';
6
+ import Editor from '../Editor';
5
7
  import AbstractRenderer, { RenderablePathSpec } from '../rendering/renderers/AbstractRenderer';
6
8
  import RenderingStyle, { styleFromJSON, styleToJSON } from '../rendering/RenderingStyle';
7
9
  import AbstractComponent from './AbstractComponent';
8
10
  import { ImageComponentLocalization } from './localization';
11
+ import RestyleableComponent, { ComponentStyle, createRestyleComponentCommand } from './RestylableComponent';
9
12
 
10
13
  interface StrokePart extends RenderablePathSpec {
11
14
  path: Path;
12
15
  }
13
16
 
14
- export default class Stroke extends AbstractComponent {
17
+ export default class Stroke extends AbstractComponent implements RestyleableComponent {
15
18
  private parts: StrokePart[];
16
19
  protected contentBBox: Rect2;
20
+
21
+ // eslint-disable-next-line @typescript-eslint/prefer-as-const
22
+ readonly isRestylableComponent: true = true;
17
23
 
18
24
  // See `getProportionalRenderingTime`
19
25
  private approximateRenderingTime: number;
20
26
 
21
- // Creates a `Stroke` from the given `parts`.
27
+ // Creates a `Stroke` from the given `parts`. All parts should have the
28
+ // same color.
22
29
  public constructor(parts: RenderablePathSpec[]) {
23
30
  super('stroke');
24
31
 
@@ -49,6 +56,65 @@ export default class Stroke extends AbstractComponent {
49
56
  this.contentBBox ??= Rect2.empty;
50
57
  }
51
58
 
59
+ public getStyle(): ComponentStyle {
60
+ if (this.parts.length === 0) {
61
+ return { };
62
+ }
63
+ const firstPart = this.parts[0];
64
+
65
+ if (
66
+ firstPart.style.stroke === undefined
67
+ || firstPart.style.stroke.width === 0
68
+ ) {
69
+ return {
70
+ color: firstPart.style.fill,
71
+ };
72
+ }
73
+
74
+ return {
75
+ color: firstPart.style.stroke.color,
76
+ };
77
+ }
78
+
79
+ public updateStyle(style: ComponentStyle): SerializableCommand {
80
+ return createRestyleComponentCommand(this.getStyle(), style, this);
81
+ }
82
+
83
+ public forceStyle(style: ComponentStyle, editor: Editor|null): void {
84
+ if (!style.color) {
85
+ return;
86
+ }
87
+
88
+ this.parts = this.parts.map((part) => {
89
+ const newStyle = {
90
+ ...part.style,
91
+ stroke: part.style.stroke ? {
92
+ ...part.style.stroke,
93
+ } : undefined,
94
+ };
95
+
96
+ // Change the stroke color if a stroked shape. Else,
97
+ // change the fill.
98
+ if (newStyle.stroke && newStyle.stroke.width > 0) {
99
+ newStyle.stroke.color = style.color!;
100
+ } else {
101
+ newStyle.fill = style.color!;
102
+ }
103
+
104
+ return {
105
+ path: part.path,
106
+ startPoint: part.startPoint,
107
+ commands: part.commands,
108
+ style: newStyle,
109
+ };
110
+ });
111
+
112
+ if (editor) {
113
+ editor.image.queueRerenderOf(this);
114
+ editor.queueRerender();
115
+ }
116
+ }
117
+
52
118
  public intersects(line: LineSegment2): boolean {
53
119
  for (const part of this.parts) {
54
120
  if (part.path.intersection(line).length > 0) {
@@ -1,10 +1,13 @@
1
1
  import Color4 from '../Color4';
2
+ import EditorImage from '../EditorImage';
2
3
  import Mat33 from '../math/Mat33';
4
+ import TextStyle from '../rendering/TextRenderingStyle';
5
+ import createEditor from '../testing/createEditor';
3
6
  import AbstractComponent from './AbstractComponent';
4
- import TextComponent, { TextStyle } from './TextComponent';
7
+ import TextComponent from './TextComponent';
5
8
 
6
9
 
7
- describe('Text', () => {
10
+ describe('TextComponent', () => {
8
11
  it('should be serializable', () => {
9
12
  const style: TextStyle = {
10
13
  size: 12,
@@ -17,4 +20,55 @@ describe('Text', () => {
17
20
  expect(deserialized.getBBox()).objEq(text.getBBox());
18
21
  expect(deserialized['getText']()).toContain('Foo');
19
22
  });
23
+
24
+ it('should be deserializable', () => {
25
+ const textComponent = TextComponent.deserializeFromString(`{
26
+ "textObjects": [ { "text": "Foo" } ],
27
+ "transform": [ 1, 0, 0, 0, 1, 0, 0, 0, 1 ],
28
+ "style": {
29
+ "fontFamily": "sans",
30
+ "size": 10,
31
+ "renderingStyle": { "fill": "#000" }
32
+ }
33
+ }`);
34
+
35
+ expect(textComponent.getText()).toBe('Foo');
36
+ expect(textComponent.getTransform()).objEq(Mat33.identity);
37
+ expect(textComponent.getStyle().color!).objEq(Color4.black);
38
+ expect(textComponent.getTextStyle().fontFamily!).toBe('sans');
39
+ });
40
+
41
+ it('should be restylable', () => {
42
+ const style: TextStyle = {
43
+ size: 10,
44
+ fontFamily: 'sans',
45
+ renderingStyle: { fill: Color4.red },
46
+ };
47
+ const text = new TextComponent([ 'Foo' ], Mat33.identity, style);
48
+
49
+ expect(text.getStyle().color).objEq(Color4.red);
50
+ text.forceStyle({
51
+ color: Color4.green,
52
+ }, null);
53
+ expect(text.getStyle().color).objEq(Color4.green);
54
+ expect(text.getTextStyle().renderingStyle.fill).objEq(Color4.green);
55
+
56
+ const restyleCommand = text.updateStyle({
57
+ color: Color4.purple,
58
+ });
59
+
60
+ // Should queue a re-render after restyling.
61
+ const editor = createEditor();
62
+ EditorImage.addElement(text).apply(editor);
63
+
64
+ editor.rerender();
65
+ expect(editor.isRerenderQueued()).toBe(false);
66
+ editor.dispatch(restyleCommand);
67
+ expect(editor.isRerenderQueued()).toBe(true);
68
+
69
+ // Undoing should reset to the correct color.
70
+ expect(text.getStyle().color).objEq(Color4.purple);
71
+ editor.history.undo();
72
+ expect(text.getStyle().color).objEq(Color4.green);
73
+ });
20
74
  });