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.
- package/.github/ISSUE_TEMPLATE/translation.yml +8 -0
- package/CHANGELOG.md +8 -1
- package/dist/bundle.js +1 -1
- package/dist/src/Color4.d.ts +4 -0
- package/dist/src/Color4.js +22 -0
- package/dist/src/Editor.d.ts +2 -1
- package/dist/src/Editor.js +10 -1
- package/dist/src/EditorImage.d.ts +1 -0
- package/dist/src/EditorImage.js +11 -0
- package/dist/src/commands/UnresolvedCommand.d.ts +14 -0
- package/dist/src/commands/UnresolvedCommand.js +22 -0
- package/dist/src/commands/uniteCommands.js +4 -2
- package/dist/src/components/AbstractComponent.d.ts +0 -1
- package/dist/src/components/AbstractComponent.js +30 -50
- package/dist/src/components/RestylableComponent.d.ts +24 -0
- package/dist/src/components/RestylableComponent.js +80 -0
- package/dist/src/components/Stroke.d.ts +8 -1
- package/dist/src/components/Stroke.js +49 -1
- package/dist/src/components/TextComponent.d.ts +10 -10
- package/dist/src/components/TextComponent.js +46 -13
- package/dist/src/components/lib.d.ts +2 -1
- package/dist/src/components/lib.js +2 -1
- package/dist/src/components/localization.d.ts +1 -0
- package/dist/src/components/localization.js +1 -0
- package/dist/src/rendering/TextRenderingStyle.d.ts +23 -0
- package/dist/src/rendering/TextRenderingStyle.js +20 -0
- package/dist/src/rendering/renderers/AbstractRenderer.d.ts +1 -1
- package/dist/src/rendering/renderers/CanvasRenderer.d.ts +1 -1
- package/dist/src/rendering/renderers/DummyRenderer.d.ts +1 -1
- package/dist/src/rendering/renderers/SVGRenderer.d.ts +1 -1
- package/dist/src/rendering/renderers/TextOnlyRenderer.d.ts +1 -1
- package/dist/src/toolbar/IconProvider.d.ts +2 -1
- package/dist/src/toolbar/IconProvider.js +10 -0
- package/dist/src/toolbar/localization.d.ts +1 -0
- package/dist/src/toolbar/localization.js +1 -0
- package/dist/src/toolbar/widgets/BaseWidget.js +10 -4
- package/dist/src/toolbar/widgets/InsertImageWidget.js +2 -1
- package/dist/src/toolbar/widgets/SelectionToolWidget.js +77 -1
- package/dist/src/tools/Pen.js +2 -2
- package/dist/src/tools/SelectionTool/SelectAllShortcutHandler.d.ts +8 -0
- package/dist/src/tools/SelectionTool/SelectAllShortcutHandler.js +22 -0
- package/dist/src/tools/SelectionTool/Selection.js +1 -1
- package/dist/src/tools/SelectionTool/SelectionTool.js +4 -9
- package/dist/src/tools/TextTool.d.ts +1 -1
- package/dist/src/tools/ToolController.js +2 -0
- package/dist/src/tools/lib.d.ts +1 -0
- package/dist/src/tools/lib.js +1 -0
- package/dist/src/tools/localization.d.ts +1 -0
- package/dist/src/tools/localization.js +1 -0
- package/package.json +1 -1
- package/src/Color4.test.ts +4 -0
- package/src/Color4.ts +26 -0
- package/src/Editor.toSVG.test.ts +1 -1
- package/src/Editor.ts +12 -1
- package/src/EditorImage.ts +13 -0
- package/src/SVGLoader.ts +2 -1
- package/src/commands/UnresolvedCommand.ts +37 -0
- package/src/commands/uniteCommands.ts +5 -2
- package/src/components/AbstractComponent.ts +36 -61
- package/src/components/RestylableComponent.ts +142 -0
- package/src/components/Stroke.test.ts +68 -0
- package/src/components/Stroke.ts +68 -2
- package/src/components/TextComponent.test.ts +56 -2
- package/src/components/TextComponent.ts +63 -25
- package/src/components/lib.ts +4 -1
- package/src/components/localization.ts +3 -0
- package/src/math/Rect2.test.ts +18 -6
- package/src/rendering/TextRenderingStyle.ts +38 -0
- package/src/rendering/renderers/AbstractRenderer.ts +1 -1
- package/src/rendering/renderers/CanvasRenderer.ts +2 -1
- package/src/rendering/renderers/DummyRenderer.ts +1 -1
- package/src/rendering/renderers/SVGRenderer.ts +1 -1
- package/src/rendering/renderers/TextOnlyRenderer.ts +1 -1
- package/src/toolbar/IconProvider.ts +12 -1
- package/src/toolbar/localization.ts +2 -0
- package/src/toolbar/widgets/BaseWidget.ts +12 -4
- package/src/toolbar/widgets/InsertImageWidget.ts +2 -1
- package/src/toolbar/widgets/SelectionToolWidget.ts +95 -1
- package/src/tools/PanZoom.test.ts +2 -1
- package/src/tools/PasteHandler.ts +1 -1
- package/src/tools/Pen.ts +2 -2
- package/src/tools/SelectionTool/SelectAllShortcutHandler.ts +28 -0
- package/src/tools/SelectionTool/Selection.ts +1 -1
- package/src/tools/SelectionTool/SelectionTool.ts +4 -9
- package/src/tools/TextTool.ts +2 -1
- package/src/tools/ToolController.ts +2 -0
- package/src/tools/lib.ts +1 -0
- 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
|
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 =
|
39
|
+
const results = commands.map(cmd => cmd.unapply(editor));
|
37
40
|
return NonSerializableUnion.waitForAll(results);
|
38
41
|
} else {
|
39
|
-
return editor.asyncUnapplyCommands(
|
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
|
158
|
-
private
|
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
|
-
|
163
|
-
|
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
|
-
|
174
|
-
if (
|
175
|
-
|
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
|
-
|
206
|
-
|
207
|
-
|
180
|
+
protected resolveComponent(image: EditorImage): void {
|
181
|
+
if (this.component) {
|
182
|
+
return;
|
183
|
+
}
|
208
184
|
|
209
|
-
|
210
|
-
|
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.
|
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.
|
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.
|
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
|
});
|
package/src/components/Stroke.ts
CHANGED
@@ -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
|
7
|
+
import TextComponent from './TextComponent';
|
5
8
|
|
6
9
|
|
7
|
-
describe('
|
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
|
});
|