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.
- package/.github/ISSUE_TEMPLATE/translation.yml +8 -0
- package/CHANGELOG.md +15 -0
- package/README.md +1 -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 +14 -5
- package/dist/src/EditorImage.d.ts +1 -0
- package/dist/src/EditorImage.js +11 -0
- package/dist/src/SVGLoader.js +8 -2
- package/dist/src/Viewport.d.ts +1 -0
- package/dist/src/Viewport.js +6 -3
- 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/math/Path.js +10 -3
- 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 +30 -3
- package/dist/src/toolbar/IconProvider.js +37 -2
- 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.d.ts +6 -0
- package/dist/src/tools/SelectionTool/Selection.js +13 -4
- package/dist/src/tools/SelectionTool/SelectionTool.js +9 -12
- package/dist/src/tools/SelectionTool/TransformMode.js +1 -1
- 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 +16 -5
- package/src/EditorImage.ts +13 -0
- package/src/SVGLoader.ts +11 -3
- package/src/Viewport.ts +7 -3
- 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/Path.toString.test.ts +10 -0
- package/src/math/Path.ts +11 -3
- 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 +40 -7
- package/src/toolbar/localization.ts +2 -0
- package/src/toolbar/toolbar.css +3 -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 +17 -6
- package/src/tools/SelectionTool/SelectionTool.ts +9 -13
- package/src/tools/SelectionTool/TransformMode.ts +1 -1
- 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
@@ -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
|
});
|
@@ -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
|
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
|
156
|
-
return
|
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
|
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
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
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) {
|
package/src/components/lib.ts
CHANGED
@@ -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
|
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
|
-
|
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 (
|
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);
|
package/src/math/Rect2.test.ts
CHANGED
@@ -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
|
-
|
81
|
-
|
82
|
-
|
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
|
-
|
86
|
-
|
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
|
+
};
|