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
@@ -0,0 +1,545 @@
|
|
1
|
+
import Command from '../commands/Command';
|
2
|
+
import AbstractComponent from '../components/AbstractComponent';
|
3
|
+
import Editor from '../Editor';
|
4
|
+
import Mat33 from '../geometry/Mat33';
|
5
|
+
// import Mat33 from "../geometry/Mat33";
|
6
|
+
import Rect2 from '../geometry/Rect2';
|
7
|
+
import { Point2, Vec2 } from '../geometry/Vec2';
|
8
|
+
import { EditorEventType, PointerEvt } from '../types';
|
9
|
+
import Viewport from '../Viewport';
|
10
|
+
import BaseTool from './BaseTool';
|
11
|
+
import { ToolType } from './ToolController';
|
12
|
+
|
13
|
+
const handleScreenSize = 30;
|
14
|
+
const styles = `
|
15
|
+
.handleOverlay {
|
16
|
+
}
|
17
|
+
|
18
|
+
.handleOverlay > .selectionBox {
|
19
|
+
position: fixed;
|
20
|
+
z-index: 0;
|
21
|
+
transform-origin: center;
|
22
|
+
}
|
23
|
+
|
24
|
+
.handleOverlay > .selectionBox .draggableBackground {
|
25
|
+
position: absolute;
|
26
|
+
top: 0;
|
27
|
+
left: 0;
|
28
|
+
right: 0;
|
29
|
+
bottom: 0;
|
30
|
+
|
31
|
+
background-color: var(--secondary-background-color);
|
32
|
+
opacity: 0.8;
|
33
|
+
border: 1px solid var(--primary-background-color);
|
34
|
+
}
|
35
|
+
|
36
|
+
.handleOverlay .resizeCorner {
|
37
|
+
width: ${handleScreenSize}px;
|
38
|
+
height: ${handleScreenSize}px;
|
39
|
+
margin-right: -${handleScreenSize / 2}px;
|
40
|
+
margin-bottom: -${handleScreenSize / 2}px;
|
41
|
+
|
42
|
+
position: absolute;
|
43
|
+
bottom: 0;
|
44
|
+
right: 0;
|
45
|
+
|
46
|
+
opacity: 0.8;
|
47
|
+
background-color: var(--primary-background-color);
|
48
|
+
border: 1px solid var(--primary-foreground-color);
|
49
|
+
}
|
50
|
+
|
51
|
+
.handleOverlay > .selectionBox .rotateCircleContainer {
|
52
|
+
position: absolute;
|
53
|
+
top: 50%;
|
54
|
+
bottom: 50%;
|
55
|
+
left: 50%;
|
56
|
+
right: 50%;
|
57
|
+
}
|
58
|
+
|
59
|
+
.handleOverlay .rotateCircle {
|
60
|
+
width: ${handleScreenSize}px;
|
61
|
+
height: ${handleScreenSize}px;
|
62
|
+
margin-left: -${handleScreenSize / 2}px;
|
63
|
+
margin-top: -${handleScreenSize / 2}px;
|
64
|
+
opacity: 0.8;
|
65
|
+
|
66
|
+
border: 1px solid var(--primary-foreground-color);
|
67
|
+
background-color: var(--primary-background-color);
|
68
|
+
border-radius: 100%;
|
69
|
+
}
|
70
|
+
`;
|
71
|
+
|
72
|
+
type DragCallback = (delta: Vec2, offset: Point2)=> void;
|
73
|
+
type DragEndCallback = ()=> void;
|
74
|
+
|
75
|
+
const makeDraggable = (element: HTMLElement, onDrag: DragCallback, onDragEnd: DragEndCallback) => {
|
76
|
+
element.style.touchAction = 'none';
|
77
|
+
let down = false;
|
78
|
+
|
79
|
+
// Work around a Safari bug
|
80
|
+
element.addEventListener('touchstart', evt => evt.preventDefault());
|
81
|
+
|
82
|
+
let lastX: number;
|
83
|
+
let lastY: number;
|
84
|
+
element.addEventListener('pointerdown', event => {
|
85
|
+
if (event.isPrimary) {
|
86
|
+
down = true;
|
87
|
+
element.setPointerCapture(event.pointerId);
|
88
|
+
lastX = event.pageX;
|
89
|
+
lastY = event.pageY;
|
90
|
+
|
91
|
+
return true;
|
92
|
+
}
|
93
|
+
return false;
|
94
|
+
});
|
95
|
+
element.addEventListener('pointermove', event => {
|
96
|
+
if (event.isPrimary && down) {
|
97
|
+
// Safari/iOS doesn't seem to support movementX/movementY on pointer events.
|
98
|
+
// Calculate manually:
|
99
|
+
const delta = Vec2.of(event.pageX - lastX, event.pageY - lastY);
|
100
|
+
onDrag(delta, Vec2.of(event.offsetX, event.offsetY));
|
101
|
+
lastX = event.pageX;
|
102
|
+
lastY = event.pageY;
|
103
|
+
|
104
|
+
return true;
|
105
|
+
}
|
106
|
+
return false;
|
107
|
+
});
|
108
|
+
const onPointerEnd = (event: PointerEvent) => {
|
109
|
+
if (event.isPrimary) {
|
110
|
+
down = false;
|
111
|
+
onDragEnd();
|
112
|
+
|
113
|
+
return true;
|
114
|
+
}
|
115
|
+
return false;
|
116
|
+
};
|
117
|
+
element.addEventListener('pointerup', onPointerEnd);
|
118
|
+
element.addEventListener('pointercancel', onPointerEnd);
|
119
|
+
};
|
120
|
+
|
121
|
+
// Maximum number of strokes to transform without a re-render.
|
122
|
+
const updateChunkSize = 50;
|
123
|
+
|
124
|
+
class Selection {
|
125
|
+
public region: Rect2;
|
126
|
+
private boxRotation: number;
|
127
|
+
private backgroundBox: HTMLElement;
|
128
|
+
private rotateCircle: HTMLElement;
|
129
|
+
private selectedElems: AbstractComponent[];
|
130
|
+
private transform: Mat33;
|
131
|
+
private transformationCommands: Command[];
|
132
|
+
|
133
|
+
public constructor(
|
134
|
+
public startPoint: Point2, private editor: Editor
|
135
|
+
) {
|
136
|
+
this.boxRotation = this.editor.viewport.getRotationAngle();
|
137
|
+
this.selectedElems = [];
|
138
|
+
this.region = Rect2.bboxOf([startPoint]);
|
139
|
+
|
140
|
+
// Create draggable rectangles
|
141
|
+
this.backgroundBox = document.createElement('div');
|
142
|
+
const draggableBackground = document.createElement('div');
|
143
|
+
const resizeCorner = document.createElement('div');
|
144
|
+
this.rotateCircle = document.createElement('div');
|
145
|
+
const rotateCircleContainer = document.createElement('div');
|
146
|
+
|
147
|
+
this.backgroundBox.classList.add('selectionBox');
|
148
|
+
draggableBackground.classList.add('draggableBackground');
|
149
|
+
resizeCorner.classList.add('resizeCorner');
|
150
|
+
this.rotateCircle.classList.add('rotateCircle');
|
151
|
+
rotateCircleContainer.classList.add('rotateCircleContainer');
|
152
|
+
|
153
|
+
rotateCircleContainer.appendChild(this.rotateCircle);
|
154
|
+
|
155
|
+
this.backgroundBox.appendChild(draggableBackground);
|
156
|
+
this.backgroundBox.appendChild(rotateCircleContainer);
|
157
|
+
this.backgroundBox.appendChild(resizeCorner);
|
158
|
+
|
159
|
+
this.transformationCommands = [];
|
160
|
+
this.transform = Mat33.identity;
|
161
|
+
|
162
|
+
makeDraggable(draggableBackground, (deltaPosition: Vec2) => {
|
163
|
+
this.handleBackgroundDrag(deltaPosition);
|
164
|
+
}, () => this.finishDragging());
|
165
|
+
|
166
|
+
makeDraggable(resizeCorner, (deltaPosition) => {
|
167
|
+
this.handleResizeCornerDrag(deltaPosition);
|
168
|
+
}, () => this.finishDragging());
|
169
|
+
|
170
|
+
makeDraggable(this.rotateCircle, (_deltaPosition, offset) => {
|
171
|
+
this.handleRotateCircleDrag(offset);
|
172
|
+
}, () => this.finishDragging());
|
173
|
+
}
|
174
|
+
|
175
|
+
// Note a small change in the position of this' background while dragging
|
176
|
+
// At the end of a drag, changes should be applied by calling this.finishDragging()
|
177
|
+
public handleBackgroundDrag(deltaPosition: Vec2) {
|
178
|
+
// Re-scale the change in position
|
179
|
+
// (use a Vec3 transform to avoid translating deltaPosition)
|
180
|
+
deltaPosition = this.editor.viewport.screenToCanvasTransform.transformVec3(
|
181
|
+
deltaPosition
|
182
|
+
);
|
183
|
+
|
184
|
+
// Snap position to a multiple of 10 (additional decimal points lead to larger files).
|
185
|
+
deltaPosition = this.editor.viewport.roundPoint(deltaPosition);
|
186
|
+
|
187
|
+
this.region = this.region.translatedBy(deltaPosition);
|
188
|
+
this.transform = this.transform.rightMul(Mat33.translation(deltaPosition));
|
189
|
+
|
190
|
+
this.previewTransformCmds();
|
191
|
+
}
|
192
|
+
|
193
|
+
public handleResizeCornerDrag(deltaPosition: Vec2) {
|
194
|
+
deltaPosition = this.editor.viewport.screenToCanvasTransform.transformVec3(
|
195
|
+
deltaPosition
|
196
|
+
);
|
197
|
+
deltaPosition = this.editor.viewport.roundPoint(deltaPosition);
|
198
|
+
|
199
|
+
const oldWidth = this.region.w;
|
200
|
+
const oldHeight = this.region.h;
|
201
|
+
const newSize = this.region.size.plus(deltaPosition);
|
202
|
+
|
203
|
+
if (newSize.y > 0 && newSize.x > 0) {
|
204
|
+
this.region = this.region.resizedTo(newSize);
|
205
|
+
const scaleFactor = Vec2.of(this.region.w / oldWidth, this.region.h / oldHeight);
|
206
|
+
|
207
|
+
const currentTransfm = Mat33.scaling2D(scaleFactor, this.region.topLeft);
|
208
|
+
this.transform = this.transform.rightMul(currentTransfm);
|
209
|
+
this.previewTransformCmds();
|
210
|
+
}
|
211
|
+
}
|
212
|
+
|
213
|
+
public handleRotateCircleDrag(offset: Vec2) {
|
214
|
+
this.boxRotation = this.boxRotation % (2 * Math.PI);
|
215
|
+
if (this.boxRotation < 0) {
|
216
|
+
this.boxRotation += 2 * Math.PI;
|
217
|
+
}
|
218
|
+
|
219
|
+
let targetRotation = offset.angle();
|
220
|
+
targetRotation = targetRotation % (2 * Math.PI);
|
221
|
+
if (targetRotation < 0) {
|
222
|
+
targetRotation += 2 * Math.PI;
|
223
|
+
}
|
224
|
+
|
225
|
+
let deltaRotation = (targetRotation - this.boxRotation);
|
226
|
+
|
227
|
+
const rotationStep = Math.PI / 12;
|
228
|
+
if (Math.abs(deltaRotation) < rotationStep || !isFinite(deltaRotation)) {
|
229
|
+
return;
|
230
|
+
} else {
|
231
|
+
const rotationDirection = Math.sign(deltaRotation);
|
232
|
+
|
233
|
+
// Step exactly one rotationStep
|
234
|
+
deltaRotation = Math.floor(Math.abs(deltaRotation) / rotationStep) * rotationStep;
|
235
|
+
deltaRotation *= rotationDirection;
|
236
|
+
}
|
237
|
+
|
238
|
+
this.transform = this.transform.rightMul(Mat33.zRotation(deltaRotation, this.region.center));
|
239
|
+
this.boxRotation += deltaRotation;
|
240
|
+
this.previewTransformCmds();
|
241
|
+
}
|
242
|
+
|
243
|
+
private computeTransformCommands() {
|
244
|
+
return this.selectedElems.map(elem => {
|
245
|
+
return elem.transformBy(this.transform);
|
246
|
+
});
|
247
|
+
}
|
248
|
+
|
249
|
+
// Applies the current transformation to the selection
|
250
|
+
public finishDragging() {
|
251
|
+
this.transformationCommands.forEach(cmd => {
|
252
|
+
cmd.unapply(this.editor);
|
253
|
+
});
|
254
|
+
|
255
|
+
const fullTransform = this.transform;
|
256
|
+
const inverseTransform = this.transform.inverse();
|
257
|
+
const deltaBoxRotation = this.boxRotation;
|
258
|
+
const currentTransfmCommands = this.computeTransformCommands();
|
259
|
+
|
260
|
+
// Reset for the next drag
|
261
|
+
this.transformationCommands = [];
|
262
|
+
this.transform = Mat33.identity;
|
263
|
+
this.region = this.region.transformedBoundingBox(inverseTransform);
|
264
|
+
|
265
|
+
// Make the commands undo-able
|
266
|
+
this.editor.dispatch({
|
267
|
+
apply: async (editor) => {
|
268
|
+
// Approximate the new selection
|
269
|
+
this.region = this.region.transformedBoundingBox(fullTransform);
|
270
|
+
this.boxRotation += deltaBoxRotation;
|
271
|
+
this.updateUI();
|
272
|
+
|
273
|
+
await editor.asyncApplyCommands(currentTransfmCommands, updateChunkSize);
|
274
|
+
this.recomputeRegion();
|
275
|
+
this.updateUI();
|
276
|
+
},
|
277
|
+
unapply: async (editor) => {
|
278
|
+
this.region = this.region.transformedBoundingBox(inverseTransform);
|
279
|
+
this.boxRotation -= deltaBoxRotation;
|
280
|
+
this.updateUI();
|
281
|
+
|
282
|
+
await editor.asyncUnapplyCommands(currentTransfmCommands, updateChunkSize);
|
283
|
+
this.recomputeRegion();
|
284
|
+
this.updateUI();
|
285
|
+
},
|
286
|
+
|
287
|
+
description(localizationTable) {
|
288
|
+
return localizationTable.transformedElements(currentTransfmCommands.length);
|
289
|
+
},
|
290
|
+
});
|
291
|
+
}
|
292
|
+
|
293
|
+
// Preview the effects of the current transformation on the selection
|
294
|
+
private previewTransformCmds() {
|
295
|
+
// Don't render what we're moving if it's likely to be slow.
|
296
|
+
if (this.selectedElems.length > updateChunkSize) {
|
297
|
+
this.updateUI();
|
298
|
+
return;
|
299
|
+
}
|
300
|
+
|
301
|
+
this.transformationCommands.forEach(cmd => cmd.unapply(this.editor));
|
302
|
+
this.transformationCommands = this.computeTransformCommands();
|
303
|
+
this.transformationCommands.forEach(cmd => cmd.apply(this.editor));
|
304
|
+
|
305
|
+
this.updateUI();
|
306
|
+
}
|
307
|
+
|
308
|
+
|
309
|
+
public appendBackgroundBoxTo(elem: HTMLElement) {
|
310
|
+
if (this.backgroundBox.parentElement) {
|
311
|
+
this.backgroundBox.remove();
|
312
|
+
}
|
313
|
+
|
314
|
+
elem.appendChild(this.backgroundBox);
|
315
|
+
}
|
316
|
+
|
317
|
+
public setToPoint(point: Point2) {
|
318
|
+
this.region = this.region.grownToPoint(point);
|
319
|
+
this.recomputeBoxRotation();
|
320
|
+
this.updateUI();
|
321
|
+
}
|
322
|
+
|
323
|
+
public cancelSelection() {
|
324
|
+
if (this.backgroundBox.parentElement) {
|
325
|
+
this.backgroundBox.remove();
|
326
|
+
}
|
327
|
+
this.region = Rect2.empty;
|
328
|
+
}
|
329
|
+
|
330
|
+
// Find the objects corresponding to this in the document,
|
331
|
+
// select them.
|
332
|
+
// Returns false iff nothing was selected.
|
333
|
+
public resolveToObjects(): boolean {
|
334
|
+
// Grow the rectangle, if necessary
|
335
|
+
if (this.region.w === 0 || this.region.h === 0) {
|
336
|
+
const padding = this.editor.viewport.visibleRect.maxDimension / 100;
|
337
|
+
this.region = Rect2.bboxOf(this.region.corners, padding);
|
338
|
+
}
|
339
|
+
|
340
|
+
this.selectedElems = this.editor.image.getElementsIntersectingRegion(this.region).filter(elem => {
|
341
|
+
if (this.region.containsRect(elem.getBBox())) {
|
342
|
+
return true;
|
343
|
+
} else if (this.region.getEdges().some(edge => elem.intersects(edge))) {
|
344
|
+
return true;
|
345
|
+
}
|
346
|
+
return false;
|
347
|
+
});
|
348
|
+
|
349
|
+
// Find the bounding box of all selected elements.
|
350
|
+
if (!this.recomputeRegion()) {
|
351
|
+
return false;
|
352
|
+
}
|
353
|
+
this.updateUI();
|
354
|
+
|
355
|
+
return true;
|
356
|
+
}
|
357
|
+
|
358
|
+
// Recompute this' region from the selected elements. Resets rotation to zero.
|
359
|
+
// Returns false if the selection is empty.
|
360
|
+
public recomputeRegion(): boolean {
|
361
|
+
const newRegion = this.selectedElems.reduce((
|
362
|
+
accumulator: Rect2|null, elem: AbstractComponent
|
363
|
+
): Rect2 => {
|
364
|
+
return (accumulator ?? elem.getBBox()).union(elem.getBBox());
|
365
|
+
}, null);
|
366
|
+
|
367
|
+
if (!newRegion) {
|
368
|
+
this.cancelSelection();
|
369
|
+
return false;
|
370
|
+
}
|
371
|
+
|
372
|
+
this.region = newRegion;
|
373
|
+
|
374
|
+
|
375
|
+
const minSize = this.getMinCanvasSize();
|
376
|
+
if (this.region.w < minSize || this.region.h < minSize) {
|
377
|
+
// Add padding
|
378
|
+
const padding = minSize / 2;
|
379
|
+
this.region = Rect2.bboxOf(
|
380
|
+
this.region.corners, padding
|
381
|
+
);
|
382
|
+
}
|
383
|
+
|
384
|
+
this.recomputeBoxRotation();
|
385
|
+
return true;
|
386
|
+
}
|
387
|
+
|
388
|
+
public getMinCanvasSize(): number {
|
389
|
+
const canvasHandleSize = handleScreenSize / this.editor.viewport.getScaleFactor();
|
390
|
+
return canvasHandleSize * 2;
|
391
|
+
}
|
392
|
+
|
393
|
+
private recomputeBoxRotation() {
|
394
|
+
this.boxRotation = this.editor.viewport.getRotationAngle();
|
395
|
+
}
|
396
|
+
|
397
|
+
public getSelectedItemCount() {
|
398
|
+
return this.selectedElems.length;
|
399
|
+
}
|
400
|
+
|
401
|
+
public updateUI() {
|
402
|
+
if (!this.backgroundBox) {
|
403
|
+
return;
|
404
|
+
}
|
405
|
+
|
406
|
+
const rightSideDirection = this.region.topRight.minus(this.region.bottomRight);
|
407
|
+
const topSideDirection = this.region.topLeft.minus(this.region.topRight);
|
408
|
+
|
409
|
+
const toScreen = this.editor.viewport.canvasToScreenTransform;
|
410
|
+
const centerOnScreen = toScreen.transformVec2(this.region.center);
|
411
|
+
const heightOnScreen = toScreen.transformVec3(rightSideDirection).magnitude();
|
412
|
+
const widthOnScreen = toScreen.transformVec3(topSideDirection).magnitude();
|
413
|
+
|
414
|
+
this.backgroundBox.style.marginLeft = `${centerOnScreen.x - widthOnScreen / 2}px`;
|
415
|
+
this.backgroundBox.style.marginTop = `${centerOnScreen.y - heightOnScreen / 2}px`;
|
416
|
+
this.backgroundBox.style.width = `${widthOnScreen}px`;
|
417
|
+
this.backgroundBox.style.height = `${heightOnScreen}px`;
|
418
|
+
|
419
|
+
const rotationDeg = this.boxRotation * 180 / Math.PI;
|
420
|
+
|
421
|
+
this.backgroundBox.style.transform = `rotate(${rotationDeg}deg)`;
|
422
|
+
this.rotateCircle.style.transform = `rotate(${-rotationDeg}deg)`;
|
423
|
+
}
|
424
|
+
}
|
425
|
+
|
426
|
+
export default class SelectionTool extends BaseTool {
|
427
|
+
private handleOverlay: HTMLElement;
|
428
|
+
private prevSelectionBox: Selection|null;
|
429
|
+
private selectionBox: Selection|null;
|
430
|
+
public readonly kind: ToolType = ToolType.Selection;
|
431
|
+
|
432
|
+
public constructor(private editor: Editor, description: string) {
|
433
|
+
super(editor.notifier, description);
|
434
|
+
|
435
|
+
this.handleOverlay = document.createElement('div');
|
436
|
+
editor.createHTMLOverlay(this.handleOverlay);
|
437
|
+
editor.addStyleSheet(styles);
|
438
|
+
|
439
|
+
this.handleOverlay.style.display = 'none';
|
440
|
+
this.handleOverlay.classList.add('handleOverlay');
|
441
|
+
|
442
|
+
editor.notifier.on(EditorEventType.ViewportChanged, _data => {
|
443
|
+
this.selectionBox?.recomputeRegion();
|
444
|
+
this.selectionBox?.updateUI();
|
445
|
+
});
|
446
|
+
}
|
447
|
+
|
448
|
+
public onPointerDown(event: PointerEvt): boolean {
|
449
|
+
if (event.allPointers.length === 1 && event.current.isPrimary) {
|
450
|
+
this.prevSelectionBox = this.selectionBox;
|
451
|
+
this.selectionBox = new Selection(
|
452
|
+
event.current.canvasPos, this.editor
|
453
|
+
);
|
454
|
+
// Remove any previous selection rects
|
455
|
+
this.handleOverlay.replaceChildren();
|
456
|
+
this.selectionBox.appendBackgroundBoxTo(this.handleOverlay);
|
457
|
+
|
458
|
+
return true;
|
459
|
+
}
|
460
|
+
return false;
|
461
|
+
}
|
462
|
+
|
463
|
+
public onPointerMove(event: PointerEvt): void {
|
464
|
+
if (!this.selectionBox) return;
|
465
|
+
|
466
|
+
this.selectionBox!.setToPoint(event.current.canvasPos);
|
467
|
+
}
|
468
|
+
|
469
|
+
private onGestureEnd() {
|
470
|
+
if (!this.selectionBox) return;
|
471
|
+
|
472
|
+
// Expand/shrink the selection rectangle, if applicable
|
473
|
+
const hasSelection = this.selectionBox.resolveToObjects();
|
474
|
+
|
475
|
+
// Note that the selection has changed
|
476
|
+
this.editor.notifier.dispatch(EditorEventType.ToolUpdated, {
|
477
|
+
kind: EditorEventType.ToolUpdated,
|
478
|
+
tool: this,
|
479
|
+
});
|
480
|
+
|
481
|
+
if (hasSelection) {
|
482
|
+
const visibleRect = this.editor.viewport.visibleRect;
|
483
|
+
const selectionRect = this.selectionBox.region;
|
484
|
+
|
485
|
+
this.editor.announceForAccessibility(
|
486
|
+
this.editor.localization.selectedElements(this.selectionBox.getSelectedItemCount())
|
487
|
+
);
|
488
|
+
|
489
|
+
// Try to move the selection within the center 2/3rds of the viewport.
|
490
|
+
const targetRect = visibleRect.transformedBoundingBox(
|
491
|
+
Mat33.scaling2D(2 / 3, visibleRect.center)
|
492
|
+
);
|
493
|
+
|
494
|
+
// Ensure that the selection fits within the target
|
495
|
+
if (targetRect.w < selectionRect.w || targetRect.h < selectionRect.h) {
|
496
|
+
const multiplier = Math.max(
|
497
|
+
selectionRect.w / targetRect.w, selectionRect.h / targetRect.h
|
498
|
+
);
|
499
|
+
const visibleRectTransform = Mat33.scaling2D(multiplier, targetRect.topLeft);
|
500
|
+
const viewportContentTransform = visibleRectTransform.inverse();
|
501
|
+
|
502
|
+
(new Viewport.ViewportTransform(viewportContentTransform)).apply(this.editor);
|
503
|
+
}
|
504
|
+
|
505
|
+
// Ensure that the top left is visible
|
506
|
+
if (!targetRect.containsRect(selectionRect)) {
|
507
|
+
// target position - current position
|
508
|
+
const translation = selectionRect.center.minus(targetRect.center);
|
509
|
+
const visibleRectTransform = Mat33.translation(translation);
|
510
|
+
const viewportContentTransform = visibleRectTransform.inverse();
|
511
|
+
|
512
|
+
(new Viewport.ViewportTransform(viewportContentTransform)).apply(this.editor);
|
513
|
+
}
|
514
|
+
}
|
515
|
+
}
|
516
|
+
|
517
|
+
public onPointerUp(event: PointerEvt): void {
|
518
|
+
if (!this.selectionBox) return;
|
519
|
+
|
520
|
+
this.selectionBox.setToPoint(event.current.canvasPos);
|
521
|
+
this.onGestureEnd();
|
522
|
+
}
|
523
|
+
|
524
|
+
public onGestureCancel(): void {
|
525
|
+
// Revert to the previous selection, if any.
|
526
|
+
this.selectionBox?.cancelSelection();
|
527
|
+
this.selectionBox = this.prevSelectionBox;
|
528
|
+
this.selectionBox?.appendBackgroundBoxTo(this.handleOverlay);
|
529
|
+
}
|
530
|
+
|
531
|
+
public setEnabled(enabled: boolean) {
|
532
|
+
super.setEnabled(enabled);
|
533
|
+
|
534
|
+
// Clear the selection
|
535
|
+
this.handleOverlay.replaceChildren();
|
536
|
+
this.selectionBox = null;
|
537
|
+
|
538
|
+
this.handleOverlay.style.display = enabled ? 'block' : 'none';
|
539
|
+
}
|
540
|
+
|
541
|
+
// Get the object responsible for displaying this' selection.
|
542
|
+
public getSelection(): Selection|null {
|
543
|
+
return this.selectionBox;
|
544
|
+
}
|
545
|
+
}
|
@@ -0,0 +1,126 @@
|
|
1
|
+
import { InputEvtType, InputEvt, EditorEventType } from '../types';
|
2
|
+
import Editor from '../Editor';
|
3
|
+
import BaseTool from './BaseTool';
|
4
|
+
import PanZoom, { PanZoomMode } from './PanZoom';
|
5
|
+
import Pen from './Pen';
|
6
|
+
import ToolEnabledGroup from './ToolEnabledGroup';
|
7
|
+
import Eraser from './Eraser';
|
8
|
+
import SelectionTool from './SelectionTool';
|
9
|
+
import Color4 from '../Color4';
|
10
|
+
import { ToolLocalization } from './localization';
|
11
|
+
|
12
|
+
export enum ToolType {
|
13
|
+
TouchPanZoom,
|
14
|
+
Pen,
|
15
|
+
Selection,
|
16
|
+
Eraser,
|
17
|
+
PanZoom,
|
18
|
+
}
|
19
|
+
|
20
|
+
export default class ToolController {
|
21
|
+
private tools: BaseTool[];
|
22
|
+
private activeTool: BaseTool|null;
|
23
|
+
|
24
|
+
public constructor(editor: Editor, localization: ToolLocalization) {
|
25
|
+
const primaryToolEnabledGroup = new ToolEnabledGroup();
|
26
|
+
const touchPanZoom = new PanZoom(editor, PanZoomMode.OneFingerGestures, localization.touchPanTool);
|
27
|
+
const primaryPenTool = new Pen(editor, localization.penTool(1));
|
28
|
+
const primaryTools = [
|
29
|
+
new SelectionTool(editor, localization.selectionTool),
|
30
|
+
new Eraser(editor, localization.eraserTool),
|
31
|
+
|
32
|
+
// Three pens
|
33
|
+
primaryPenTool,
|
34
|
+
new Pen(editor, localization.penTool(2), Color4.clay, 8),
|
35
|
+
|
36
|
+
// Highlighter-like pen with width=64
|
37
|
+
new Pen(editor, localization.penTool(3), Color4.ofRGBA(1, 1, 0, 0.5), 64),
|
38
|
+
];
|
39
|
+
this.tools = [
|
40
|
+
touchPanZoom,
|
41
|
+
...primaryTools,
|
42
|
+
new PanZoom(editor, PanZoomMode.TwoFingerGestures | PanZoomMode.AnyDevice, localization.twoFingerPanZoomTool),
|
43
|
+
];
|
44
|
+
primaryTools.forEach(tool => tool.setToolGroup(primaryToolEnabledGroup));
|
45
|
+
touchPanZoom.setEnabled(false);
|
46
|
+
primaryPenTool.setEnabled(true);
|
47
|
+
|
48
|
+
editor.notifier.on(EditorEventType.ToolEnabled, event => {
|
49
|
+
if (event.kind === EditorEventType.ToolEnabled) {
|
50
|
+
editor.announceForAccessibility(localization.toolEnabledAnnouncement(event.tool.description));
|
51
|
+
}
|
52
|
+
});
|
53
|
+
editor.notifier.on(EditorEventType.ToolDisabled, event => {
|
54
|
+
if (event.kind === EditorEventType.ToolDisabled) {
|
55
|
+
editor.announceForAccessibility(localization.toolDisabledAnnouncement(event.tool.description));
|
56
|
+
}
|
57
|
+
});
|
58
|
+
|
59
|
+
this.activeTool = null;
|
60
|
+
}
|
61
|
+
|
62
|
+
// Returns true if the event was handled
|
63
|
+
public dispatchInputEvent(event: InputEvt): boolean {
|
64
|
+
let handled = false;
|
65
|
+
if (event.kind === InputEvtType.PointerDownEvt) {
|
66
|
+
for (const tool of this.tools) {
|
67
|
+
if (tool.isEnabled() && tool.onPointerDown(event)) {
|
68
|
+
if (this.activeTool !== tool) {
|
69
|
+
this.activeTool?.onGestureCancel();
|
70
|
+
}
|
71
|
+
|
72
|
+
this.activeTool = tool;
|
73
|
+
handled = true;
|
74
|
+
break;
|
75
|
+
}
|
76
|
+
}
|
77
|
+
} else if (event.kind === InputEvtType.PointerUpEvt) {
|
78
|
+
this.activeTool?.onPointerUp(event);
|
79
|
+
this.activeTool = null;
|
80
|
+
handled = true;
|
81
|
+
} else if (
|
82
|
+
event.kind === InputEvtType.WheelEvt || event.kind === InputEvtType.KeyPressEvent
|
83
|
+
) {
|
84
|
+
const isKeyPressEvt = event.kind === InputEvtType.KeyPressEvent;
|
85
|
+
const isWheelEvt = event.kind === InputEvtType.WheelEvt;
|
86
|
+
for (const tool of this.tools) {
|
87
|
+
if (!tool.isEnabled()) {
|
88
|
+
continue;
|
89
|
+
}
|
90
|
+
|
91
|
+
const wheelResult = isWheelEvt && tool.onWheel(event);
|
92
|
+
const keyPressResult = isKeyPressEvt && tool.onKeyPress(event);
|
93
|
+
handled = keyPressResult || wheelResult;
|
94
|
+
|
95
|
+
if (handled) {
|
96
|
+
break;
|
97
|
+
}
|
98
|
+
}
|
99
|
+
} else if (this.activeTool !== null) {
|
100
|
+
let allCasesHandledGuard: never;
|
101
|
+
|
102
|
+
switch (event.kind) {
|
103
|
+
case InputEvtType.PointerMoveEvt:
|
104
|
+
this.activeTool.onPointerMove(event);
|
105
|
+
break;
|
106
|
+
case InputEvtType.GestureCancelEvt:
|
107
|
+
this.activeTool.onGestureCancel();
|
108
|
+
this.activeTool = null;
|
109
|
+
break;
|
110
|
+
default:
|
111
|
+
allCasesHandledGuard = event;
|
112
|
+
return allCasesHandledGuard;
|
113
|
+
}
|
114
|
+
handled = true;
|
115
|
+
} else {
|
116
|
+
handled = false;
|
117
|
+
}
|
118
|
+
|
119
|
+
return handled;
|
120
|
+
}
|
121
|
+
|
122
|
+
public getMatchingTools(kind: ToolType): BaseTool[] {
|
123
|
+
return this.tools.filter(tool => tool.kind === kind);
|
124
|
+
}
|
125
|
+
}
|
126
|
+
|
@@ -0,0 +1,14 @@
|
|
1
|
+
import BaseTool from './BaseTool';
|
2
|
+
|
3
|
+
// Connects a group of tools -- at most one tool in the group must be enabled.
|
4
|
+
export default class ToolEnabledGroup {
|
5
|
+
private activeTool: BaseTool|null;
|
6
|
+
public constructor() { }
|
7
|
+
|
8
|
+
public notifyEnabled(tool: BaseTool) {
|
9
|
+
if (tool !== this.activeTool) {
|
10
|
+
this.activeTool?.setEnabled(false);
|
11
|
+
this.activeTool = tool;
|
12
|
+
}
|
13
|
+
}
|
14
|
+
}
|
@@ -0,0 +1,22 @@
|
|
1
|
+
|
2
|
+
export interface ToolLocalization {
|
3
|
+
penTool: (penId: number)=>string;
|
4
|
+
selectionTool: string;
|
5
|
+
eraserTool: string;
|
6
|
+
touchPanTool: string;
|
7
|
+
twoFingerPanZoomTool: string;
|
8
|
+
|
9
|
+
toolEnabledAnnouncement: (toolName: string) => string;
|
10
|
+
toolDisabledAnnouncement: (toolName: string) => string;
|
11
|
+
}
|
12
|
+
|
13
|
+
export const defaultToolLocalization: ToolLocalization = {
|
14
|
+
penTool: (penId) => `Pen ${penId}`,
|
15
|
+
selectionTool: 'Selection',
|
16
|
+
eraserTool: 'Eraser',
|
17
|
+
touchPanTool: 'Touch Panning',
|
18
|
+
twoFingerPanZoomTool: 'Panning and Zooming',
|
19
|
+
|
20
|
+
toolEnabledAnnouncement: (toolName) => `${toolName} enabled`,
|
21
|
+
toolDisabledAnnouncement: (toolName) => `${toolName} disabled`,
|
22
|
+
};
|