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,297 @@
|
|
1
|
+
import Editor from './Editor';
|
2
|
+
import AbstractRenderer from './rendering/AbstractRenderer';
|
3
|
+
import Command from './commands/Command';
|
4
|
+
import Viewport from './Viewport';
|
5
|
+
import AbstractComponent from './components/AbstractComponent';
|
6
|
+
import Rect2 from './geometry/Rect2';
|
7
|
+
import { EditorLocalization } from './localization';
|
8
|
+
|
9
|
+
// Handles lookup/storage of elements in the image
|
10
|
+
export default class EditorImage {
|
11
|
+
private root: ImageNode;
|
12
|
+
|
13
|
+
public constructor() {
|
14
|
+
this.root = new ImageNode();
|
15
|
+
}
|
16
|
+
|
17
|
+
private addElement(elem: AbstractComponent): ImageNode {
|
18
|
+
return this.root.addLeaf(elem);
|
19
|
+
}
|
20
|
+
|
21
|
+
// Returns the parent of the given element, if it exists.
|
22
|
+
public findParent(elem: AbstractComponent): ImageNode|null {
|
23
|
+
const candidates = this.root.getLeavesInRegion(elem.getBBox());
|
24
|
+
for (const candidate of candidates) {
|
25
|
+
if (candidate.getContent() === elem) {
|
26
|
+
return candidate;
|
27
|
+
}
|
28
|
+
}
|
29
|
+
return null;
|
30
|
+
}
|
31
|
+
|
32
|
+
private sortLeaves(leaves: ImageNode[]) {
|
33
|
+
leaves.sort((a, b) => a.getContent()!.zIndex - b.getContent()!.zIndex);
|
34
|
+
}
|
35
|
+
|
36
|
+
public render(renderer: AbstractRenderer, viewport: Viewport, minFraction: number = 0.001) {
|
37
|
+
// Don't render components that are < 0.1% of the viewport.
|
38
|
+
const leaves = this.root.getLeavesInRegion(viewport.visibleRect, minFraction);
|
39
|
+
this.sortLeaves(leaves);
|
40
|
+
|
41
|
+
for (const leaf of leaves) {
|
42
|
+
// Leaves by definition have content
|
43
|
+
leaf.getContent()!.render(renderer, viewport.visibleRect);
|
44
|
+
}
|
45
|
+
}
|
46
|
+
|
47
|
+
// Renders all nodes, even ones not within the viewport
|
48
|
+
public renderAll(renderer: AbstractRenderer) {
|
49
|
+
const leaves = this.root.getLeaves();
|
50
|
+
this.sortLeaves(leaves);
|
51
|
+
|
52
|
+
for (const leaf of leaves) {
|
53
|
+
leaf.getContent()!.render(renderer, leaf.getBBox());
|
54
|
+
}
|
55
|
+
}
|
56
|
+
|
57
|
+
public getElementsIntersectingRegion(region: Rect2): AbstractComponent[] {
|
58
|
+
const leaves = this.root.getLeavesInRegion(region);
|
59
|
+
this.sortLeaves(leaves);
|
60
|
+
return leaves.map(leaf => leaf.getContent()!);
|
61
|
+
}
|
62
|
+
|
63
|
+
// A Command that can access private [EditorImage] functionality
|
64
|
+
public static AddElementCommand = class implements Command {
|
65
|
+
readonly #element: AbstractComponent;
|
66
|
+
#applyByFlattening: boolean = false;
|
67
|
+
|
68
|
+
// If [applyByFlattening], then the rendered content of this element
|
69
|
+
// is present on the display's wet ink canvas. As such, no re-render is necessary
|
70
|
+
// the first time this command is applied (the surfaces are joined instead).
|
71
|
+
public constructor(
|
72
|
+
element: AbstractComponent,
|
73
|
+
applyByFlattening: boolean = false
|
74
|
+
) {
|
75
|
+
this.#element = element;
|
76
|
+
this.#applyByFlattening = applyByFlattening;
|
77
|
+
}
|
78
|
+
|
79
|
+
public apply(editor: Editor) {
|
80
|
+
editor.image.addElement(this.#element);
|
81
|
+
|
82
|
+
if (!this.#applyByFlattening) {
|
83
|
+
editor.queueRerender();
|
84
|
+
} else {
|
85
|
+
this.#applyByFlattening = false;
|
86
|
+
editor.display.flatten();
|
87
|
+
}
|
88
|
+
}
|
89
|
+
|
90
|
+
public unapply(editor: Editor) {
|
91
|
+
const container = editor.image.findParent(this.#element);
|
92
|
+
container?.remove();
|
93
|
+
editor.queueRerender();
|
94
|
+
}
|
95
|
+
|
96
|
+
public description(localization: EditorLocalization) {
|
97
|
+
return localization.addElementAction(this.#element.description(localization));
|
98
|
+
}
|
99
|
+
};
|
100
|
+
}
|
101
|
+
|
102
|
+
export type AddElementCommand = typeof EditorImage.AddElementCommand.prototype;
|
103
|
+
|
104
|
+
|
105
|
+
export class ImageNode {
|
106
|
+
private content: AbstractComponent|null;
|
107
|
+
private bbox: Rect2;
|
108
|
+
private children: ImageNode[];
|
109
|
+
private targetChildCount: number = 30;
|
110
|
+
|
111
|
+
public constructor(
|
112
|
+
private parent: ImageNode|null = null
|
113
|
+
) {
|
114
|
+
this.children = [];
|
115
|
+
this.bbox = Rect2.empty;
|
116
|
+
this.content = null;
|
117
|
+
}
|
118
|
+
|
119
|
+
public getContent(): AbstractComponent|null {
|
120
|
+
return this.content;
|
121
|
+
}
|
122
|
+
|
123
|
+
public getParent(): ImageNode|null {
|
124
|
+
return this.parent;
|
125
|
+
}
|
126
|
+
|
127
|
+
private getChildrenInRegion(region: Rect2): ImageNode[] {
|
128
|
+
return this.children.filter(child => {
|
129
|
+
return child.getBBox().intersects(region);
|
130
|
+
});
|
131
|
+
}
|
132
|
+
|
133
|
+
// / Returns a list of `ImageNode`s with content (and thus no children).
|
134
|
+
public getLeavesInRegion(region: Rect2, minFractionOfRegion: number = 0): ImageNode[] {
|
135
|
+
const result: ImageNode[] = [];
|
136
|
+
|
137
|
+
// Don't render if too small
|
138
|
+
if (this.bbox.maxDimension / region.maxDimension <= minFractionOfRegion) {
|
139
|
+
return [];
|
140
|
+
}
|
141
|
+
|
142
|
+
if (this.content !== null && this.getBBox().intersects(region)) {
|
143
|
+
result.push(this);
|
144
|
+
}
|
145
|
+
|
146
|
+
const children = this.getChildrenInRegion(region);
|
147
|
+
for (const child of children) {
|
148
|
+
result.push(...child.getLeavesInRegion(region, minFractionOfRegion));
|
149
|
+
}
|
150
|
+
|
151
|
+
return result;
|
152
|
+
}
|
153
|
+
|
154
|
+
// Returns a list of leaves with this as an ancestor.
|
155
|
+
// Like getLeavesInRegion, but does not check whether ancestors are in a given rectangle
|
156
|
+
public getLeaves(): ImageNode[] {
|
157
|
+
if (this.content) {
|
158
|
+
return [this];
|
159
|
+
}
|
160
|
+
|
161
|
+
const result: ImageNode[] = [];
|
162
|
+
for (const child of this.children) {
|
163
|
+
result.push(...child.getLeaves());
|
164
|
+
}
|
165
|
+
|
166
|
+
return result;
|
167
|
+
}
|
168
|
+
|
169
|
+
public addLeaf(leaf: AbstractComponent): ImageNode {
|
170
|
+
if (this.content === null && this.children.length === 0) {
|
171
|
+
this.content = leaf;
|
172
|
+
this.recomputeBBox(true);
|
173
|
+
|
174
|
+
return this;
|
175
|
+
}
|
176
|
+
|
177
|
+
if (this.content !== null) {
|
178
|
+
console.assert(this.children.length === 0);
|
179
|
+
|
180
|
+
const contentNode = new ImageNode(this);
|
181
|
+
contentNode.content = this.content;
|
182
|
+
this.content = null;
|
183
|
+
this.children.push(contentNode);
|
184
|
+
contentNode.recomputeBBox(false);
|
185
|
+
}
|
186
|
+
|
187
|
+
// If this node is contained within the leaf, make this and the leaf
|
188
|
+
// share a parent.
|
189
|
+
const leafBBox = leaf.getBBox();
|
190
|
+
if (leafBBox.containsRect(this.getBBox())) {
|
191
|
+
// Create a node for this' children and for the new content..
|
192
|
+
const nodeForNewLeaf = new ImageNode(this);
|
193
|
+
const nodeForChildren = new ImageNode(this);
|
194
|
+
|
195
|
+
nodeForChildren.children = this.children;
|
196
|
+
this.children = [nodeForNewLeaf, nodeForChildren];
|
197
|
+
nodeForChildren.recomputeBBox(true);
|
198
|
+
|
199
|
+
return nodeForNewLeaf.addLeaf(leaf);
|
200
|
+
}
|
201
|
+
|
202
|
+
const containingNodes = this.children.filter(
|
203
|
+
child => child.getBBox().containsRect(leafBBox)
|
204
|
+
);
|
205
|
+
|
206
|
+
// Does the leaf already fit within one of the children?
|
207
|
+
if (containingNodes.length > 0 && this.children.length >= this.targetChildCount) {
|
208
|
+
// Sort the containers in ascending order by area
|
209
|
+
containingNodes.sort((a, b) => a.getBBox().area - b.getBBox().area);
|
210
|
+
|
211
|
+
// Choose the smallest child that contains the new element.
|
212
|
+
const result = containingNodes[0].addLeaf(leaf);
|
213
|
+
result.rebalance();
|
214
|
+
return result;
|
215
|
+
}
|
216
|
+
|
217
|
+
|
218
|
+
const newNode = new ImageNode(this);
|
219
|
+
this.children.push(newNode);
|
220
|
+
newNode.content = leaf;
|
221
|
+
newNode.recomputeBBox(true);
|
222
|
+
|
223
|
+
return newNode;
|
224
|
+
}
|
225
|
+
|
226
|
+
public getBBox(): Rect2 {
|
227
|
+
return this.bbox;
|
228
|
+
}
|
229
|
+
|
230
|
+
// Recomputes this' bounding box. If [bubbleUp], also recompute
|
231
|
+
// this' ancestors bounding boxes
|
232
|
+
public recomputeBBox(bubbleUp: boolean) {
|
233
|
+
const oldBBox = this.bbox;
|
234
|
+
if (this.content !== null) {
|
235
|
+
this.bbox = this.content.getBBox();
|
236
|
+
} else {
|
237
|
+
this.bbox = Rect2.empty;
|
238
|
+
|
239
|
+
for (const child of this.children) {
|
240
|
+
this.bbox = this.bbox.union(child.getBBox());
|
241
|
+
}
|
242
|
+
}
|
243
|
+
|
244
|
+
if (bubbleUp && !oldBBox.eq(this.bbox)) {
|
245
|
+
this.parent?.recomputeBBox(true);
|
246
|
+
}
|
247
|
+
}
|
248
|
+
|
249
|
+
private rebalance() {
|
250
|
+
// If the current node is its parent's only child,
|
251
|
+
if (this.parent && this.parent.children.length === 1) {
|
252
|
+
console.assert(this.parent.content === null);
|
253
|
+
console.assert(this.parent.children[0] === this);
|
254
|
+
|
255
|
+
// Remove this' parent, if this' parent isn't the root.
|
256
|
+
const oldParent = this.parent;
|
257
|
+
if (oldParent.parent !== null) {
|
258
|
+
oldParent.children = [];
|
259
|
+
this.parent = oldParent.parent;
|
260
|
+
this.parent.children.push(this);
|
261
|
+
oldParent.parent = null;
|
262
|
+
this.parent.recomputeBBox(false);
|
263
|
+
} else if (this.content === null) {
|
264
|
+
// Remove this and transfer this' children to the parent.
|
265
|
+
this.parent.children = this.children;
|
266
|
+
this.parent = null;
|
267
|
+
}
|
268
|
+
}
|
269
|
+
}
|
270
|
+
|
271
|
+
// Remove this node and all of its children
|
272
|
+
public remove() {
|
273
|
+
if (!this.parent) {
|
274
|
+
this.content = null;
|
275
|
+
this.children = [];
|
276
|
+
|
277
|
+
return;
|
278
|
+
}
|
279
|
+
|
280
|
+
const oldChildCount = this.parent.children.length;
|
281
|
+
this.parent.children = this.parent.children.filter(node => {
|
282
|
+
return node !== this;
|
283
|
+
});
|
284
|
+
console.assert(this.parent.children.length === oldChildCount - 1);
|
285
|
+
|
286
|
+
this.parent.children.forEach(child => {
|
287
|
+
child.rebalance();
|
288
|
+
});
|
289
|
+
|
290
|
+
this.parent.recomputeBBox(true);
|
291
|
+
|
292
|
+
// Invalidate/disconnect this.
|
293
|
+
this.content = null;
|
294
|
+
this.parent = null;
|
295
|
+
this.children = [];
|
296
|
+
}
|
297
|
+
}
|
@@ -0,0 +1,123 @@
|
|
1
|
+
import EventDispatcher from './EventDispatcher';
|
2
|
+
|
3
|
+
enum TestKey {
|
4
|
+
FooEvent,
|
5
|
+
BarEvent,
|
6
|
+
BazEvent,
|
7
|
+
}
|
8
|
+
|
9
|
+
describe('EventDispatcher', () => {
|
10
|
+
it('should trigger after adding a listener', () => {
|
11
|
+
const dispatcher = new EventDispatcher<TestKey, void>();
|
12
|
+
let calledCount = 0;
|
13
|
+
dispatcher.on(TestKey.FooEvent, () => {
|
14
|
+
calledCount ++;
|
15
|
+
});
|
16
|
+
|
17
|
+
expect(calledCount).toBe(0);
|
18
|
+
dispatcher.dispatch(TestKey.FooEvent);
|
19
|
+
expect(calledCount).toBe(1);
|
20
|
+
});
|
21
|
+
|
22
|
+
it('should not trigger after removing a listener', () => {
|
23
|
+
const dispatcher = new EventDispatcher<TestKey, void>();
|
24
|
+
let calledCount = 0;
|
25
|
+
const handle = dispatcher.on(TestKey.FooEvent, () => {
|
26
|
+
calledCount ++;
|
27
|
+
});
|
28
|
+
|
29
|
+
handle.remove();
|
30
|
+
|
31
|
+
expect(calledCount).toBe(0);
|
32
|
+
dispatcher.dispatch(TestKey.FooEvent);
|
33
|
+
expect(calledCount).toBe(0);
|
34
|
+
});
|
35
|
+
|
36
|
+
it('adding and removing listeners should not affect other listeners', () => {
|
37
|
+
const dispatcher = new EventDispatcher<TestKey, void>();
|
38
|
+
|
39
|
+
let fooCount = 0;
|
40
|
+
const fooListener = dispatcher.on(TestKey.FooEvent, () => {
|
41
|
+
fooCount ++;
|
42
|
+
});
|
43
|
+
|
44
|
+
let barCount = 0;
|
45
|
+
const barListener1 = dispatcher.on(TestKey.BarEvent, () => {
|
46
|
+
barCount ++;
|
47
|
+
});
|
48
|
+
const barListener2 = dispatcher.on(TestKey.BarEvent, () => {
|
49
|
+
barCount += 3;
|
50
|
+
});
|
51
|
+
const barListener3 = dispatcher.on(TestKey.BarEvent, () => {
|
52
|
+
barCount += 2;
|
53
|
+
});
|
54
|
+
|
55
|
+
dispatcher.dispatch(TestKey.BarEvent);
|
56
|
+
expect(barCount).toBe(6);
|
57
|
+
|
58
|
+
dispatcher.dispatch(TestKey.FooEvent);
|
59
|
+
expect(barCount).toBe(6);
|
60
|
+
expect(fooCount).toBe(1);
|
61
|
+
|
62
|
+
fooListener.remove();
|
63
|
+
barListener2.remove();
|
64
|
+
|
65
|
+
// barListener2 shouldn't be fired
|
66
|
+
dispatcher.dispatch(TestKey.BarEvent);
|
67
|
+
expect(barCount).toBe(9);
|
68
|
+
|
69
|
+
// The BazEvent shouldn't change fooCount or barCount
|
70
|
+
dispatcher.dispatch(TestKey.BazEvent);
|
71
|
+
expect(barCount).toBe(9);
|
72
|
+
expect(fooCount).toBe(1);
|
73
|
+
|
74
|
+
// Removing a listener for the first time should return true (it removed the listener)
|
75
|
+
// and false all subsequent times
|
76
|
+
expect(barListener1.remove()).toBe(true);
|
77
|
+
expect(barListener1.remove()).toBe(false);
|
78
|
+
expect(barListener3.remove()).toBe(true);
|
79
|
+
});
|
80
|
+
|
81
|
+
it('should fire all un-removed listeners if removing a listener in a listener', () => {
|
82
|
+
const dispatcher = new EventDispatcher<TestKey, void>();
|
83
|
+
|
84
|
+
let count = 0;
|
85
|
+
const barListener = () => {
|
86
|
+
};
|
87
|
+
const bazListener = () => {
|
88
|
+
count += 5;
|
89
|
+
};
|
90
|
+
const fooListener = () => {
|
91
|
+
count ++;
|
92
|
+
dispatcher.off(TestKey.FooEvent, barListener);
|
93
|
+
};
|
94
|
+
dispatcher.on(TestKey.FooEvent, barListener);
|
95
|
+
dispatcher.on(TestKey.FooEvent, fooListener);
|
96
|
+
dispatcher.on(TestKey.FooEvent, bazListener);
|
97
|
+
|
98
|
+
// Removing a listener shouldn't cause other listeners to be skipped
|
99
|
+
dispatcher.dispatch(TestKey.FooEvent);
|
100
|
+
|
101
|
+
expect(count).toBe(6);
|
102
|
+
});
|
103
|
+
|
104
|
+
it('should send correct data associated with events', () => {
|
105
|
+
const dispatcher = new EventDispatcher<TestKey, string>();
|
106
|
+
|
107
|
+
let lastResult = '';
|
108
|
+
const resultListener = (result: string) => {
|
109
|
+
lastResult = result;
|
110
|
+
};
|
111
|
+
|
112
|
+
dispatcher.on(TestKey.BarEvent, resultListener);
|
113
|
+
|
114
|
+
dispatcher.dispatch(TestKey.BazEvent, 'Testing...');
|
115
|
+
expect(lastResult).toBe('');
|
116
|
+
|
117
|
+
dispatcher.dispatch(TestKey.BarEvent, 'Test.');
|
118
|
+
dispatcher.off(TestKey.BarEvent, resultListener);
|
119
|
+
|
120
|
+
dispatcher.dispatch(TestKey.BarEvent, 'Testing.');
|
121
|
+
expect(lastResult).toBe('Test.');
|
122
|
+
});
|
123
|
+
});
|
@@ -0,0 +1,53 @@
|
|
1
|
+
// Code shared with Joplin
|
2
|
+
|
3
|
+
type Listener<Value> = (data: Value)=> void;
|
4
|
+
type CallbackHandler<EventType> = (data: EventType)=> void;
|
5
|
+
|
6
|
+
// EventKeyType is used to distinguish events (e.g. a 'ClickEvent' vs a 'TouchEvent')
|
7
|
+
// while EventMessageType is the type of the data sent with an event (can be `void`)
|
8
|
+
export default class EventDispatcher<EventKeyType extends string|symbol|number, EventMessageType> {
|
9
|
+
// Partial marks all fields as optional. To initialize with an empty object, this is required.
|
10
|
+
// See https://stackoverflow.com/a/64526384
|
11
|
+
private listeners: Partial<Record<EventKeyType, Array<Listener<EventMessageType>>>>;
|
12
|
+
public constructor() {
|
13
|
+
this.listeners = {};
|
14
|
+
}
|
15
|
+
|
16
|
+
public dispatch(eventName: EventKeyType, event: EventMessageType) {
|
17
|
+
const listenerList = this.listeners[eventName];
|
18
|
+
|
19
|
+
if (listenerList) {
|
20
|
+
for (let i = 0; i < listenerList.length; i++) {
|
21
|
+
listenerList[i](event);
|
22
|
+
}
|
23
|
+
}
|
24
|
+
}
|
25
|
+
|
26
|
+
public on(eventName: EventKeyType, callback: CallbackHandler<EventMessageType>) {
|
27
|
+
if (!this.listeners[eventName]) this.listeners[eventName] = [];
|
28
|
+
this.listeners[eventName]!.push(callback);
|
29
|
+
|
30
|
+
return {
|
31
|
+
// Retuns false if the listener has already been removed, true otherwise.
|
32
|
+
remove: (): boolean => {
|
33
|
+
const originalListeners = this.listeners[eventName]!;
|
34
|
+
this.off(eventName, callback);
|
35
|
+
|
36
|
+
return originalListeners.length !== this.listeners[eventName]!.length;
|
37
|
+
},
|
38
|
+
};
|
39
|
+
}
|
40
|
+
|
41
|
+
// Equivalent to calling .remove() on the object returned by .on
|
42
|
+
public off(eventName: EventKeyType, callback: CallbackHandler<EventMessageType>) {
|
43
|
+
const listeners = this.listeners[eventName];
|
44
|
+
if (!listeners) return;
|
45
|
+
|
46
|
+
// Replace the current list of listeners with a new, shortened list.
|
47
|
+
// This allows any iterators over this.listeners to continue iterating
|
48
|
+
// without skipping elements.
|
49
|
+
this.listeners[eventName] = listeners.filter(
|
50
|
+
otherCallback => otherCallback !== callback
|
51
|
+
);
|
52
|
+
}
|
53
|
+
}
|
package/src/Pointer.ts
ADDED
@@ -0,0 +1,93 @@
|
|
1
|
+
import { Point2, Vec2 } from './geometry/Vec2';
|
2
|
+
import Viewport from './Viewport';
|
3
|
+
|
4
|
+
export enum PointerDevice {
|
5
|
+
Pen,
|
6
|
+
Eraser,
|
7
|
+
Touch,
|
8
|
+
Mouse,
|
9
|
+
Other,
|
10
|
+
}
|
11
|
+
|
12
|
+
// Provides a snapshot containing information about a pointer. A Pointer
|
13
|
+
// object is immutable --- it will not be updated when the pointer's information changes.
|
14
|
+
export default class Pointer {
|
15
|
+
private constructor(
|
16
|
+
// The (x, y) position of the pointer relative to the top-left corner
|
17
|
+
// of the visible canvas.
|
18
|
+
public readonly screenPos: Point2,
|
19
|
+
|
20
|
+
// Position of the pointer relative to the top left corner of the drawing
|
21
|
+
// surface.
|
22
|
+
public readonly canvasPos: Point2,
|
23
|
+
|
24
|
+
public readonly pressure: number|null,
|
25
|
+
public readonly isPrimary: boolean,
|
26
|
+
public readonly down: boolean,
|
27
|
+
|
28
|
+
public readonly device: PointerDevice,
|
29
|
+
|
30
|
+
// Unique ID for the pointer
|
31
|
+
public readonly id: number,
|
32
|
+
|
33
|
+
// Numeric timestamp (milliseconds, as from (new Date).getTime())
|
34
|
+
public readonly timeStamp: number
|
35
|
+
) {
|
36
|
+
}
|
37
|
+
|
38
|
+
public static ofEvent(evt: PointerEvent, isDown: boolean, viewport: Viewport): Pointer {
|
39
|
+
const screenPos = Vec2.of(evt.offsetX, evt.offsetY);
|
40
|
+
|
41
|
+
const pointerTypeToDevice: Record<string, PointerDevice> = {
|
42
|
+
'mouse': PointerDevice.Mouse,
|
43
|
+
'pen': PointerDevice.Pen,
|
44
|
+
'touch': PointerDevice.Touch,
|
45
|
+
};
|
46
|
+
|
47
|
+
let device = pointerTypeToDevice[evt.pointerType] ?? PointerDevice.Other;
|
48
|
+
const eraserButtonMask = 0x20;
|
49
|
+
if (device === PointerDevice.Pen && (evt.buttons & eraserButtonMask) !== 0) {
|
50
|
+
device = PointerDevice.Eraser;
|
51
|
+
}
|
52
|
+
|
53
|
+
const timeStamp = (new Date()).getTime();
|
54
|
+
const canvasPos = viewport.screenToCanvas(screenPos);
|
55
|
+
|
56
|
+
return new Pointer(
|
57
|
+
screenPos,
|
58
|
+
canvasPos,
|
59
|
+
evt.pressure ?? null,
|
60
|
+
evt.isPrimary,
|
61
|
+
isDown,
|
62
|
+
device,
|
63
|
+
evt.pointerId,
|
64
|
+
timeStamp
|
65
|
+
);
|
66
|
+
}
|
67
|
+
|
68
|
+
// Create a new Pointer from a point on the canvas.
|
69
|
+
// Intended for unit tests.
|
70
|
+
public static ofCanvasPoint(
|
71
|
+
canvasPos: Point2,
|
72
|
+
isDown: boolean,
|
73
|
+
viewport: Viewport,
|
74
|
+
id: number = 0,
|
75
|
+
device: PointerDevice = PointerDevice.Pen,
|
76
|
+
isPrimary: boolean = true,
|
77
|
+
pressure: number|null = null
|
78
|
+
): Pointer {
|
79
|
+
const screenPos = viewport.canvasToScreen(canvasPos);
|
80
|
+
const timeStamp = (new Date()).getTime();
|
81
|
+
|
82
|
+
return new Pointer(
|
83
|
+
screenPos,
|
84
|
+
canvasPos,
|
85
|
+
pressure,
|
86
|
+
isPrimary,
|
87
|
+
isDown,
|
88
|
+
device,
|
89
|
+
id,
|
90
|
+
timeStamp
|
91
|
+
);
|
92
|
+
}
|
93
|
+
}
|