js-draw 0.0.1
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 +57 -0
- package/.husky/pre-commit +4 -0
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/__mocks__/coloris.ts +8 -0
- package/__mocks__/styleMock.js +1 -0
- package/dist/__mocks__/coloris.d.ts +2 -0
- package/dist/__mocks__/coloris.js +5 -0
- package/dist/build_tools/BundledFile.d.ts +12 -0
- package/dist/build_tools/BundledFile.js +153 -0
- package/dist/scripts/bundle.d.ts +1 -0
- package/dist/scripts/bundle.js +19 -0
- package/dist/scripts/watchBundle.d.ts +1 -0
- package/dist/scripts/watchBundle.js +9 -0
- package/dist/src/Color4.d.ts +23 -0
- package/dist/src/Color4.js +102 -0
- package/dist/src/Display.d.ts +22 -0
- package/dist/src/Display.js +93 -0
- package/dist/src/Editor.d.ts +55 -0
- package/dist/src/Editor.js +366 -0
- package/dist/src/EditorImage.d.ts +44 -0
- package/dist/src/EditorImage.js +243 -0
- package/dist/src/EventDispatcher.d.ts +11 -0
- package/dist/src/EventDispatcher.js +39 -0
- package/dist/src/Pointer.d.ts +22 -0
- package/dist/src/Pointer.js +57 -0
- package/dist/src/SVGLoader.d.ts +21 -0
- package/dist/src/SVGLoader.js +204 -0
- package/dist/src/StrokeBuilder.d.ts +35 -0
- package/dist/src/StrokeBuilder.js +275 -0
- package/dist/src/UndoRedoHistory.d.ts +17 -0
- package/dist/src/UndoRedoHistory.js +46 -0
- package/dist/src/Viewport.d.ts +39 -0
- package/dist/src/Viewport.js +134 -0
- package/dist/src/commands/Command.d.ts +15 -0
- package/dist/src/commands/Command.js +29 -0
- package/dist/src/commands/Erase.d.ts +11 -0
- package/dist/src/commands/Erase.js +37 -0
- package/dist/src/commands/localization.d.ts +19 -0
- package/dist/src/commands/localization.js +17 -0
- package/dist/src/components/AbstractComponent.d.ts +19 -0
- package/dist/src/components/AbstractComponent.js +46 -0
- package/dist/src/components/Stroke.d.ts +16 -0
- package/dist/src/components/Stroke.js +79 -0
- package/dist/src/components/UnknownSVGObject.d.ts +15 -0
- package/dist/src/components/UnknownSVGObject.js +25 -0
- package/dist/src/components/localization.d.ts +5 -0
- package/dist/src/components/localization.js +4 -0
- package/dist/src/geometry/LineSegment2.d.ts +19 -0
- package/dist/src/geometry/LineSegment2.js +100 -0
- package/dist/src/geometry/Mat33.d.ts +31 -0
- package/dist/src/geometry/Mat33.js +187 -0
- package/dist/src/geometry/Path.d.ts +55 -0
- package/dist/src/geometry/Path.js +364 -0
- package/dist/src/geometry/Rect2.d.ts +47 -0
- package/dist/src/geometry/Rect2.js +148 -0
- package/dist/src/geometry/Vec2.d.ts +13 -0
- package/dist/src/geometry/Vec2.js +13 -0
- package/dist/src/geometry/Vec3.d.ts +32 -0
- package/dist/src/geometry/Vec3.js +98 -0
- package/dist/src/localization.d.ts +12 -0
- package/dist/src/localization.js +5 -0
- package/dist/src/main.d.ts +3 -0
- package/dist/src/main.js +4 -0
- package/dist/src/rendering/AbstractRenderer.d.ts +38 -0
- package/dist/src/rendering/AbstractRenderer.js +108 -0
- package/dist/src/rendering/CanvasRenderer.d.ts +23 -0
- package/dist/src/rendering/CanvasRenderer.js +108 -0
- package/dist/src/rendering/DummyRenderer.d.ts +25 -0
- package/dist/src/rendering/DummyRenderer.js +65 -0
- package/dist/src/rendering/SVGRenderer.d.ts +27 -0
- package/dist/src/rendering/SVGRenderer.js +122 -0
- package/dist/src/testing/loadExpectExtensions.d.ts +17 -0
- package/dist/src/testing/loadExpectExtensions.js +27 -0
- package/dist/src/toolbar/HTMLToolbar.d.ts +12 -0
- package/dist/src/toolbar/HTMLToolbar.js +444 -0
- package/dist/src/toolbar/types.d.ts +17 -0
- package/dist/src/toolbar/types.js +5 -0
- package/dist/src/tools/BaseTool.d.ts +20 -0
- package/dist/src/tools/BaseTool.js +44 -0
- package/dist/src/tools/Eraser.d.ts +16 -0
- package/dist/src/tools/Eraser.js +53 -0
- package/dist/src/tools/PanZoom.d.ts +40 -0
- package/dist/src/tools/PanZoom.js +191 -0
- package/dist/src/tools/Pen.d.ts +25 -0
- package/dist/src/tools/Pen.js +97 -0
- package/dist/src/tools/SelectionTool.d.ts +49 -0
- package/dist/src/tools/SelectionTool.js +437 -0
- package/dist/src/tools/ToolController.d.ts +18 -0
- package/dist/src/tools/ToolController.js +110 -0
- package/dist/src/tools/ToolEnabledGroup.d.ts +6 -0
- package/dist/src/tools/ToolEnabledGroup.js +11 -0
- package/dist/src/tools/localization.d.ts +10 -0
- package/dist/src/tools/localization.js +9 -0
- package/dist/src/types.d.ts +88 -0
- package/dist/src/types.js +20 -0
- package/jest.config.js +22 -0
- package/lint-staged.config.js +6 -0
- package/package.json +82 -0
- package/src/Color4.test.ts +12 -0
- package/src/Color4.ts +122 -0
- package/src/Display.ts +118 -0
- package/src/Editor.css +58 -0
- package/src/Editor.ts +469 -0
- package/src/EditorImage.test.ts +90 -0
- package/src/EditorImage.ts +297 -0
- package/src/EventDispatcher.test.ts +123 -0
- package/src/EventDispatcher.ts +53 -0
- package/src/Pointer.ts +93 -0
- package/src/SVGLoader.ts +230 -0
- package/src/StrokeBuilder.ts +362 -0
- package/src/UndoRedoHistory.ts +61 -0
- package/src/Viewport.ts +168 -0
- package/src/commands/Command.ts +43 -0
- package/src/commands/Erase.ts +52 -0
- package/src/commands/localization.ts +38 -0
- package/src/components/AbstractComponent.ts +73 -0
- package/src/components/Stroke.test.ts +18 -0
- package/src/components/Stroke.ts +102 -0
- package/src/components/UnknownSVGObject.ts +36 -0
- package/src/components/localization.ts +9 -0
- package/src/editorStyles.js +3 -0
- package/src/geometry/LineSegment2.test.ts +77 -0
- package/src/geometry/LineSegment2.ts +127 -0
- package/src/geometry/Mat33.test.ts +144 -0
- package/src/geometry/Mat33.ts +268 -0
- package/src/geometry/Path.fromString.test.ts +146 -0
- package/src/geometry/Path.test.ts +96 -0
- package/src/geometry/Path.toString.test.ts +31 -0
- package/src/geometry/Path.ts +456 -0
- package/src/geometry/Rect2.test.ts +121 -0
- package/src/geometry/Rect2.ts +215 -0
- package/src/geometry/Vec2.test.ts +32 -0
- package/src/geometry/Vec2.ts +18 -0
- package/src/geometry/Vec3.test.ts +29 -0
- package/src/geometry/Vec3.ts +133 -0
- package/src/localization.ts +27 -0
- package/src/rendering/AbstractRenderer.ts +164 -0
- package/src/rendering/CanvasRenderer.ts +141 -0
- package/src/rendering/DummyRenderer.ts +80 -0
- package/src/rendering/SVGRenderer.ts +159 -0
- package/src/testing/loadExpectExtensions.ts +43 -0
- package/src/toolbar/HTMLToolbar.ts +551 -0
- package/src/toolbar/toolbar.css +110 -0
- package/src/toolbar/types.ts +20 -0
- package/src/tools/BaseTool.ts +58 -0
- package/src/tools/Eraser.ts +67 -0
- package/src/tools/PanZoom.ts +253 -0
- package/src/tools/Pen.ts +121 -0
- package/src/tools/SelectionTool.test.ts +85 -0
- package/src/tools/SelectionTool.ts +545 -0
- package/src/tools/ToolController.ts +126 -0
- package/src/tools/ToolEnabledGroup.ts +14 -0
- package/src/tools/localization.ts +22 -0
- package/src/types.ts +133 -0
- package/tsconfig.json +28 -0
package/src/Viewport.ts
ADDED
@@ -0,0 +1,168 @@
|
|
1
|
+
import Command from './commands/Command';
|
2
|
+
import { CommandLocalization } from './commands/localization';
|
3
|
+
import Editor from './Editor';
|
4
|
+
import Mat33 from './geometry/Mat33';
|
5
|
+
import Rect2 from './geometry/Rect2';
|
6
|
+
import { Point2, Vec2 } from './geometry/Vec2';
|
7
|
+
import Vec3 from './geometry/Vec3';
|
8
|
+
import { StrokeDataPoint } from './StrokeBuilder';
|
9
|
+
import { EditorEventType, EditorNotifier } from './types';
|
10
|
+
|
11
|
+
// Returns the base type of some type of point/number
|
12
|
+
type PointDataType<T extends Point2|StrokeDataPoint|number> = T extends Point2 ? Point2 : number;
|
13
|
+
|
14
|
+
export class Viewport {
|
15
|
+
// Command that translates/scales the viewport.
|
16
|
+
public static ViewportTransform = class implements Command {
|
17
|
+
readonly #inverseTransform: Mat33;
|
18
|
+
|
19
|
+
public constructor(public readonly transform: Mat33) {
|
20
|
+
this.#inverseTransform = transform.inverse();
|
21
|
+
}
|
22
|
+
|
23
|
+
public apply(editor: Editor) {
|
24
|
+
const viewport = editor.viewport;
|
25
|
+
viewport.resetTransform(viewport.transform.rightMul(this.transform));
|
26
|
+
editor.queueRerender();
|
27
|
+
}
|
28
|
+
|
29
|
+
public unapply(editor: Editor) {
|
30
|
+
const viewport = editor.viewport;
|
31
|
+
viewport.resetTransform(viewport.transform.rightMul(this.#inverseTransform));
|
32
|
+
editor.queueRerender();
|
33
|
+
}
|
34
|
+
|
35
|
+
public description(localizationTable: CommandLocalization): string {
|
36
|
+
const result: string[] = [];
|
37
|
+
|
38
|
+
// Describe the transformation's affect on the viewport (note that transformation transforms
|
39
|
+
// the **elements** within the viewport). Assumes the transformation only does rotation/scale/translation.
|
40
|
+
const origVec = Vec2.unitX;
|
41
|
+
const linearTransformedVec = this.transform.transformVec3(Vec2.unitX);
|
42
|
+
const affineTransformedVec = this.transform.transformVec2(Vec2.unitX);
|
43
|
+
|
44
|
+
const scale = linearTransformedVec.magnitude();
|
45
|
+
const rotation = 180 / Math.PI * linearTransformedVec.angle();
|
46
|
+
const translation = affineTransformedVec.minus(origVec);
|
47
|
+
|
48
|
+
if (scale > 1.2) {
|
49
|
+
result.push(localizationTable.zoomedIn);
|
50
|
+
}
|
51
|
+
else if (scale < 0.8) {
|
52
|
+
result.push(localizationTable.zoomedOut);
|
53
|
+
}
|
54
|
+
|
55
|
+
if (Math.floor(Math.abs(rotation)) > 0) {
|
56
|
+
result.push(localizationTable.rotatedBy(Math.round(rotation)));
|
57
|
+
}
|
58
|
+
|
59
|
+
const minTranslation = 1e-4;
|
60
|
+
if (translation.x > minTranslation) {
|
61
|
+
result.push(localizationTable.movedLeft);
|
62
|
+
} else if (translation.x < -minTranslation) {
|
63
|
+
result.push(localizationTable.movedRight);
|
64
|
+
}
|
65
|
+
|
66
|
+
if (translation.y < minTranslation) {
|
67
|
+
result.push(localizationTable.movedDown);
|
68
|
+
} else if (translation.y > minTranslation) {
|
69
|
+
result.push(localizationTable.movedUp);
|
70
|
+
}
|
71
|
+
|
72
|
+
return result.join('; ');
|
73
|
+
}
|
74
|
+
};
|
75
|
+
|
76
|
+
private transform: Mat33;
|
77
|
+
private inverseTransform: Mat33;
|
78
|
+
private screenRect: Rect2;
|
79
|
+
|
80
|
+
public constructor(private notifier: EditorNotifier) {
|
81
|
+
this.resetTransform(Mat33.identity);
|
82
|
+
this.screenRect = Rect2.empty;
|
83
|
+
}
|
84
|
+
|
85
|
+
public updateScreenSize(screenSize: Vec2) {
|
86
|
+
this.screenRect = this.screenRect.resizedTo(screenSize);
|
87
|
+
}
|
88
|
+
|
89
|
+
public get visibleRect(): Rect2 {
|
90
|
+
return this.screenRect.transformedBoundingBox(this.inverseTransform);
|
91
|
+
}
|
92
|
+
|
93
|
+
// the given point, but in canvas coordinates
|
94
|
+
public screenToCanvas(screenPoint: Point2): Point2 {
|
95
|
+
return this.inverseTransform.transformVec2(screenPoint);
|
96
|
+
}
|
97
|
+
|
98
|
+
public canvasToScreen(canvasPoint: Point2): Point2 {
|
99
|
+
return this.transform.transformVec2(canvasPoint);
|
100
|
+
}
|
101
|
+
|
102
|
+
// Updates the transformation directly. Using ViewportTransform is preferred.
|
103
|
+
// [newTransform] should map from canvas coordinates to screen coordinates.
|
104
|
+
public resetTransform(newTransform: Mat33) {
|
105
|
+
this.transform = newTransform;
|
106
|
+
this.inverseTransform = newTransform.inverse();
|
107
|
+
this.notifier.dispatch(EditorEventType.ViewportChanged, {
|
108
|
+
kind: EditorEventType.ViewportChanged,
|
109
|
+
newTransform,
|
110
|
+
});
|
111
|
+
}
|
112
|
+
|
113
|
+
public get screenToCanvasTransform(): Mat33 {
|
114
|
+
return this.inverseTransform;
|
115
|
+
}
|
116
|
+
|
117
|
+
public get canvasToScreenTransform(): Mat33 {
|
118
|
+
return this.transform;
|
119
|
+
}
|
120
|
+
|
121
|
+
// Returns the amount a vector on the canvas is scaled to become a vector on the screen.
|
122
|
+
public getScaleFactor(): number {
|
123
|
+
// Use transformVec3 to avoid translating the vector
|
124
|
+
return this.transform.transformVec3(Vec3.unitX).magnitude();
|
125
|
+
}
|
126
|
+
|
127
|
+
// Returns the angle of the canvas in radians
|
128
|
+
public getRotationAngle(): number {
|
129
|
+
return this.transform.transformVec3(Vec3.unitX).angle();
|
130
|
+
}
|
131
|
+
|
132
|
+
// Rounds the given [point] to a multiple of 10 such that it is within [tolerance] of
|
133
|
+
// its original location. This is useful for preparing data for base-10 conversion.
|
134
|
+
public static roundPoint<T extends Point2|number>(
|
135
|
+
point: T, tolerance: number,
|
136
|
+
): PointDataType<T>;
|
137
|
+
|
138
|
+
// The separate function type definition seems necessary here.
|
139
|
+
// See https://stackoverflow.com/a/58163623/17055750.
|
140
|
+
// eslint-disable-next-line no-dupe-class-members
|
141
|
+
public static roundPoint(
|
142
|
+
point: Point2|number, tolerance: number
|
143
|
+
): Point2|number {
|
144
|
+
const scaleFactor = 10 ** Math.floor(Math.log10(tolerance));
|
145
|
+
const roundComponent = (component: number): number => {
|
146
|
+
return Math.round(component / scaleFactor) * scaleFactor;
|
147
|
+
};
|
148
|
+
|
149
|
+
if (typeof point === 'number') {
|
150
|
+
return roundComponent(point);
|
151
|
+
}
|
152
|
+
|
153
|
+
return point.map(roundComponent);
|
154
|
+
}
|
155
|
+
|
156
|
+
|
157
|
+
// Round a point with a tolerance of ±1 screen unit.
|
158
|
+
public roundPoint(point: Point2): Point2 {
|
159
|
+
return Viewport.roundPoint(point, 1 / this.getScaleFactor());
|
160
|
+
}
|
161
|
+
}
|
162
|
+
|
163
|
+
export namespace Viewport { // eslint-disable-line
|
164
|
+
// Needed to allow accessing as a type. See https://stackoverflow.com/a/68201883
|
165
|
+
export type ViewportTransform = typeof Viewport.ViewportTransform.prototype;
|
166
|
+
}
|
167
|
+
|
168
|
+
export default Viewport;
|
@@ -0,0 +1,43 @@
|
|
1
|
+
import Editor from '../Editor';
|
2
|
+
import { EditorLocalization } from '../localization';
|
3
|
+
|
4
|
+
interface Command {
|
5
|
+
apply(editor: Editor): void;
|
6
|
+
unapply(editor: Editor): void;
|
7
|
+
|
8
|
+
description(localizationTable: EditorLocalization): string;
|
9
|
+
}
|
10
|
+
|
11
|
+
// eslint-disable-next-line no-redeclare
|
12
|
+
namespace Command {
|
13
|
+
export const empty = {
|
14
|
+
apply(_editor: Editor) { },
|
15
|
+
unapply(_editor: Editor) { },
|
16
|
+
};
|
17
|
+
|
18
|
+
export const union = (a: Command, b: Command): Command => {
|
19
|
+
return {
|
20
|
+
apply(editor: Editor) {
|
21
|
+
a.apply(editor);
|
22
|
+
b.apply(editor);
|
23
|
+
},
|
24
|
+
unapply(editor: Editor) {
|
25
|
+
b.unapply(editor);
|
26
|
+
a.unapply(editor);
|
27
|
+
},
|
28
|
+
|
29
|
+
description(localizationTable: EditorLocalization) {
|
30
|
+
const aDescription = a.description(localizationTable);
|
31
|
+
const bDescription = b.description(localizationTable);
|
32
|
+
|
33
|
+
if (aDescription === bDescription) {
|
34
|
+
return aDescription;
|
35
|
+
}
|
36
|
+
|
37
|
+
return `${aDescription}, ${bDescription}`;
|
38
|
+
},
|
39
|
+
};
|
40
|
+
};
|
41
|
+
}
|
42
|
+
|
43
|
+
export default Command;
|
@@ -0,0 +1,52 @@
|
|
1
|
+
import AbstractComponent from '../components/AbstractComponent';
|
2
|
+
import Editor from '../Editor';
|
3
|
+
import EditorImage from '../EditorImage';
|
4
|
+
import { EditorLocalization } from '../localization';
|
5
|
+
import Command from './Command';
|
6
|
+
|
7
|
+
export default class Erase implements Command {
|
8
|
+
private toRemove: AbstractComponent[];
|
9
|
+
|
10
|
+
public constructor(toRemove: AbstractComponent[]) {
|
11
|
+
// Clone the list
|
12
|
+
this.toRemove = toRemove.map(elem => elem);
|
13
|
+
}
|
14
|
+
|
15
|
+
public apply(editor: Editor): void {
|
16
|
+
for (const part of this.toRemove) {
|
17
|
+
const parent = editor.image.findParent(part);
|
18
|
+
|
19
|
+
if (parent) {
|
20
|
+
parent.remove();
|
21
|
+
}
|
22
|
+
}
|
23
|
+
|
24
|
+
editor.queueRerender();
|
25
|
+
}
|
26
|
+
|
27
|
+
public unapply(editor: Editor): void {
|
28
|
+
for (const part of this.toRemove) {
|
29
|
+
if (!editor.image.findParent(part)) {
|
30
|
+
new EditorImage.AddElementCommand(part).apply(editor);
|
31
|
+
}
|
32
|
+
}
|
33
|
+
|
34
|
+
editor.queueRerender();
|
35
|
+
}
|
36
|
+
|
37
|
+
public description(localizationTable: EditorLocalization): string {
|
38
|
+
if (this.toRemove.length === 0) {
|
39
|
+
return localizationTable.erasedNoElements;
|
40
|
+
}
|
41
|
+
|
42
|
+
let description = this.toRemove[0].description(localizationTable);
|
43
|
+
for (const elem of this.toRemove) {
|
44
|
+
if (elem.description(localizationTable) !== description) {
|
45
|
+
description = localizationTable.elements;
|
46
|
+
break;
|
47
|
+
}
|
48
|
+
}
|
49
|
+
|
50
|
+
return localizationTable.eraseAction(description, this.toRemove.length);
|
51
|
+
}
|
52
|
+
}
|
@@ -0,0 +1,38 @@
|
|
1
|
+
import Rect2 from '../geometry/Rect2';
|
2
|
+
|
3
|
+
export interface CommandLocalization {
|
4
|
+
movedLeft: string;
|
5
|
+
movedUp: string;
|
6
|
+
movedDown: string;
|
7
|
+
movedRight: string;
|
8
|
+
rotatedBy: (degrees: number) => string;
|
9
|
+
zoomedOut: string;
|
10
|
+
zoomedIn: string;
|
11
|
+
erasedNoElements: string;
|
12
|
+
elements: string;
|
13
|
+
updatedViewport: string;
|
14
|
+
transformedElements: (elemCount: number) => string;
|
15
|
+
resizeOutputCommand: (newSize: Rect2) => string;
|
16
|
+
addElementAction: (elemDescription: string) => string;
|
17
|
+
eraseAction: (elemDescription: string, numElems: number) => string;
|
18
|
+
|
19
|
+
selectedElements: (count: number)=>string;
|
20
|
+
}
|
21
|
+
|
22
|
+
export const defaultCommandLocalization: CommandLocalization = {
|
23
|
+
updatedViewport: 'Transformed Viewport',
|
24
|
+
transformedElements: (elemCount) => `Transformed ${elemCount} element${elemCount === 1 ? '' : 's'}`,
|
25
|
+
resizeOutputCommand: (newSize: Rect2) => `Resized image to ${newSize.w}x${newSize.h}`,
|
26
|
+
addElementAction: (componentDescription: string) => `Added ${componentDescription}`,
|
27
|
+
eraseAction: (componentDescription: string, numElems: number) => `Erased ${numElems} ${componentDescription}`,
|
28
|
+
elements: 'Elements',
|
29
|
+
erasedNoElements: 'Erased nothing',
|
30
|
+
rotatedBy: (degrees) => `Rotated by ${Math.abs(degrees)} degrees ${degrees < 0 ? 'clockwise' : 'counter-clockwise'}`,
|
31
|
+
movedLeft: 'Moved left',
|
32
|
+
movedUp: 'Moved up',
|
33
|
+
movedDown: 'Moved down',
|
34
|
+
movedRight: 'Moved right',
|
35
|
+
zoomedOut: 'Zoomed out',
|
36
|
+
zoomedIn: 'Zoomed in',
|
37
|
+
selectedElements: (count) => `Selected ${count} element${count === 1 ? '' : 's'}`,
|
38
|
+
};
|
@@ -0,0 +1,73 @@
|
|
1
|
+
import Command from '../commands/Command';
|
2
|
+
import Editor from '../Editor';
|
3
|
+
import EditorImage from '../EditorImage';
|
4
|
+
import LineSegment2 from '../geometry/LineSegment2';
|
5
|
+
import Mat33 from '../geometry/Mat33';
|
6
|
+
import Rect2 from '../geometry/Rect2';
|
7
|
+
import AbstractRenderer from '../rendering/AbstractRenderer';
|
8
|
+
import { ImageComponentLocalization } from './localization';
|
9
|
+
|
10
|
+
export default abstract class AbstractComponent {
|
11
|
+
protected lastChangedTime: number;
|
12
|
+
protected abstract contentBBox: Rect2;
|
13
|
+
public zIndex: number;
|
14
|
+
|
15
|
+
// Topmost z-index
|
16
|
+
private static zIndexCounter: number = 0;
|
17
|
+
|
18
|
+
protected constructor() {
|
19
|
+
this.lastChangedTime = (new Date()).getTime();
|
20
|
+
this.zIndex = AbstractComponent.zIndexCounter++;
|
21
|
+
}
|
22
|
+
|
23
|
+
public getBBox(): Rect2 {
|
24
|
+
return this.contentBBox;
|
25
|
+
}
|
26
|
+
public abstract render(canvas: AbstractRenderer, visibleRect: Rect2): void;
|
27
|
+
public abstract intersects(lineSegment: LineSegment2): boolean;
|
28
|
+
|
29
|
+
// Private helper for transformBy: Apply the given transformation to all points of this.
|
30
|
+
protected abstract applyTransformation(affineTransfm: Mat33): void;
|
31
|
+
|
32
|
+
// Returns a command that, when applied, transforms this by [affineTransfm] and
|
33
|
+
// updates the editor.
|
34
|
+
public transformBy(affineTransfm: Mat33): Command {
|
35
|
+
const updateTransform = (editor: Editor, newTransfm: Mat33) => {
|
36
|
+
// Any parent should have only one direct child.
|
37
|
+
const parent = editor.image.findParent(this);
|
38
|
+
let hadParent = false;
|
39
|
+
if (parent) {
|
40
|
+
parent.remove();
|
41
|
+
hadParent = true;
|
42
|
+
}
|
43
|
+
|
44
|
+
this.applyTransformation(newTransfm);
|
45
|
+
|
46
|
+
// Add the element back to the document.
|
47
|
+
if (hadParent) {
|
48
|
+
new EditorImage.AddElementCommand(this).apply(editor);
|
49
|
+
}
|
50
|
+
};
|
51
|
+
const origZIndex = this.zIndex;
|
52
|
+
|
53
|
+
return {
|
54
|
+
apply: (editor: Editor) => {
|
55
|
+
this.zIndex = AbstractComponent.zIndexCounter++;
|
56
|
+
updateTransform(editor, affineTransfm);
|
57
|
+
editor.queueRerender();
|
58
|
+
},
|
59
|
+
unapply: (editor: Editor): void => {
|
60
|
+
this.zIndex = origZIndex;
|
61
|
+
updateTransform(
|
62
|
+
editor, affineTransfm.inverse()
|
63
|
+
);
|
64
|
+
editor.queueRerender();
|
65
|
+
},
|
66
|
+
description(localizationTable) {
|
67
|
+
return localizationTable.transformedElements(1);
|
68
|
+
},
|
69
|
+
};
|
70
|
+
}
|
71
|
+
|
72
|
+
public abstract description(localizationTable: ImageComponentLocalization): string;
|
73
|
+
}
|
@@ -0,0 +1,18 @@
|
|
1
|
+
import Color4 from '../Color4';
|
2
|
+
import { Vec2 } from '../geometry/Vec2';
|
3
|
+
import Stroke from './Stroke';
|
4
|
+
|
5
|
+
describe('Stroke', () => {
|
6
|
+
it('empty stroke should have an empty bounding box', () => {
|
7
|
+
const stroke = new Stroke([{
|
8
|
+
startPoint: Vec2.zero,
|
9
|
+
commands: [],
|
10
|
+
style: {
|
11
|
+
fill: Color4.blue,
|
12
|
+
},
|
13
|
+
}]);
|
14
|
+
expect(stroke.getBBox()).toMatchObject({
|
15
|
+
x: 0, y: 0, w: 0, h: 0,
|
16
|
+
});
|
17
|
+
});
|
18
|
+
});
|
@@ -0,0 +1,102 @@
|
|
1
|
+
import LineSegment2 from '../geometry/LineSegment2';
|
2
|
+
import Mat33 from '../geometry/Mat33';
|
3
|
+
import Path from '../geometry/Path';
|
4
|
+
import Rect2 from '../geometry/Rect2';
|
5
|
+
import AbstractRenderer, { RenderablePathSpec, RenderingStyle } from '../rendering/AbstractRenderer';
|
6
|
+
import AbstractComponent from './AbstractComponent';
|
7
|
+
import { ImageComponentLocalization } from './localization';
|
8
|
+
|
9
|
+
interface StrokePart extends RenderablePathSpec {
|
10
|
+
path: Path;
|
11
|
+
bbox: Rect2;
|
12
|
+
}
|
13
|
+
|
14
|
+
export default class Stroke extends AbstractComponent {
|
15
|
+
private parts: StrokePart[];
|
16
|
+
protected contentBBox: Rect2;
|
17
|
+
|
18
|
+
public constructor(parts: RenderablePathSpec[]) {
|
19
|
+
super();
|
20
|
+
|
21
|
+
this.parts = parts.map(section => {
|
22
|
+
const path = Path.fromRenderable(section);
|
23
|
+
const pathBBox = this.bboxForPart(path.bbox, section.style);
|
24
|
+
|
25
|
+
if (!this.contentBBox) {
|
26
|
+
this.contentBBox = pathBBox;
|
27
|
+
} else {
|
28
|
+
this.contentBBox = this.contentBBox.union(pathBBox);
|
29
|
+
}
|
30
|
+
|
31
|
+
return {
|
32
|
+
path,
|
33
|
+
bbox: pathBBox,
|
34
|
+
|
35
|
+
// To implement RenderablePathSpec
|
36
|
+
startPoint: path.startPoint,
|
37
|
+
style: section.style,
|
38
|
+
commands: path.parts,
|
39
|
+
};
|
40
|
+
});
|
41
|
+
this.contentBBox ??= Rect2.empty;
|
42
|
+
}
|
43
|
+
|
44
|
+
public intersects(line: LineSegment2): boolean {
|
45
|
+
for (const part of this.parts) {
|
46
|
+
if (part.path.intersection(line).length > 0) {
|
47
|
+
return true;
|
48
|
+
}
|
49
|
+
}
|
50
|
+
return false;
|
51
|
+
}
|
52
|
+
|
53
|
+
public render(canvas: AbstractRenderer, visibleRect: Rect2): void {
|
54
|
+
canvas.startObject(this.getBBox());
|
55
|
+
for (const part of this.parts) {
|
56
|
+
const bbox = part.bbox;
|
57
|
+
if (bbox.intersects(visibleRect)) {
|
58
|
+
canvas.drawPath(part);
|
59
|
+
}
|
60
|
+
}
|
61
|
+
canvas.endObject();
|
62
|
+
}
|
63
|
+
|
64
|
+
// Grows the bounding box for a given stroke part based on that part's style.
|
65
|
+
private bboxForPart(origBBox: Rect2, style: RenderingStyle) {
|
66
|
+
if (!style.stroke) {
|
67
|
+
return origBBox;
|
68
|
+
}
|
69
|
+
|
70
|
+
return origBBox.grownBy(style.stroke.width / 2);
|
71
|
+
}
|
72
|
+
|
73
|
+
protected applyTransformation(affineTransfm: Mat33): void {
|
74
|
+
this.contentBBox = Rect2.empty;
|
75
|
+
let isFirstPart = true;
|
76
|
+
|
77
|
+
// Update each part
|
78
|
+
this.parts = this.parts.map((part) => {
|
79
|
+
const newPath = part.path.transformedBy(affineTransfm);
|
80
|
+
const newBBox = this.bboxForPart(newPath.bbox, part.style);
|
81
|
+
|
82
|
+
if (isFirstPart) {
|
83
|
+
this.contentBBox = newBBox;
|
84
|
+
isFirstPart = false;
|
85
|
+
} else {
|
86
|
+
this.contentBBox = this.contentBBox.union(newBBox);
|
87
|
+
}
|
88
|
+
|
89
|
+
return {
|
90
|
+
path: newPath,
|
91
|
+
bbox: newBBox,
|
92
|
+
startPoint: newPath.startPoint,
|
93
|
+
commands: newPath.parts,
|
94
|
+
style: part.style,
|
95
|
+
};
|
96
|
+
});
|
97
|
+
}
|
98
|
+
|
99
|
+
public description(localization: ImageComponentLocalization): string {
|
100
|
+
return localization.stroke;
|
101
|
+
}
|
102
|
+
}
|
@@ -0,0 +1,36 @@
|
|
1
|
+
import LineSegment2 from '../geometry/LineSegment2';
|
2
|
+
import Mat33 from '../geometry/Mat33';
|
3
|
+
import Rect2 from '../geometry/Rect2';
|
4
|
+
import AbstractRenderer from '../rendering/AbstractRenderer';
|
5
|
+
import SVGRenderer from '../rendering/SVGRenderer';
|
6
|
+
import AbstractComponent from './AbstractComponent';
|
7
|
+
import { ImageComponentLocalization } from './localization';
|
8
|
+
|
9
|
+
export default class UnknownSVGObject extends AbstractComponent {
|
10
|
+
protected contentBBox: Rect2;
|
11
|
+
|
12
|
+
public constructor(private svgObject: SVGElement) {
|
13
|
+
super();
|
14
|
+
this.contentBBox = Rect2.of(svgObject.getBoundingClientRect());
|
15
|
+
}
|
16
|
+
|
17
|
+
public render(canvas: AbstractRenderer, _visibleRect: Rect2): void {
|
18
|
+
if (!(canvas instanceof SVGRenderer)) {
|
19
|
+
// Don't draw unrenderable objects if we can't
|
20
|
+
return;
|
21
|
+
}
|
22
|
+
|
23
|
+
canvas.drawSVGElem(this.svgObject);
|
24
|
+
}
|
25
|
+
|
26
|
+
public intersects(lineSegment: LineSegment2): boolean {
|
27
|
+
return this.contentBBox.getEdges().some(edge => edge.intersection(lineSegment) !== null);
|
28
|
+
}
|
29
|
+
|
30
|
+
protected applyTransformation(_affineTransfm: Mat33): void {
|
31
|
+
}
|
32
|
+
|
33
|
+
public description(localization: ImageComponentLocalization): string {
|
34
|
+
return localization.svgObject;
|
35
|
+
}
|
36
|
+
}
|
@@ -0,0 +1,77 @@
|
|
1
|
+
import LineSegment2 from './LineSegment2';
|
2
|
+
import { loadExpectExtensions } from '../testing/loadExpectExtensions';
|
3
|
+
import { Vec2 } from './Vec2';
|
4
|
+
|
5
|
+
loadExpectExtensions();
|
6
|
+
|
7
|
+
describe('Line2', () => {
|
8
|
+
it('x and y axes should intersect at (0, 0)', () => {
|
9
|
+
const xAxis = new LineSegment2(Vec2.of(-10, 0), Vec2.of(10, 0));
|
10
|
+
const yAxis = new LineSegment2(Vec2.of(0, -10), Vec2.of(0, 10));
|
11
|
+
expect(xAxis.intersection(yAxis)?.point).objEq(Vec2.zero);
|
12
|
+
expect(yAxis.intersection(xAxis)?.point).objEq(Vec2.zero);
|
13
|
+
});
|
14
|
+
|
15
|
+
it('y = -2x + 2 and y = 2x - 2 should intersect at (1,0)', () => {
|
16
|
+
// y = -2x + 2
|
17
|
+
const line1 = new LineSegment2(Vec2.of(0, 2), Vec2.of(1, -2));
|
18
|
+
// y = 2x - 2
|
19
|
+
const line2 = new LineSegment2(Vec2.of(0, -2), Vec2.of(1, 2));
|
20
|
+
|
21
|
+
expect(line1.intersection(line2)?.point).objEq(Vec2.of(1, 0));
|
22
|
+
expect(line2.intersection(line1)?.point).objEq(Vec2.of(1, 0));
|
23
|
+
});
|
24
|
+
|
25
|
+
it('line from (10, 10) to (-100, 10) should intersect with the y-axis at t = 10', () => {
|
26
|
+
const line1 = new LineSegment2(Vec2.of(10, 10), Vec2.of(-10, 10));
|
27
|
+
// y = 2x - 2
|
28
|
+
const line2 = new LineSegment2(Vec2.of(0, -2), Vec2.of(0, 200));
|
29
|
+
|
30
|
+
expect(line1.intersection(line2)?.point).objEq(Vec2.of(0, 10));
|
31
|
+
|
32
|
+
// t=10 implies 10 units along he line from (10, 10) to (-10, 10)
|
33
|
+
expect(line1.intersection(line2)?.t).toBe(10);
|
34
|
+
|
35
|
+
// Similarly, t = 12 implies 12 units above (0, -2) in the direction of (0, 200)
|
36
|
+
expect(line2.intersection(line1)?.t).toBe(12);
|
37
|
+
});
|
38
|
+
|
39
|
+
it('y=2 and y=0 should not intersect', () => {
|
40
|
+
const line1 = new LineSegment2(Vec2.of(-10, 2), Vec2.of(10, 2));
|
41
|
+
const line2 = new LineSegment2(Vec2.of(-10, 0), Vec2.of(10, 0));
|
42
|
+
expect(line1.intersection(line2)).toBeNull();
|
43
|
+
expect(line2.intersection(line1)).toBeNull();
|
44
|
+
});
|
45
|
+
|
46
|
+
it('x=2 and x=-1 should not intersect', () => {
|
47
|
+
const line1 = new LineSegment2(Vec2.of(2, -10), Vec2.of(2, 10));
|
48
|
+
const line2 = new LineSegment2(Vec2.of(-1, 10), Vec2.of(-1, -10));
|
49
|
+
expect(line1.intersection(line2)).toBeNull();
|
50
|
+
expect(line2.intersection(line1)).toBeNull();
|
51
|
+
});
|
52
|
+
|
53
|
+
it('Line from (0, 0) to (1, 0) should not intersect line from (1.1, 0) to (2, 0)', () => {
|
54
|
+
const line1 = new LineSegment2(Vec2.of(0, 0), Vec2.of(1, 0));
|
55
|
+
const line2 = new LineSegment2(Vec2.of(1.1, 0), Vec2.of(2, 0));
|
56
|
+
expect(line1.intersection(line2)).toBeNull();
|
57
|
+
expect(line2.intersection(line1)).toBeNull();
|
58
|
+
});
|
59
|
+
|
60
|
+
it('Line segment from (1, 1) to (3, 1) should have length 2', () => {
|
61
|
+
const segment = new LineSegment2(Vec2.of(1, 1), Vec2.of(3, 1));
|
62
|
+
expect(segment.length).toBe(2);
|
63
|
+
});
|
64
|
+
|
65
|
+
it('(769.612,221.037)->(770.387,224.962) should not intersect (763.359,223.667)->(763.5493, 223.667)', () => {
|
66
|
+
// Points taken from issue observed directly in editor
|
67
|
+
const p1 = Vec2.of(769.6126045442547, 221.037877485765);
|
68
|
+
const p2 = Vec2.of(770.3873954557453, 224.962122514235);
|
69
|
+
const p3 = Vec2.of( 763.3590010920082, 223.66723995850086);
|
70
|
+
const p4 = Vec2.of(763.5494167642871, 223.66723995850086);
|
71
|
+
|
72
|
+
const line1 = new LineSegment2(p1, p2);
|
73
|
+
const line2 = new LineSegment2(p3, p4);
|
74
|
+
expect(line1.intersection(line2)).toBeNull();
|
75
|
+
expect(line2.intersection(line1)).toBeNull();
|
76
|
+
});
|
77
|
+
});
|