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.
Files changed (156) hide show
  1. package/.eslintrc.js +57 -0
  2. package/.husky/pre-commit +4 -0
  3. package/LICENSE +21 -0
  4. package/README.md +74 -0
  5. package/__mocks__/coloris.ts +8 -0
  6. package/__mocks__/styleMock.js +1 -0
  7. package/dist/__mocks__/coloris.d.ts +2 -0
  8. package/dist/__mocks__/coloris.js +5 -0
  9. package/dist/build_tools/BundledFile.d.ts +12 -0
  10. package/dist/build_tools/BundledFile.js +153 -0
  11. package/dist/scripts/bundle.d.ts +1 -0
  12. package/dist/scripts/bundle.js +19 -0
  13. package/dist/scripts/watchBundle.d.ts +1 -0
  14. package/dist/scripts/watchBundle.js +9 -0
  15. package/dist/src/Color4.d.ts +23 -0
  16. package/dist/src/Color4.js +102 -0
  17. package/dist/src/Display.d.ts +22 -0
  18. package/dist/src/Display.js +93 -0
  19. package/dist/src/Editor.d.ts +55 -0
  20. package/dist/src/Editor.js +366 -0
  21. package/dist/src/EditorImage.d.ts +44 -0
  22. package/dist/src/EditorImage.js +243 -0
  23. package/dist/src/EventDispatcher.d.ts +11 -0
  24. package/dist/src/EventDispatcher.js +39 -0
  25. package/dist/src/Pointer.d.ts +22 -0
  26. package/dist/src/Pointer.js +57 -0
  27. package/dist/src/SVGLoader.d.ts +21 -0
  28. package/dist/src/SVGLoader.js +204 -0
  29. package/dist/src/StrokeBuilder.d.ts +35 -0
  30. package/dist/src/StrokeBuilder.js +275 -0
  31. package/dist/src/UndoRedoHistory.d.ts +17 -0
  32. package/dist/src/UndoRedoHistory.js +46 -0
  33. package/dist/src/Viewport.d.ts +39 -0
  34. package/dist/src/Viewport.js +134 -0
  35. package/dist/src/commands/Command.d.ts +15 -0
  36. package/dist/src/commands/Command.js +29 -0
  37. package/dist/src/commands/Erase.d.ts +11 -0
  38. package/dist/src/commands/Erase.js +37 -0
  39. package/dist/src/commands/localization.d.ts +19 -0
  40. package/dist/src/commands/localization.js +17 -0
  41. package/dist/src/components/AbstractComponent.d.ts +19 -0
  42. package/dist/src/components/AbstractComponent.js +46 -0
  43. package/dist/src/components/Stroke.d.ts +16 -0
  44. package/dist/src/components/Stroke.js +79 -0
  45. package/dist/src/components/UnknownSVGObject.d.ts +15 -0
  46. package/dist/src/components/UnknownSVGObject.js +25 -0
  47. package/dist/src/components/localization.d.ts +5 -0
  48. package/dist/src/components/localization.js +4 -0
  49. package/dist/src/geometry/LineSegment2.d.ts +19 -0
  50. package/dist/src/geometry/LineSegment2.js +100 -0
  51. package/dist/src/geometry/Mat33.d.ts +31 -0
  52. package/dist/src/geometry/Mat33.js +187 -0
  53. package/dist/src/geometry/Path.d.ts +55 -0
  54. package/dist/src/geometry/Path.js +364 -0
  55. package/dist/src/geometry/Rect2.d.ts +47 -0
  56. package/dist/src/geometry/Rect2.js +148 -0
  57. package/dist/src/geometry/Vec2.d.ts +13 -0
  58. package/dist/src/geometry/Vec2.js +13 -0
  59. package/dist/src/geometry/Vec3.d.ts +32 -0
  60. package/dist/src/geometry/Vec3.js +98 -0
  61. package/dist/src/localization.d.ts +12 -0
  62. package/dist/src/localization.js +5 -0
  63. package/dist/src/main.d.ts +3 -0
  64. package/dist/src/main.js +4 -0
  65. package/dist/src/rendering/AbstractRenderer.d.ts +38 -0
  66. package/dist/src/rendering/AbstractRenderer.js +108 -0
  67. package/dist/src/rendering/CanvasRenderer.d.ts +23 -0
  68. package/dist/src/rendering/CanvasRenderer.js +108 -0
  69. package/dist/src/rendering/DummyRenderer.d.ts +25 -0
  70. package/dist/src/rendering/DummyRenderer.js +65 -0
  71. package/dist/src/rendering/SVGRenderer.d.ts +27 -0
  72. package/dist/src/rendering/SVGRenderer.js +122 -0
  73. package/dist/src/testing/loadExpectExtensions.d.ts +17 -0
  74. package/dist/src/testing/loadExpectExtensions.js +27 -0
  75. package/dist/src/toolbar/HTMLToolbar.d.ts +12 -0
  76. package/dist/src/toolbar/HTMLToolbar.js +444 -0
  77. package/dist/src/toolbar/types.d.ts +17 -0
  78. package/dist/src/toolbar/types.js +5 -0
  79. package/dist/src/tools/BaseTool.d.ts +20 -0
  80. package/dist/src/tools/BaseTool.js +44 -0
  81. package/dist/src/tools/Eraser.d.ts +16 -0
  82. package/dist/src/tools/Eraser.js +53 -0
  83. package/dist/src/tools/PanZoom.d.ts +40 -0
  84. package/dist/src/tools/PanZoom.js +191 -0
  85. package/dist/src/tools/Pen.d.ts +25 -0
  86. package/dist/src/tools/Pen.js +97 -0
  87. package/dist/src/tools/SelectionTool.d.ts +49 -0
  88. package/dist/src/tools/SelectionTool.js +437 -0
  89. package/dist/src/tools/ToolController.d.ts +18 -0
  90. package/dist/src/tools/ToolController.js +110 -0
  91. package/dist/src/tools/ToolEnabledGroup.d.ts +6 -0
  92. package/dist/src/tools/ToolEnabledGroup.js +11 -0
  93. package/dist/src/tools/localization.d.ts +10 -0
  94. package/dist/src/tools/localization.js +9 -0
  95. package/dist/src/types.d.ts +88 -0
  96. package/dist/src/types.js +20 -0
  97. package/jest.config.js +22 -0
  98. package/lint-staged.config.js +6 -0
  99. package/package.json +82 -0
  100. package/src/Color4.test.ts +12 -0
  101. package/src/Color4.ts +122 -0
  102. package/src/Display.ts +118 -0
  103. package/src/Editor.css +58 -0
  104. package/src/Editor.ts +469 -0
  105. package/src/EditorImage.test.ts +90 -0
  106. package/src/EditorImage.ts +297 -0
  107. package/src/EventDispatcher.test.ts +123 -0
  108. package/src/EventDispatcher.ts +53 -0
  109. package/src/Pointer.ts +93 -0
  110. package/src/SVGLoader.ts +230 -0
  111. package/src/StrokeBuilder.ts +362 -0
  112. package/src/UndoRedoHistory.ts +61 -0
  113. package/src/Viewport.ts +168 -0
  114. package/src/commands/Command.ts +43 -0
  115. package/src/commands/Erase.ts +52 -0
  116. package/src/commands/localization.ts +38 -0
  117. package/src/components/AbstractComponent.ts +73 -0
  118. package/src/components/Stroke.test.ts +18 -0
  119. package/src/components/Stroke.ts +102 -0
  120. package/src/components/UnknownSVGObject.ts +36 -0
  121. package/src/components/localization.ts +9 -0
  122. package/src/editorStyles.js +3 -0
  123. package/src/geometry/LineSegment2.test.ts +77 -0
  124. package/src/geometry/LineSegment2.ts +127 -0
  125. package/src/geometry/Mat33.test.ts +144 -0
  126. package/src/geometry/Mat33.ts +268 -0
  127. package/src/geometry/Path.fromString.test.ts +146 -0
  128. package/src/geometry/Path.test.ts +96 -0
  129. package/src/geometry/Path.toString.test.ts +31 -0
  130. package/src/geometry/Path.ts +456 -0
  131. package/src/geometry/Rect2.test.ts +121 -0
  132. package/src/geometry/Rect2.ts +215 -0
  133. package/src/geometry/Vec2.test.ts +32 -0
  134. package/src/geometry/Vec2.ts +18 -0
  135. package/src/geometry/Vec3.test.ts +29 -0
  136. package/src/geometry/Vec3.ts +133 -0
  137. package/src/localization.ts +27 -0
  138. package/src/rendering/AbstractRenderer.ts +164 -0
  139. package/src/rendering/CanvasRenderer.ts +141 -0
  140. package/src/rendering/DummyRenderer.ts +80 -0
  141. package/src/rendering/SVGRenderer.ts +159 -0
  142. package/src/testing/loadExpectExtensions.ts +43 -0
  143. package/src/toolbar/HTMLToolbar.ts +551 -0
  144. package/src/toolbar/toolbar.css +110 -0
  145. package/src/toolbar/types.ts +20 -0
  146. package/src/tools/BaseTool.ts +58 -0
  147. package/src/tools/Eraser.ts +67 -0
  148. package/src/tools/PanZoom.ts +253 -0
  149. package/src/tools/Pen.ts +121 -0
  150. package/src/tools/SelectionTool.test.ts +85 -0
  151. package/src/tools/SelectionTool.ts +545 -0
  152. package/src/tools/ToolController.ts +126 -0
  153. package/src/tools/ToolEnabledGroup.ts +14 -0
  154. package/src/tools/localization.ts +22 -0
  155. package/src/types.ts +133 -0
  156. package/tsconfig.json +28 -0
@@ -0,0 +1,230 @@
1
+ import Color4 from './Color4';
2
+ import AbstractComponent from './components/AbstractComponent';
3
+ import Stroke from './components/Stroke';
4
+ import UnknownSVGObject from './components/UnknownSVGObject';
5
+ import Path from './geometry/Path';
6
+ import Rect2 from './geometry/Rect2';
7
+ import { RenderablePathSpec, RenderingStyle } from './rendering/AbstractRenderer';
8
+ import { ComponentAddedListener, ImageLoader, OnProgressListener } from './types';
9
+
10
+ type OnFinishListener = ()=> void;
11
+
12
+ // Size of a loaded image if no size is specified.
13
+ export const defaultSVGViewRect = new Rect2(0, 0, 500, 500);
14
+
15
+ export default class SVGLoader implements ImageLoader {
16
+ private onAddComponent: ComponentAddedListener|null = null;
17
+ private onProgress: OnProgressListener|null = null;
18
+ private processedCount: number = 0;
19
+ private totalToProcess: number = 0;
20
+ private rootViewBox: Rect2|null;
21
+
22
+ private constructor(private source: SVGSVGElement, private onFinish?: OnFinishListener) {
23
+ }
24
+
25
+ private getStyle(node: SVGElement) {
26
+ const style: RenderingStyle = {
27
+ fill: Color4.transparent,
28
+ };
29
+
30
+ const fillAttribute = node.getAttribute('fill') ?? node.style.fill;
31
+ if (fillAttribute) {
32
+ try {
33
+ style.fill = Color4.fromString(fillAttribute);
34
+ } catch (e) {
35
+ console.error('Unknown fill color,', fillAttribute);
36
+ }
37
+ }
38
+
39
+ const strokeAttribute = node.getAttribute('stroke') ?? node.style.stroke;
40
+ const strokeWidthAttr = node.getAttribute('stroke-width') ?? node.style.strokeWidth;
41
+ if (strokeAttribute) {
42
+ try {
43
+ let width = parseFloat(strokeWidthAttr ?? '1');
44
+ if (!isFinite(width)) {
45
+ width = 0;
46
+ }
47
+
48
+ style.stroke = {
49
+ width,
50
+ color: Color4.fromString(strokeAttribute),
51
+ };
52
+ } catch (e) {
53
+ console.error('Error parsing stroke data:', e);
54
+ }
55
+ }
56
+
57
+ return style;
58
+ }
59
+
60
+ private strokeDataFromElem(node: SVGPathElement): RenderablePathSpec[] {
61
+ const result: RenderablePathSpec[] = [];
62
+ const pathData = node.getAttribute('d') ?? '';
63
+ const style = this.getStyle(node);
64
+
65
+ // Break the path into chunks at each moveTo ('M') command:
66
+ const parts = pathData.split('M');
67
+ let isFirst = true;
68
+ for (const part of parts) {
69
+ if (part !== '') {
70
+ // We split the path by moveTo commands, so add the 'M' back in
71
+ // if it was present.
72
+ const current = !isFirst ? `M${part}` : part;
73
+ const path = Path.fromString(current);
74
+ const spec = path.toRenderable(style);
75
+ result.push(spec);
76
+ }
77
+
78
+ isFirst = false;
79
+ }
80
+
81
+ return result;
82
+ }
83
+
84
+ // Adds a stroke with a single path
85
+ private addPath(node: SVGPathElement) {
86
+ let elem: AbstractComponent;
87
+ try {
88
+ const strokeData = this.strokeDataFromElem(node);
89
+ elem = new Stroke(strokeData);
90
+ } catch (e) {
91
+ console.error(
92
+ 'Invalid path in node', node,
93
+ '\nError:', e,
94
+ '\nAdding as an unknown object.'
95
+ );
96
+
97
+ elem = new UnknownSVGObject(node);
98
+ }
99
+ this.onAddComponent?.(elem);
100
+ }
101
+
102
+ private addUnknownNode(node: SVGElement) {
103
+ const component = new UnknownSVGObject(node);
104
+ this.onAddComponent?.(component);
105
+ }
106
+
107
+ private updateViewBox(node: SVGSVGElement) {
108
+ const viewBoxAttr = node.getAttribute('viewBox');
109
+ if (this.rootViewBox || !viewBoxAttr) {
110
+ return;
111
+ }
112
+
113
+ const components = viewBoxAttr.split(/[ \t,]/);
114
+ const x = parseFloat(components[0]);
115
+ const y = parseFloat(components[1]);
116
+ const width = parseFloat(components[2]);
117
+ const height = parseFloat(components[3]);
118
+
119
+ if (isNaN(x) || isNaN(y) || isNaN(width) || isNaN(height)) {
120
+ return;
121
+ }
122
+
123
+ this.rootViewBox = new Rect2(x, y, width, height);
124
+ }
125
+
126
+ private async visit(node: Element) {
127
+ this.totalToProcess += node.childElementCount;
128
+
129
+ switch (node.tagName.toLowerCase()) {
130
+ case 'g':
131
+ // Continue -- visit the node's children.
132
+ break;
133
+ case 'path':
134
+ this.addPath(node as SVGPathElement);
135
+ break;
136
+ case 'svg':
137
+ this.updateViewBox(node as SVGSVGElement);
138
+ break;
139
+ default:
140
+ console.warn('Unknown SVG element,', node);
141
+ if (!(node instanceof SVGElement)) {
142
+ console.warn('Element', node, 'is not an SVGElement! Continuing anyway.');
143
+ }
144
+
145
+ this.addUnknownNode(node as SVGElement);
146
+ return;
147
+ }
148
+
149
+ for (const child of node.children) {
150
+ await this.visit(child);
151
+ }
152
+
153
+ this.processedCount ++;
154
+ await this.onProgress?.(this.processedCount, this.totalToProcess);
155
+ }
156
+
157
+ public async start(
158
+ onAddComponent: ComponentAddedListener, onProgress: OnProgressListener
159
+ ): Promise<Rect2> {
160
+ this.onAddComponent = onAddComponent;
161
+ this.onProgress = onProgress;
162
+
163
+ // Estimate the number of tags to process.
164
+ this.totalToProcess = this.source.childElementCount;
165
+ this.processedCount = 0;
166
+
167
+ this.rootViewBox = null;
168
+ await this.visit(this.source);
169
+
170
+ const viewBox = this.rootViewBox;
171
+ let result = defaultSVGViewRect;
172
+
173
+ if (viewBox) {
174
+ result = Rect2.of(viewBox);
175
+ }
176
+
177
+ this.onFinish?.();
178
+ return result;
179
+ }
180
+
181
+ // TODO: Handling unsafe data! Tripple-check that this is secure!
182
+ public static fromString(text: string): SVGLoader {
183
+ const sandbox = document.createElement('iframe');
184
+ sandbox.src = 'about:blank';
185
+ sandbox.setAttribute('sandbox', 'allow-same-origin');
186
+ sandbox.setAttribute('csp', 'default-src \'about:blank\'');
187
+ sandbox.style.display = 'none';
188
+
189
+ // Required to access the frame's DOM. See https://stackoverflow.com/a/17777943/17055750
190
+ document.body.appendChild(sandbox);
191
+
192
+ if (!sandbox.hasAttribute('sandbox')) {
193
+ sandbox.remove();
194
+ throw new Error('SVG loading iframe is not sandboxed.');
195
+ }
196
+
197
+ // Try running JavaScript within the iframe
198
+ const sandboxDoc = sandbox.contentWindow?.document ?? sandbox.contentDocument;
199
+
200
+ if (sandboxDoc == null) throw new Error('Unable to open a sandboxed iframe!');
201
+
202
+ sandboxDoc.open();
203
+ sandboxDoc.write(`
204
+ <!DOCTYPE html>
205
+ <html>
206
+ <head>
207
+ <title>SVG Loading Sandbox</title>
208
+ </head>
209
+ <body>
210
+ <script>
211
+ console.error('JavaScript should not be able to run here!');
212
+ throw new Error(
213
+ 'The SVG sandbox is broken! Please double-check the sandboxing setting.'
214
+ );
215
+ </script>
216
+ </body>
217
+ </html>
218
+ `);
219
+ sandboxDoc.close();
220
+
221
+ const svgElem = sandboxDoc.createElementNS(
222
+ 'http://www.w3.org/2000/svg', 'svg'
223
+ );
224
+ svgElem.innerHTML = text;
225
+
226
+ return new SVGLoader(svgElem, () => {
227
+ sandbox.remove();
228
+ });
229
+ }
230
+ }
@@ -0,0 +1,362 @@
1
+ import Color4 from './Color4';
2
+ import { Bezier } from 'bezier-js';
3
+ import { RenderingStyle, RenderablePathSpec } from './rendering/AbstractRenderer';
4
+ import { Point2, Vec2 } from './geometry/Vec2';
5
+ import Rect2 from './geometry/Rect2';
6
+ import { PathCommand, PathCommandType } from './geometry/Path';
7
+ import LineSegment2 from './geometry/LineSegment2';
8
+ import Stroke from './components/Stroke';
9
+ import Viewport from './Viewport';
10
+
11
+ export interface StrokeDataPoint {
12
+ pos: Point2;
13
+ width: number;
14
+ time: number;
15
+ color: Color4;
16
+ }
17
+
18
+ // Handles stroke smoothing and creates Strokes from user/stylus input.
19
+ export default class StrokeBuilder {
20
+ private segments: RenderablePathSpec[];
21
+ private buffer: Point2[];
22
+ private lastPoint: StrokeDataPoint;
23
+ private lastExitingVec: Vec2;
24
+ private currentCurve: Bezier|null = null;
25
+ private curveStartWidth: number;
26
+ private curveEndWidth: number;
27
+
28
+ // Stroke smoothing and tangent approximation
29
+ private momentum: Vec2;
30
+ private bbox: Rect2;
31
+
32
+ public constructor(
33
+ private startPoint: StrokeDataPoint,
34
+
35
+ // Maximum distance from the actual curve (irrespective of stroke width)
36
+ // for which a point is considered 'part of the curve'.
37
+ // Note that the maximum will be smaller if the stroke width is less than
38
+ // [maxFitAllowed].
39
+ private minFitAllowed: number,
40
+ private maxFitAllowed: number
41
+ ) {
42
+ this.lastPoint = this.startPoint;
43
+ this.segments = [];
44
+ this.buffer = [this.startPoint.pos];
45
+ this.momentum = Vec2.zero;
46
+ this.currentCurve = null;
47
+
48
+ this.bbox = new Rect2(this.startPoint.pos.x, this.startPoint.pos.y, 0, 0);
49
+ }
50
+
51
+ public getBBox(): Rect2 {
52
+ return this.bbox;
53
+ }
54
+
55
+ private getRenderingStyle(): RenderingStyle {
56
+ return {
57
+ fill: this.lastPoint.color ?? null,
58
+ };
59
+ }
60
+
61
+ // Get the segments that make up this' path. Can be called after calling build()
62
+ public preview(): RenderablePathSpec[] {
63
+ if (this.currentCurve && this.lastPoint) {
64
+ const currentPath = this.currentSegmentToPath();
65
+ return this.segments.concat(currentPath);
66
+ }
67
+
68
+ return this.segments;
69
+ }
70
+
71
+ public build(): Stroke {
72
+ if (this.lastPoint) {
73
+ this.finalizeCurrentCurve();
74
+ }
75
+ return new Stroke(
76
+ this.segments,
77
+ );
78
+ }
79
+
80
+ private roundPoint(point: Point2): Point2 {
81
+ return Viewport.roundPoint(point, this.minFitAllowed);
82
+ }
83
+
84
+ private finalizeCurrentCurve() {
85
+ // Case where no points have been added
86
+ if (!this.currentCurve) {
87
+ // Don't create a circle around the initial point if the stroke has more than one point.
88
+ if (this.segments.length > 0) {
89
+ return;
90
+ }
91
+
92
+ const width = Viewport.roundPoint(this.startPoint.width / 3, this.minFitAllowed);
93
+ const center = this.roundPoint(this.startPoint.pos);
94
+
95
+ // Draw a circle-ish shape around the start point
96
+ this.segments.push({
97
+ // Start on the right, cycle clockwise:
98
+ // |
99
+ // ----- ←
100
+ // |
101
+ startPoint: this.startPoint.pos.plus(Vec2.of(width, 0)),
102
+ commands: [
103
+ {
104
+ kind: PathCommandType.QuadraticBezierTo,
105
+ controlPoint: center.plus(Vec2.of(width, width)),
106
+
107
+ // Bottom of the circle
108
+ // |
109
+ // -----
110
+ // |
111
+ // ↑
112
+ endPoint: center.plus(Vec2.of(0, width)),
113
+ },
114
+ {
115
+ kind: PathCommandType.QuadraticBezierTo,
116
+ controlPoint: center.plus(Vec2.of(-width, width)),
117
+ endPoint: center.plus(Vec2.of(-width, 0)),
118
+ },
119
+ {
120
+ kind: PathCommandType.QuadraticBezierTo,
121
+ controlPoint: center.plus(Vec2.of(-width, -width)),
122
+ endPoint: center.plus(Vec2.of(0, -width)),
123
+ },
124
+ {
125
+ kind: PathCommandType.QuadraticBezierTo,
126
+ controlPoint: center.plus(Vec2.of(width, -width)),
127
+ endPoint: center.plus(Vec2.of(width, 0)),
128
+ },
129
+ ],
130
+ style: this.getRenderingStyle(),
131
+ });
132
+ return;
133
+ }
134
+
135
+ this.segments.push(this.currentSegmentToPath());
136
+ const lastPoint = this.buffer[this.buffer.length - 1];
137
+ this.lastExitingVec = Vec2.ofXY(
138
+ this.currentCurve.points[2]
139
+ ).minus(Vec2.ofXY(this.currentCurve.points[1]));
140
+ console.assert(this.lastExitingVec.magnitude() !== 0);
141
+
142
+ // Use the last two points to start a new curve (the last point isn't used
143
+ // in the current curve and we want connected curves to share end points)
144
+ this.buffer = [
145
+ this.buffer[this.buffer.length - 2], lastPoint,
146
+ ];
147
+ this.currentCurve = null;
148
+ }
149
+
150
+ private currentSegmentToPath(): RenderablePathSpec {
151
+ if (this.currentCurve == null) {
152
+ throw new Error('Invalid State: currentCurve is null!');
153
+ }
154
+
155
+ let startVec = Vec2.ofXY(this.currentCurve.normal(0)).normalized();
156
+ let endVec = Vec2.ofXY(this.currentCurve.normal(1)).normalized();
157
+
158
+ startVec = startVec.times(this.curveStartWidth / 2);
159
+ endVec = endVec.times(this.curveEndWidth / 2);
160
+
161
+ if (isNaN(startVec.magnitude())) {
162
+ // TODO: This can happen when events are too close together. Find out why and
163
+ // fix.
164
+ console.error('startVec is NaN', startVec, endVec, this.currentCurve);
165
+ startVec = endVec;
166
+ }
167
+
168
+ const startPt = Vec2.ofXY(this.currentCurve.get(0));
169
+ const endPt = Vec2.ofXY(this.currentCurve.get(1));
170
+ const controlPoint = Vec2.ofXY(this.currentCurve.points[1]);
171
+
172
+ // Approximate the normal at the location of the control point
173
+ let projectionT = this.currentCurve.project(controlPoint.xy).t;
174
+
175
+ if (!projectionT) {
176
+ if (startPt.minus(controlPoint).magnitudeSquared() < endPt.minus(controlPoint).magnitudeSquared()) {
177
+ projectionT = 0.1;
178
+ } else {
179
+ projectionT = 0.9;
180
+ }
181
+ }
182
+
183
+ const halfVec = Vec2.ofXY(this.currentCurve.normal(projectionT))
184
+ .normalized().times(
185
+ this.curveStartWidth / 2 * projectionT
186
+ + this.curveEndWidth / 2 * (1 - projectionT)
187
+ );
188
+
189
+ const pathCommands: PathCommand[] = [
190
+ {
191
+ kind: PathCommandType.QuadraticBezierTo,
192
+ controlPoint: this.roundPoint(controlPoint.plus(halfVec)),
193
+ endPoint: this.roundPoint(endPt.plus(endVec)),
194
+ },
195
+
196
+ {
197
+ kind: PathCommandType.LineTo,
198
+ point: this.roundPoint(endPt.minus(endVec)),
199
+ },
200
+
201
+ {
202
+ kind: PathCommandType.QuadraticBezierTo,
203
+ controlPoint: this.roundPoint(controlPoint.minus(halfVec)),
204
+ endPoint: this.roundPoint(startPt.minus(startVec)),
205
+ },
206
+ ];
207
+
208
+ return {
209
+ startPoint: this.roundPoint(startPt.plus(startVec)),
210
+ commands: pathCommands,
211
+ style: this.getRenderingStyle(),
212
+ };
213
+ }
214
+
215
+ // Compute the direction of the velocity at the end of this.buffer
216
+ private computeExitingVec(): Vec2 {
217
+ return this.momentum.normalized().times(this.lastPoint.width / 2);
218
+ }
219
+
220
+ public addPoint(newPoint: StrokeDataPoint) {
221
+ if (this.lastPoint) {
222
+ // Ignore points that are identical
223
+ const fuzzEq = 1e-10;
224
+ const deltaTime = newPoint.time - this.lastPoint.time;
225
+ if (newPoint.pos.eq(this.lastPoint.pos, fuzzEq) || deltaTime === 0) {
226
+ console.warn('Discarding identical point');
227
+ return;
228
+ } else if (isNaN(newPoint.pos.magnitude())) {
229
+ console.warn('Discarding NaN point.', newPoint);
230
+ return;
231
+ }
232
+
233
+ const threshold = Math.min(this.lastPoint.width, newPoint.width) / 4;
234
+ if (this.lastPoint.pos.minus(newPoint.pos).magnitude() < threshold) {
235
+ return;
236
+ }
237
+
238
+ const velocity = newPoint.pos.minus(this.lastPoint.pos).times(1 / (deltaTime) * 1000);
239
+ this.momentum = this.momentum.lerp(velocity, 0.9);
240
+ }
241
+
242
+ const lastPoint = this.lastPoint ?? newPoint;
243
+ this.lastPoint = newPoint;
244
+
245
+ this.buffer.push(newPoint.pos);
246
+ const pointRadius = newPoint.width / 2;
247
+ const prevEndWidth = this.curveEndWidth;
248
+ this.curveEndWidth = pointRadius;
249
+
250
+ // recompute bbox
251
+ this.bbox = this.bbox.grownToPoint(newPoint.pos, pointRadius);
252
+
253
+ if (this.currentCurve === null) {
254
+ const p1 = lastPoint.pos;
255
+ const p2 = lastPoint.pos.plus(this.lastExitingVec ?? Vec2.unitX);
256
+ const p3 = newPoint.pos;
257
+
258
+ // Quadratic Bézier curve
259
+ this.currentCurve = new Bezier(
260
+ p1.xy, p2.xy, p3.xy
261
+ );
262
+ this.curveStartWidth = lastPoint.width / 2;
263
+ console.assert(!isNaN(p1.magnitude()) && !isNaN(p2.magnitude()) && !isNaN(p3.magnitude()), 'Expected !NaN');
264
+ }
265
+
266
+ let enteringVec = this.lastExitingVec;
267
+ if (!enteringVec) {
268
+ let sampleIdx = Math.ceil(this.buffer.length / 3);
269
+ if (sampleIdx === 0) {
270
+ sampleIdx = this.buffer.length - 1;
271
+ }
272
+
273
+ enteringVec = this.buffer[sampleIdx].minus(this.buffer[0]);
274
+ }
275
+
276
+ let exitingVec = this.computeExitingVec();
277
+
278
+ // Find the intersection between the entering vector and the exiting vector
279
+ const maxRelativeLength = 2;
280
+ const segmentStart = this.buffer[0];
281
+ const segmentEnd = newPoint.pos;
282
+ const startEndDist = segmentEnd.minus(segmentStart).magnitude();
283
+ const maxControlPointDist = maxRelativeLength * startEndDist;
284
+
285
+ // Exit in cases where we would divide by zero
286
+ if (maxControlPointDist === 0 || exitingVec.magnitude() === 0 || isNaN(exitingVec.magnitude())) {
287
+ return;
288
+ }
289
+
290
+ console.assert(!isNaN(enteringVec.magnitude()));
291
+
292
+ enteringVec = enteringVec.normalized();
293
+ exitingVec = exitingVec.normalized();
294
+
295
+ console.assert(!isNaN(enteringVec.magnitude()));
296
+
297
+ const lineFromStart = new LineSegment2(
298
+ segmentStart,
299
+ segmentStart.plus(enteringVec.times(maxControlPointDist))
300
+ );
301
+ const lineFromEnd = new LineSegment2(
302
+ segmentEnd.minus(exitingVec.times(maxControlPointDist)),
303
+ segmentEnd
304
+ );
305
+ const intersection = lineFromEnd.intersection(lineFromStart);
306
+
307
+ // Position the control point at this intersection
308
+ let controlPoint: Point2;
309
+ if (intersection) {
310
+ controlPoint = intersection.point;
311
+ } else {
312
+ // Position the control point closer to the first -- the connecting
313
+ // segment will be roughly a line.
314
+ controlPoint = segmentStart.plus(enteringVec.times(startEndDist / 4));
315
+ }
316
+
317
+ if (isNaN(controlPoint.magnitude()) || isNaN(segmentStart.magnitude())) {
318
+ console.error('controlPoint is NaN', intersection, 'Start:', segmentStart, 'End:', segmentEnd, 'in:', enteringVec, 'out:', exitingVec);
319
+ }
320
+
321
+ const prevCurve = this.currentCurve;
322
+ this.currentCurve = new Bezier(segmentStart.xy, controlPoint.xy, segmentEnd.xy);
323
+
324
+ if (isNaN(Vec2.ofXY(this.currentCurve.normal(0)).magnitude())) {
325
+ console.error('NaN normal at 0. Curve:', this.currentCurve);
326
+ this.currentCurve = prevCurve;
327
+ }
328
+
329
+ // Should we start making a new curve? Check whether all buffer points are within
330
+ // ±strokeWidth of the curve.
331
+ const curveMatchesPoints = (curve: Bezier): boolean => {
332
+ for (const point of this.buffer) {
333
+ const proj =
334
+ Vec2.ofXY(curve.project(point.xy));
335
+ const dist = proj.minus(point).magnitude();
336
+
337
+ const minFit = Math.max(
338
+ Math.min(this.curveStartWidth, this.curveEndWidth) / 2,
339
+ this.minFitAllowed
340
+ );
341
+ if (dist > minFit || dist > this.maxFitAllowed) {
342
+ return false;
343
+ }
344
+ }
345
+ return true;
346
+ };
347
+
348
+ if (this.buffer.length > 3) {
349
+ if (!curveMatchesPoints(this.currentCurve)) {
350
+ // Use a curve that better fits the points
351
+ this.currentCurve = prevCurve;
352
+ this.curveEndWidth = prevEndWidth;
353
+
354
+ // Reset the last point -- the current point was not added to the curve.
355
+ this.lastPoint = lastPoint;
356
+
357
+ this.finalizeCurrentCurve();
358
+ return;
359
+ }
360
+ }
361
+ }
362
+ }
@@ -0,0 +1,61 @@
1
+ import Editor from './Editor';
2
+ import Command from './commands/Command';
3
+ import { EditorEventType } from './types';
4
+
5
+ type AnnounceRedoCallback = (command: Command)=>void;
6
+ type AnnounceUndoCallback = (command: Command)=>void;
7
+
8
+ class UndoRedoHistory {
9
+ private undoStack: Command[];
10
+ private redoStack: Command[];
11
+
12
+ public constructor(
13
+ private readonly editor: Editor,
14
+ private announceRedoCallback: AnnounceRedoCallback,
15
+ private announceUndoCallback: AnnounceUndoCallback,
16
+ ) {
17
+ this.undoStack = [];
18
+ this.redoStack = [];
19
+ }
20
+
21
+ private fireUpdateEvent() {
22
+ this.editor.notifier.dispatch(EditorEventType.UndoRedoStackUpdated, {
23
+ kind: EditorEventType.UndoRedoStackUpdated,
24
+ undoStackSize: this.undoStack.length,
25
+ redoStackSize: this.redoStack.length,
26
+ });
27
+ }
28
+
29
+ // Adds the given command to this and applies it to the editor.
30
+ public push(command: Command, apply: boolean = true) {
31
+ if (apply) {
32
+ command.apply(this.editor);
33
+ }
34
+ this.undoStack.push(command);
35
+ this.redoStack = [];
36
+ this.fireUpdateEvent();
37
+ }
38
+
39
+ // Remove the last command from this' undo stack and apply it.
40
+ public undo() {
41
+ const command = this.undoStack.pop();
42
+ if (command) {
43
+ this.redoStack.push(command);
44
+ command.unapply(this.editor);
45
+ this.announceUndoCallback(command);
46
+ }
47
+ this.fireUpdateEvent();
48
+ }
49
+
50
+ public redo() {
51
+ const command = this.redoStack.pop();
52
+ if (command) {
53
+ this.undoStack.push(command);
54
+ command.apply(this.editor);
55
+ this.announceRedoCallback(command);
56
+ }
57
+ this.fireUpdateEvent();
58
+ }
59
+ }
60
+
61
+ export default UndoRedoHistory;