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
@@ -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
  });
@@ -1,24 +1,22 @@
1
+ import SerializableCommand from '../commands/SerializableCommand';
1
2
  import LineSegment2 from '../math/LineSegment2';
2
3
  import Mat33, { Mat33Array } from '../math/Mat33';
3
4
  import Rect2 from '../math/Rect2';
5
+ import Editor from '../Editor';
4
6
  import { Vec2 } from '../math/Vec2';
5
7
  import AbstractRenderer from '../rendering/renderers/AbstractRenderer';
6
- import RenderingStyle, { styleFromJSON, styleToJSON } from '../rendering/RenderingStyle';
8
+ import { TextStyle, textStyleFromJSON, textStyleToJSON } from '../rendering/TextRenderingStyle';
7
9
  import AbstractComponent from './AbstractComponent';
8
10
  import { ImageComponentLocalization } from './localization';
9
-
10
- export interface TextStyle {
11
- size: number;
12
- fontFamily: string;
13
- fontWeight?: string;
14
- fontVariant?: string;
15
- renderingStyle: RenderingStyle;
16
- }
11
+ import RestyleableComponent, { ComponentStyle, createRestyleComponentCommand } from './RestylableComponent';
17
12
 
18
13
  const componentTypeId = 'text';
19
- export default class TextComponent extends AbstractComponent {
14
+ export default class TextComponent extends AbstractComponent implements RestyleableComponent {
20
15
  protected contentBBox: Rect2;
21
16
 
17
+ // eslint-disable-next-line @typescript-eslint/prefer-as-const
18
+ readonly isRestylableComponent: true = true;
19
+
22
20
  public constructor(
23
21
  protected readonly textObjects: Array<string|TextComponent>,
24
22
  private transform: Mat33,
@@ -152,14 +150,57 @@ export default class TextComponent extends AbstractComponent {
152
150
  return false;
153
151
  }
154
152
 
155
- public getBaselinePos() {
156
- return this.transform.transformVec2(Vec2.zero);
153
+ public getStyle(): ComponentStyle {
154
+ return {
155
+ color: this.style.renderingStyle.fill,
156
+
157
+ // Make a copy
158
+ textStyle: {
159
+ ...this.style,
160
+ renderingStyle: {
161
+ ...this.style.renderingStyle,
162
+ },
163
+ },
164
+ };
165
+ }
166
+
167
+ public updateStyle(style: ComponentStyle): SerializableCommand {
168
+ return createRestyleComponentCommand(this.getStyle(), style, this);
169
+ }
170
+
171
+ public forceStyle(style: ComponentStyle, editor: Editor|null): void {
172
+ if (style.textStyle) {
173
+ this.style = style.textStyle;
174
+ } else if (style.color) {
175
+ this.style.renderingStyle = {
176
+ ...this.style.renderingStyle,
177
+ fill: style.color,
178
+ };
179
+ } else {
180
+ return;
181
+ }
182
+
183
+ for (const child of this.textObjects) {
184
+ if (child instanceof TextComponent) {
185
+ child.forceStyle(style, editor);
186
+ }
187
+ }
188
+
189
+ if (editor) {
190
+ editor.image.queueRerenderOf(this);
191
+ editor.queueRerender();
192
+ }
157
193
  }
158
194
 
195
+ // See this.getStyle
159
196
  public getTextStyle() {
160
197
  return this.style;
161
198
  }
162
199
 
200
+ public getBaselinePos() {
201
+ return this.transform.transformVec2(Vec2.zero);
202
+ }
203
+
163
204
  public getTransform(): Mat33 {
164
205
  return this.transform;
165
206
  }
@@ -191,13 +232,11 @@ export default class TextComponent extends AbstractComponent {
191
232
  return localizationTable.text(this.getText());
192
233
  }
193
234
 
235
+ // Do not rely on the output of `serializeToJSON` taking any particular format.
194
236
  protected serializeToJSON(): Record<string, any> {
195
- const serializableStyle = {
196
- ...this.style,
197
- renderingStyle: styleToJSON(this.style.renderingStyle),
198
- };
237
+ const serializableStyle = textStyleToJSON(this.style);
199
238
 
200
- const textObjects = this.textObjects.map(text => {
239
+ const serializedTextObjects = this.textObjects.map(text => {
201
240
  if (typeof text === 'string') {
202
241
  return {
203
242
  text,
@@ -210,20 +249,19 @@ export default class TextComponent extends AbstractComponent {
210
249
  });
211
250
 
212
251
  return {
213
- textObjects,
252
+ textObjects: serializedTextObjects,
214
253
  transform: this.transform.toArray(),
215
254
  style: serializableStyle,
216
255
  };
217
256
  }
218
257
 
258
+ // @internal
219
259
  public static deserializeFromString(json: any): TextComponent {
220
- const style: TextStyle = {
221
- renderingStyle: styleFromJSON(json.style.renderingStyle),
222
- size: json.style.size,
223
- fontWeight: json.style.fontWeight,
224
- fontVariant: json.style.fontVariant,
225
- fontFamily: json.style.fontFamily,
226
- };
260
+ if (typeof json === 'string') {
261
+ json = JSON.parse(json);
262
+ }
263
+
264
+ const style = textStyleFromJSON(json.style);
227
265
 
228
266
  const textObjects: Array<string|TextComponent> = json.textObjects.map((data: any) => {
229
267
  if ((data.text ?? null) !== null) {
@@ -8,12 +8,15 @@ export { default as AbstractComponent } from './AbstractComponent';
8
8
  import Stroke from './Stroke';
9
9
  import TextComponent from './TextComponent';
10
10
  import ImageComponent from './ImageComponent';
11
+ import RestyleableComponent, { createRestyleComponentCommand } from './RestylableComponent';
11
12
 
12
13
  export {
13
14
  Stroke,
14
15
  TextComponent as Text,
16
+ RestyleableComponent,
17
+ createRestyleComponentCommand,
15
18
 
16
- TextComponent as TextComponent,
19
+ TextComponent,
17
20
  Stroke as StrokeComponent,
18
21
  ImageComponent,
19
22
  };
@@ -4,12 +4,15 @@ export interface ImageComponentLocalization {
4
4
  imageNode: (description: string)=> string;
5
5
  stroke: string;
6
6
  svgObject: string;
7
+
8
+ restyledElements: string;
7
9
  }
8
10
 
9
11
  export const defaultComponentLocalization: ImageComponentLocalization = {
10
12
  unlabeledImageNode: 'Unlabeled image node',
11
13
  stroke: 'Stroke',
12
14
  svgObject: 'SVG Object',
15
+ restyledElements: 'Restyled elements',
13
16
  text: (text) => `Text object: ${text}`,
14
17
  imageNode: (description: string) => `Image: ${description}`,
15
18
  };
@@ -64,4 +64,14 @@ describe('Path.toString', () => {
64
64
 
65
65
  expect(path.toString(true)).toBe(path1.toString(true));
66
66
  });
67
+
68
+ it('should remove no-op move-tos', () => {
69
+ const path1 = Path.fromString('M50,75m0,0q0,12.5 0,50q0,6.3 25,0');
70
+ path1['cachedStringVersion'] = null;
71
+ const path2 = Path.fromString('M150,175M150,175q0,12.5 0,50q0,6.3 25,0');
72
+ path2['cachedStringVersion'] = null;
73
+
74
+ expect(path1.toString()).toBe('M50,75q0,12.5 0,50q0,6.3 25,0');
75
+ expect(path2.toString()).toBe('M150,175q0,12.5 0,50q0,6.3 25,0');
76
+ });
67
77
  });
package/src/math/Path.ts CHANGED
@@ -493,7 +493,7 @@ export default class Path {
493
493
  }
494
494
 
495
495
  // Don't add no-ops.
496
- if (commandString === 'l0,0') {
496
+ if (commandString === 'l0,0' || commandString === 'm0,0') {
497
497
  return;
498
498
  }
499
499
  result.push(commandString);
@@ -503,9 +503,17 @@ export default class Path {
503
503
  }
504
504
  };
505
505
 
506
- addCommand('M', startPoint);
506
+ // Don't add two moveTos in a row (this can happen if
507
+ // the start point corresponds to a moveTo _and_ the first command is
508
+ // also a moveTo)
509
+ if (parts[0]?.kind !== PathCommandType.MoveTo) {
510
+ addCommand('M', startPoint);
511
+ }
512
+
507
513
  let exhaustivenessCheck: never;
508
- for (const part of parts) {
514
+ for (let i = 0; i < parts.length; i++) {
515
+ const part = parts[i];
516
+
509
517
  switch (part.kind) {
510
518
  case PathCommandType.MoveTo:
511
519
  addCommand('M', part.point);
@@ -77,13 +77,23 @@ describe('Rect2', () => {
77
77
  expect(new Rect2(-2, -2, 4, 4).containsRect(new Rect2(-1, 0, 10, 1))).toBe(false);
78
78
  });
79
79
 
80
- it('a rectangle should contain itself', () => {
81
- const rect = new Rect2(1 / 3, 1 / 4, 1 / 5, 1 / 6);
82
- expect(rect.containsRect(rect)).toBe(true);
83
- });
80
+ describe('containsRect', () => {
81
+ it('a rectangle should contain itself', () => {
82
+ const rect = new Rect2(1 / 3, 1 / 4, 1 / 5, 1 / 6);
83
+ expect(rect.containsRect(rect)).toBe(true);
84
+ });
84
85
 
85
- it('empty rect should not contain a larger rect', () => {
86
- expect(Rect2.empty.containsRect(new Rect2(-1, -1, 3, 3))).toBe(false);
86
+ it('empty rect should not contain a larger rect', () => {
87
+ expect(Rect2.empty.containsRect(new Rect2(-1, -1, 3, 3))).toBe(false);
88
+ });
89
+
90
+ it('should correctly contain rectangles', () => {
91
+ const testRect = new Rect2(4, -10, 50, 100);
92
+ expect(testRect.containsRect(new Rect2(4.1, 0, 1, 1))).toBe(true);
93
+ expect(testRect.containsRect(new Rect2(48, 0, 1, 1))).toBe(true);
94
+ expect(testRect.containsRect(new Rect2(48, -9, 1, 1))).toBe(true);
95
+ expect(testRect.containsRect(new Rect2(48, -9, 1, 91))).toBe(true);
96
+ });
87
97
  });
88
98
 
89
99
  it('intersecting rectangles should be identified as intersecting', () => {
@@ -92,6 +102,8 @@ describe('Rect2', () => {
92
102
  expect(new Rect2(-1, -1, 2, 2).intersects(new Rect2(0, 0, 10, 10))).toBe(true);
93
103
  expect(new Rect2(-1, -1, 2, 2).intersects(new Rect2(3, 3, 10, 10))).toBe(false);
94
104
  expect(new Rect2(-1, -1, 2, 2).intersects(new Rect2(0.2, 0.1, 0, 0))).toBe(true);
105
+ expect(new Rect2(-100, -1, 200, 2).intersects(new Rect2(-5, -5, 10, 30))).toBe(true);
106
+ expect(new Rect2(-100, -1, 200, 2).intersects(new Rect2(-5, 50, 10, 30))).toBe(false);
95
107
  });
96
108
 
97
109
  it('intersecting rectangles should have their intersections correctly computed', () => {
@@ -0,0 +1,38 @@
1
+ import RenderingStyle, { styleFromJSON, styleToJSON } from './RenderingStyle';
2
+
3
+ export interface TextStyle {
4
+ size: number;
5
+ fontFamily: string;
6
+ fontWeight?: string;
7
+ fontVariant?: string;
8
+ renderingStyle: RenderingStyle;
9
+ }
10
+
11
+ export default TextStyle;
12
+
13
+ export const textStyleFromJSON = (json: any) => {
14
+ if (typeof json === 'string') {
15
+ json = JSON.parse(json);
16
+ }
17
+
18
+ if (typeof(json.fontFamily) !== 'string') {
19
+ throw new Error('Serialized textStyle missing string fontFamily attribute!');
20
+ }
21
+
22
+ const style: TextStyle = {
23
+ renderingStyle: styleFromJSON(json.renderingStyle),
24
+ size: json.size,
25
+ fontWeight: json.fontWeight,
26
+ fontVariant: json.fontVariant,
27
+ fontFamily: json.fontFamily,
28
+ };
29
+
30
+ return style;
31
+ };
32
+
33
+ export const textStyleToJSON = (style: TextStyle) => {
34
+ return {
35
+ ...style,
36
+ renderingStyle: styleToJSON(style.renderingStyle),
37
+ };
38
+ };