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