js-draw 0.7.1 → 0.8.0

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 (49) hide show
  1. package/.github/ISSUE_TEMPLATE/translation.md +1 -0
  2. package/CHANGELOG.md +10 -0
  3. package/CONTRIBUTING.md +75 -0
  4. package/dist/bundle.js +1 -1
  5. package/dist/src/SVGLoader.js +1 -0
  6. package/dist/src/components/Stroke.js +10 -3
  7. package/dist/src/components/builders/FreehandLineBuilder.d.ts +10 -23
  8. package/dist/src/components/builders/FreehandLineBuilder.js +70 -396
  9. package/dist/src/components/builders/PressureSensitiveFreehandLineBuilder.d.ts +36 -0
  10. package/dist/src/components/builders/PressureSensitiveFreehandLineBuilder.js +339 -0
  11. package/dist/src/components/lib.d.ts +2 -0
  12. package/dist/src/components/lib.js +2 -0
  13. package/dist/src/components/util/StrokeSmoother.d.ts +35 -0
  14. package/dist/src/components/util/StrokeSmoother.js +206 -0
  15. package/dist/src/math/Mat33.d.ts +2 -0
  16. package/dist/src/math/Mat33.js +4 -0
  17. package/dist/src/math/Path.d.ts +2 -0
  18. package/dist/src/math/Path.js +39 -0
  19. package/dist/src/rendering/renderers/CanvasRenderer.js +2 -0
  20. package/dist/src/rendering/renderers/SVGRenderer.d.ts +2 -0
  21. package/dist/src/rendering/renderers/SVGRenderer.js +39 -7
  22. package/dist/src/toolbar/localization.d.ts +1 -0
  23. package/dist/src/toolbar/localization.js +1 -0
  24. package/dist/src/toolbar/widgets/PenToolWidget.js +6 -1
  25. package/dist/src/tools/Pen.d.ts +2 -2
  26. package/dist/src/tools/Pen.js +2 -2
  27. package/dist/src/tools/SelectionTool/Selection.d.ts +1 -0
  28. package/dist/src/tools/SelectionTool/Selection.js +8 -1
  29. package/dist/src/tools/TextTool.js +4 -2
  30. package/dist/src/tools/ToolController.js +2 -1
  31. package/package.json +1 -1
  32. package/src/SVGLoader.ts +1 -0
  33. package/src/components/Stroke.ts +16 -3
  34. package/src/components/builders/FreehandLineBuilder.ts +54 -495
  35. package/src/components/builders/PressureSensitiveFreehandLineBuilder.ts +454 -0
  36. package/src/components/lib.ts +3 -1
  37. package/src/components/util/StrokeSmoother.ts +290 -0
  38. package/src/math/Mat33.ts +5 -0
  39. package/src/math/Path.test.ts +25 -0
  40. package/src/math/Path.ts +45 -0
  41. package/src/rendering/renderers/CanvasRenderer.ts +2 -0
  42. package/src/rendering/renderers/SVGRenderer.ts +47 -7
  43. package/src/toolbar/localization.ts +2 -0
  44. package/src/toolbar/widgets/PenToolWidget.ts +6 -1
  45. package/src/tools/Pen.test.ts +2 -2
  46. package/src/tools/Pen.ts +1 -1
  47. package/src/tools/SelectionTool/Selection.ts +10 -1
  48. package/src/tools/TextTool.ts +5 -2
  49. package/src/tools/ToolController.ts +2 -1
@@ -0,0 +1,290 @@
1
+ import { Bezier } from 'bezier-js';
2
+ import { Point2, Vec2 } from '../../math/Vec2';
3
+ import Rect2 from '../../math/Rect2';
4
+ import LineSegment2 from '../../math/LineSegment2';
5
+ import { StrokeDataPoint } from '../../types';
6
+
7
+ export interface Curve {
8
+ startPoint: Vec2;
9
+ startWidth: number;
10
+
11
+ controlPoint: Vec2;
12
+
13
+ endWidth: number;
14
+ endPoint: Vec2;
15
+ }
16
+
17
+ type OnCurveAddedCallback = (curve: Curve|null)=>void;
18
+
19
+ // Handles stroke smoothing
20
+ export class StrokeSmoother {
21
+ private isFirstSegment: boolean = true;
22
+
23
+ private buffer: Point2[];
24
+ private lastPoint: StrokeDataPoint;
25
+ private lastExitingVec: Vec2|null = null;
26
+ private currentCurve: Bezier|null = null;
27
+ private curveStartWidth: number;
28
+ private curveEndWidth: number;
29
+
30
+ // Stroke smoothing and tangent approximation
31
+ private momentum: Vec2;
32
+ private bbox: Rect2;
33
+
34
+ public constructor(
35
+ private startPoint: StrokeDataPoint,
36
+
37
+ // Maximum distance from the actual curve (irrespective of stroke width)
38
+ // for which a point is considered 'part of the curve'.
39
+ // Note that the maximum will be smaller if the stroke width is less than
40
+ // [maxFitAllowed].
41
+ private minFitAllowed: number,
42
+ private maxFitAllowed: number,
43
+
44
+ private onCurveAdded: OnCurveAddedCallback,
45
+ ) {
46
+ this.lastPoint = this.startPoint;
47
+
48
+ this.buffer = [this.startPoint.pos];
49
+ this.momentum = Vec2.zero;
50
+ this.currentCurve = null;
51
+ this.curveStartWidth = startPoint.width;
52
+
53
+ this.bbox = new Rect2(this.startPoint.pos.x, this.startPoint.pos.y, 0, 0);
54
+ }
55
+
56
+ public getBBox(): Rect2 {
57
+ return this.bbox;
58
+ }
59
+
60
+ public preview(): Curve|null {
61
+ if (!this.currentCurve) {
62
+ return null;
63
+ }
64
+
65
+ return this.currentSegmentToPath();
66
+ }
67
+
68
+ // Returns the distance between the start, control, and end points of the curve.
69
+ private approxCurrentCurveLength() {
70
+ if (!this.currentCurve) {
71
+ return 0;
72
+ }
73
+ const startPt = Vec2.ofXY(this.currentCurve.points[0]);
74
+ const controlPt = Vec2.ofXY(this.currentCurve.points[1]);
75
+ const endPt = Vec2.ofXY(this.currentCurve.points[2]);
76
+ const toControlDist = startPt.minus(controlPt).length();
77
+ const toEndDist = endPt.minus(controlPt).length();
78
+ return toControlDist + toEndDist;
79
+ }
80
+
81
+ public finalizeCurrentCurve() {
82
+ // Case where no points have been added
83
+ if (!this.currentCurve) {
84
+ return;
85
+ }
86
+
87
+ this.onCurveAdded(this.currentSegmentToPath());
88
+
89
+ const lastPoint = this.buffer[this.buffer.length - 1];
90
+ this.lastExitingVec = Vec2.ofXY(
91
+ this.currentCurve.points[2]
92
+ ).minus(Vec2.ofXY(this.currentCurve.points[1]));
93
+ console.assert(this.lastExitingVec.magnitude() !== 0, 'lastExitingVec has zero length!');
94
+
95
+ // Use the last two points to start a new curve (the last point isn't used
96
+ // in the current curve and we want connected curves to share end points)
97
+ this.buffer = [
98
+ this.buffer[this.buffer.length - 2], lastPoint,
99
+ ];
100
+ this.currentCurve = null;
101
+ }
102
+
103
+ // Returns [upper curve, connector, lower curve]
104
+ private currentSegmentToPath() {
105
+ if (this.currentCurve == null) {
106
+ throw new Error('Invalid State: currentCurve is null!');
107
+ }
108
+
109
+ const startVec = Vec2.ofXY(this.currentCurve.normal(0)).normalized();
110
+
111
+ if (!isFinite(startVec.magnitude())) {
112
+ throw new Error(`startVec(${startVec}) is NaN or ∞`);
113
+ }
114
+
115
+ const startPt = Vec2.ofXY(this.currentCurve.get(0));
116
+ const endPt = Vec2.ofXY(this.currentCurve.get(1));
117
+ const controlPoint = Vec2.ofXY(this.currentCurve.points[1]);
118
+
119
+ return {
120
+ startPoint: startPt,
121
+ controlPoint,
122
+ endPoint: endPt,
123
+ startWidth: this.curveStartWidth,
124
+ endWidth: this.curveEndWidth,
125
+ };
126
+ }
127
+
128
+ // Compute the direction of the velocity at the end of this.buffer
129
+ private computeExitingVec(): Vec2 {
130
+ return this.momentum.normalized().times(this.lastPoint.width / 2);
131
+ }
132
+
133
+ public addPoint(newPoint: StrokeDataPoint) {
134
+ if (this.lastPoint) {
135
+ // Ignore points that are identical
136
+ const fuzzEq = 1e-10;
137
+ const deltaTime = newPoint.time - this.lastPoint.time;
138
+ if (newPoint.pos.eq(this.lastPoint.pos, fuzzEq) || deltaTime === 0) {
139
+ return;
140
+ } else if (isNaN(newPoint.pos.magnitude())) {
141
+ console.warn('Discarding NaN point.', newPoint);
142
+ return;
143
+ }
144
+
145
+ const threshold = Math.min(this.lastPoint.width, newPoint.width) / 3;
146
+ const shouldSnapToInitial = this.startPoint.pos.minus(newPoint.pos).magnitude() < threshold
147
+ && this.isFirstSegment;
148
+
149
+ // Snap to the starting point if the stroke is contained within a small ball centered
150
+ // at the starting point.
151
+ // This allows us to create a circle/dot at the start of the stroke.
152
+ if (shouldSnapToInitial) {
153
+ return;
154
+ }
155
+
156
+ const velocity = newPoint.pos.minus(this.lastPoint.pos).times(1 / (deltaTime) * 1000);
157
+ this.momentum = this.momentum.lerp(velocity, 0.9);
158
+ }
159
+
160
+ const lastPoint = this.lastPoint ?? newPoint;
161
+ this.lastPoint = newPoint;
162
+
163
+ this.buffer.push(newPoint.pos);
164
+ const pointRadius = newPoint.width / 2;
165
+ const prevEndWidth = this.curveEndWidth;
166
+ this.curveEndWidth = pointRadius;
167
+
168
+ if (this.isFirstSegment) {
169
+ // The start of a curve often lacks accurate pressure information. Update it.
170
+ this.curveStartWidth = (this.curveStartWidth + pointRadius) / 2;
171
+ }
172
+
173
+ // recompute bbox
174
+ this.bbox = this.bbox.grownToPoint(newPoint.pos, pointRadius);
175
+
176
+ if (this.currentCurve === null) {
177
+ const p1 = lastPoint.pos;
178
+ const p2 = lastPoint.pos.plus(this.lastExitingVec ?? Vec2.unitX);
179
+ const p3 = newPoint.pos;
180
+
181
+ // Quadratic Bézier curve
182
+ this.currentCurve = new Bezier(
183
+ p1.xy, p2.xy, p3.xy
184
+ );
185
+ this.curveStartWidth = lastPoint.width / 2;
186
+ console.assert(!isNaN(p1.magnitude()) && !isNaN(p2.magnitude()) && !isNaN(p3.magnitude()), 'Expected !NaN');
187
+ }
188
+
189
+ // If there isn't an entering vector (e.g. because this.isFirstCurve), approximate it.
190
+ let enteringVec = this.lastExitingVec;
191
+ if (!enteringVec) {
192
+ let sampleIdx = Math.ceil(this.buffer.length / 2);
193
+ if (sampleIdx === 0 || sampleIdx >= this.buffer.length) {
194
+ sampleIdx = this.buffer.length - 1;
195
+ }
196
+
197
+ enteringVec = this.buffer[sampleIdx].minus(this.buffer[0]);
198
+ }
199
+
200
+ let exitingVec = this.computeExitingVec();
201
+
202
+ // Find the intersection between the entering vector and the exiting vector
203
+ const maxRelativeLength = 2.2;
204
+ const segmentStart = this.buffer[0];
205
+ const segmentEnd = newPoint.pos;
206
+ const startEndDist = segmentEnd.minus(segmentStart).magnitude();
207
+ const maxControlPointDist = maxRelativeLength * startEndDist;
208
+
209
+ // Exit in cases where we would divide by zero
210
+ if (maxControlPointDist === 0 || exitingVec.magnitude() === 0 || !isFinite(exitingVec.magnitude())) {
211
+ return;
212
+ }
213
+
214
+ console.assert(isFinite(enteringVec.magnitude()), 'Pre-normalized enteringVec has NaN or ∞ magnitude!');
215
+
216
+ enteringVec = enteringVec.normalized();
217
+ exitingVec = exitingVec.normalized();
218
+
219
+ console.assert(isFinite(enteringVec.magnitude()), 'Normalized enteringVec has NaN or ∞ magnitude!');
220
+
221
+ const lineFromStart = new LineSegment2(
222
+ segmentStart,
223
+ segmentStart.plus(enteringVec.times(maxControlPointDist))
224
+ );
225
+ const lineFromEnd = new LineSegment2(
226
+ segmentEnd.minus(exitingVec.times(maxControlPointDist)),
227
+ segmentEnd
228
+ );
229
+ const intersection = lineFromEnd.intersection(lineFromStart);
230
+
231
+ // Position the control point at this intersection
232
+ let controlPoint: Point2|null = null;
233
+ if (intersection) {
234
+ controlPoint = intersection.point;
235
+ }
236
+
237
+ // No intersection or the intersection is one of the end points?
238
+ if (!controlPoint || segmentStart.eq(controlPoint) || segmentEnd.eq(controlPoint)) {
239
+ // Position the control point closer to the first -- the connecting
240
+ // segment will be roughly a line.
241
+ controlPoint = segmentStart.plus(enteringVec.times(startEndDist / 3));
242
+ }
243
+
244
+ console.assert(!segmentStart.eq(controlPoint, 1e-11), 'Start and control points are equal!');
245
+ console.assert(!controlPoint.eq(segmentEnd, 1e-11), 'Control and end points are equal!');
246
+
247
+ const prevCurve = this.currentCurve;
248
+ this.currentCurve = new Bezier(segmentStart.xy, controlPoint.xy, segmentEnd.xy);
249
+
250
+ if (isNaN(Vec2.ofXY(this.currentCurve.normal(0)).magnitude())) {
251
+ console.error('NaN normal at 0. Curve:', this.currentCurve);
252
+ this.currentCurve = prevCurve;
253
+ }
254
+
255
+ // Should we start making a new curve? Check whether all buffer points are within
256
+ // ±strokeWidth of the curve.
257
+ const curveMatchesPoints = (curve: Bezier): boolean => {
258
+ for (const point of this.buffer) {
259
+ const proj =
260
+ Vec2.ofXY(curve.project(point.xy));
261
+ const dist = proj.minus(point).magnitude();
262
+
263
+ const minFit = Math.max(
264
+ Math.min(this.curveStartWidth, this.curveEndWidth) / 3,
265
+ this.minFitAllowed
266
+ );
267
+ if (dist > minFit || dist > this.maxFitAllowed) {
268
+ return false;
269
+ }
270
+ }
271
+ return true;
272
+ };
273
+
274
+ if (this.buffer.length > 3 && this.approxCurrentCurveLength() > this.curveStartWidth) {
275
+ if (!curveMatchesPoints(this.currentCurve)) {
276
+ // Use a curve that better fits the points
277
+ this.currentCurve = prevCurve;
278
+ this.curveEndWidth = prevEndWidth;
279
+
280
+ // Reset the last point -- the current point was not added to the curve.
281
+ this.lastPoint = lastPoint;
282
+
283
+ this.finalizeCurrentCurve();
284
+ return;
285
+ }
286
+ }
287
+ }
288
+ }
289
+
290
+ export default StrokeSmoother;
package/src/math/Mat33.ts CHANGED
@@ -274,6 +274,11 @@ export default class Mat33 {
274
274
  );
275
275
  }
276
276
 
277
+ /** Estimate the scale factor of this matrix (based on the first row). */
278
+ public getScaleFactor() {
279
+ return Math.hypot(this.a1, this.a2);
280
+ }
281
+
277
282
  /** Constructs a 3x3 translation matrix (for translating `Vec2`s) */
278
283
  public static translation(amount: Vec2): Mat33 {
279
284
  // When transforming Vec2s by a 3x3 matrix, we give the input
@@ -146,4 +146,29 @@ describe('Path', () => {
146
146
  ).toBe(false);
147
147
  });
148
148
  });
149
+
150
+ describe('roughlyIntersects', () => {
151
+ it('should consider parts outside bbox of individual parts of a line as not intersecting', () => {
152
+ const path = Path.fromString(`
153
+ M10,10
154
+ L20,20
155
+ L100,21
156
+ `);
157
+ expect(
158
+ path.roughlyIntersects(new Rect2(0, 0, 50, 50))
159
+ ).toBe(true);
160
+ expect(
161
+ path.roughlyIntersects(new Rect2(0, 0, 5, 5))
162
+ ).toBe(false);
163
+ expect(
164
+ path.roughlyIntersects(new Rect2(8, 22, 1, 1))
165
+ ).toBe(false);
166
+ expect(
167
+ path.roughlyIntersects(new Rect2(21, 11, 1, 1))
168
+ ).toBe(false);
169
+ expect(
170
+ path.roughlyIntersects(new Rect2(50, 19, 1, 2))
171
+ ).toBe(true);
172
+ });
173
+ });
149
174
  });
package/src/math/Path.ts CHANGED
@@ -267,6 +267,51 @@ export default class Path {
267
267
  ]);
268
268
  }
269
269
 
270
+ private getEndPoint() {
271
+ if (this.parts.length === 0) {
272
+ return this.startPoint;
273
+ }
274
+ const lastPart = this.parts[this.parts.length - 1];
275
+ if (lastPart.kind === PathCommandType.QuadraticBezierTo || lastPart.kind === PathCommandType.CubicBezierTo) {
276
+ return lastPart.endPoint;
277
+ } else {
278
+ return lastPart.point;
279
+ }
280
+ }
281
+
282
+ public roughlyIntersects(rect: Rect2, strokeWidth: number = 0) {
283
+ if (this.parts.length === 0) {
284
+ return rect.containsPoint(this.startPoint);
285
+ }
286
+ const isClosed = this.startPoint.eq(this.getEndPoint());
287
+
288
+ if (isClosed && strokeWidth == 0) {
289
+ return this.closedRoughlyIntersects(rect);
290
+ }
291
+
292
+ if (rect.containsRect(this.bbox)) {
293
+ return true;
294
+ }
295
+
296
+ // Does the rectangle intersect the bounding boxes of any of this' parts?
297
+ let startPoint = this.startPoint;
298
+ for (const part of this.parts) {
299
+ const bbox = Path.computeBBoxForSegment(startPoint, part).grownBy(strokeWidth);
300
+
301
+ if (part.kind === PathCommandType.LineTo || part.kind === PathCommandType.MoveTo) {
302
+ startPoint = part.point;
303
+ } else {
304
+ startPoint = part.endPoint;
305
+ }
306
+
307
+ if (rect.intersects(bbox)) {
308
+ return true;
309
+ }
310
+ }
311
+
312
+ return false;
313
+ }
314
+
270
315
  // Treats this as a closed path and returns true if part of `rect` is roughly within
271
316
  // this path's interior.
272
317
  //
@@ -94,6 +94,8 @@ export default class CanvasRenderer extends AbstractRenderer {
94
94
  if (style.stroke) {
95
95
  this.ctx.strokeStyle = style.stroke.color.toHexString();
96
96
  this.ctx.lineWidth = this.getSizeOfCanvasPixelOnScreen() * style.stroke.width;
97
+ this.ctx.lineCap = 'round';
98
+ this.ctx.lineJoin = 'round';
97
99
  this.ctx.stroke();
98
100
  }
99
101
 
@@ -11,6 +11,8 @@ import Viewport from '../../Viewport';
11
11
  import RenderingStyle from '../RenderingStyle';
12
12
  import AbstractRenderer, { RenderableImage, RenderablePathSpec } from './AbstractRenderer';
13
13
 
14
+ export const renderedStylesheetId = 'js-draw-style-sheet';
15
+
14
16
  const svgNameSpace = 'http://www.w3.org/2000/svg';
15
17
  export default class SVGRenderer extends AbstractRenderer {
16
18
  private lastPathStyle: RenderingStyle|null = null;
@@ -23,6 +25,23 @@ export default class SVGRenderer extends AbstractRenderer {
23
25
  public constructor(private elem: SVGSVGElement, viewport: Viewport, private sanitize: boolean = false) {
24
26
  super(viewport);
25
27
  this.clear();
28
+
29
+ this.addStyleSheet();
30
+ }
31
+
32
+ private addStyleSheet() {
33
+ if (!this.elem.querySelector(`#${renderedStylesheetId}`)) {
34
+ // Default to rounded strokes.
35
+ const styleSheet = document.createElementNS('http://www.w3.org/2000/svg', 'style');
36
+ styleSheet.innerHTML = `
37
+ path {
38
+ stroke-linecap: round;
39
+ stroke-linejoin: round;
40
+ }
41
+ `.replace(/\s+/g, '');
42
+ styleSheet.setAttribute('id', renderedStylesheetId);
43
+ this.elem.appendChild(styleSheet);
44
+ }
26
45
  }
27
46
 
28
47
  // Sets an attribute on the root SVG element.
@@ -99,11 +118,15 @@ export default class SVGRenderer extends AbstractRenderer {
99
118
  this.lastPathString.push(path.toString());
100
119
  }
101
120
 
102
- // Apply [elemTransform] to [elem].
103
- private transformFrom(elemTransform: Mat33, elem: SVGElement, inCanvasSpace: boolean = false) {
121
+ // Apply [elemTransform] to [elem]. Uses both a `matrix` and `.x`, `.y` properties if `setXY` is true.
122
+ // Otherwise, just uses a `matrix`.
123
+ private transformFrom(elemTransform: Mat33, elem: SVGElement, inCanvasSpace: boolean = false, setXY: boolean = true) {
104
124
  let transform = !inCanvasSpace ? this.getCanvasToScreenTransform().rightMul(elemTransform) : elemTransform;
105
125
  const translation = transform.transformVec2(Vec2.zero);
106
- transform = transform.rightMul(Mat33.translation(translation.times(-1)));
126
+
127
+ if (setXY) {
128
+ transform = transform.rightMul(Mat33.translation(translation.times(-1)));
129
+ }
107
130
 
108
131
  if (!transform.eq(Mat33.identity)) {
109
132
  elem.style.transform = `matrix(
@@ -115,8 +138,10 @@ export default class SVGRenderer extends AbstractRenderer {
115
138
  elem.style.transform = '';
116
139
  }
117
140
 
118
- elem.setAttribute('x', `${toRoundedString(translation.x)}`);
119
- elem.setAttribute('y', `${toRoundedString(translation.y)}`);
141
+ if (setXY) {
142
+ elem.setAttribute('x', `${toRoundedString(translation.x)}`);
143
+ elem.setAttribute('y', `${toRoundedString(translation.y)}`);
144
+ }
120
145
  }
121
146
 
122
147
  private textContainer: SVGTextElement|null = null;
@@ -140,7 +165,11 @@ export default class SVGRenderer extends AbstractRenderer {
140
165
  if (!this.textContainer) {
141
166
  const container = document.createElementNS(svgNameSpace, 'text');
142
167
  container.appendChild(document.createTextNode(text));
143
- this.transformFrom(transform, container, true);
168
+
169
+ // Don't set .x/.y properties (just use .style.transform).
170
+ // Child nodes aren't translated by .x/.y properties, but are by .style.transform.
171
+ const setXY = false;
172
+ this.transformFrom(transform, container, true, setXY);
144
173
  applyTextStyles(container, style);
145
174
 
146
175
  this.elem.appendChild(container);
@@ -154,8 +183,14 @@ export default class SVGRenderer extends AbstractRenderer {
154
183
  elem.appendChild(document.createTextNode(text));
155
184
  this.textContainer.appendChild(elem);
156
185
 
186
+ // Make .x/.y relative to the parent.
157
187
  transform = this.textContainerTransform!.inverse().rightMul(transform);
158
- this.transformFrom(transform, elem, true);
188
+
189
+ // .style.transform does nothing to tspan elements. As such, we need to set x/y:
190
+ const translation = transform.transformVec2(Vec2.zero);
191
+ elem.setAttribute('x', `${toRoundedString(translation.x)}`);
192
+ elem.setAttribute('y', `${toRoundedString(translation.y)}`);
193
+
159
194
  applyTextStyles(elem, style);
160
195
  }
161
196
  }
@@ -236,6 +271,11 @@ export default class SVGRenderer extends AbstractRenderer {
236
271
  return;
237
272
  }
238
273
 
274
+ // Don't add multiple copies of the default stylesheet.
275
+ if (elem.tagName.toLowerCase() === 'style' && elem.getAttribute('id') === renderedStylesheetId) {
276
+ return;
277
+ }
278
+
239
279
  this.elem.appendChild(elem.cloneNode(true));
240
280
  }
241
281
 
@@ -8,6 +8,7 @@ export interface ToolbarLocalization {
8
8
  linePen: string;
9
9
  arrowPen: string;
10
10
  freehandPen: string;
11
+ pressureSensitiveFreehandPen: string;
11
12
  selectObjectType: string;
12
13
  colorLabel: string;
13
14
  pen: string;
@@ -55,6 +56,7 @@ export const defaultToolbarLocalization: ToolbarLocalization = {
55
56
  touchPanning: 'Touchscreen panning',
56
57
 
57
58
  freehandPen: 'Freehand',
59
+ pressureSensitiveFreehandPen: 'Freehand (pressure sensitive)',
58
60
  arrowPen: 'Arrow',
59
61
  linePen: 'Line',
60
62
  outlinedRectanglePen: 'Outlined rectangle',
@@ -1,5 +1,6 @@
1
1
  import { makeArrowBuilder } from '../../components/builders/ArrowBuilder';
2
2
  import { makeFreehandLineBuilder } from '../../components/builders/FreehandLineBuilder';
3
+ import { makePressureSensitiveFreehandLineBuilder } from '../../components/builders/PressureSensitiveFreehandLineBuilder';
3
4
  import { makeLineBuilder } from '../../components/builders/LineBuilder';
4
5
  import { makeFilledRectangleBuilder, makeOutlinedRectangleBuilder } from '../../components/builders/RectangleBuilder';
5
6
  import { ComponentBuilderFactory } from '../../components/builders/types';
@@ -31,6 +32,10 @@ export default class PenToolWidget extends BaseToolWidget {
31
32
 
32
33
  // Default pen types
33
34
  this.penTypes = [
35
+ {
36
+ name: localization.pressureSensitiveFreehandPen,
37
+ factory: makePressureSensitiveFreehandLineBuilder,
38
+ },
34
39
  {
35
40
  name: localization.freehandPen,
36
41
  factory: makeFreehandLineBuilder,
@@ -72,7 +77,7 @@ export default class PenToolWidget extends BaseToolWidget {
72
77
 
73
78
  protected createIcon(): Element {
74
79
  const strokeFactory = this.tool.getStrokeFactory();
75
- if (strokeFactory === makeFreehandLineBuilder) {
80
+ if (strokeFactory === makeFreehandLineBuilder || strokeFactory === makePressureSensitiveFreehandLineBuilder) {
76
81
  // Use a square-root scale to prevent the pen's tip from overflowing.
77
82
  const scale = Math.round(Math.sqrt(this.tool.getThickness()) * 4);
78
83
  const color = this.tool.getColor();
@@ -144,7 +144,7 @@ describe('Pen', () => {
144
144
  const elems = editor.image.getElementsIntersectingRegion(new Rect2(0, 0, 1000, 1000));
145
145
  expect(elems).toHaveLength(1);
146
146
 
147
- expect(elems[0].getBBox().topLeft).objEq(Vec2.of(420, 24), 8); // ± 8
147
+ expect(elems[0].getBBox().topLeft).objEq(Vec2.of(420, 24), 32); // ± 32
148
148
  expect(elems[0].getBBox().bottomRight).objEq(Vec2.of(420, 340), 25); // ± 25
149
149
  });
150
- });
150
+ });
package/src/tools/Pen.ts CHANGED
@@ -14,13 +14,13 @@ export interface PenStyle {
14
14
 
15
15
  export default class Pen extends BaseTool {
16
16
  protected builder: ComponentBuilder|null = null;
17
- protected builderFactory: ComponentBuilderFactory = makeFreehandLineBuilder;
18
17
  private lastPoint: StrokeDataPoint|null = null;
19
18
 
20
19
  public constructor(
21
20
  private editor: Editor,
22
21
  description: string,
23
22
  private style: PenStyle,
23
+ private builderFactory: ComponentBuilderFactory = makeFreehandLineBuilder,
24
24
  ) {
25
25
  super(editor.notifier, description);
26
26
  }
@@ -37,6 +37,8 @@ export default class Selection {
37
37
  private container: HTMLElement;
38
38
  private backgroundElem: HTMLElement;
39
39
 
40
+ private hasParent: boolean = true;
41
+
40
42
  public constructor(startPoint: Point2, private editor: Editor) {
41
43
  this.originalRegion = new Rect2(startPoint.x, startPoint.y, 0, 0);
42
44
  this.transformers = {
@@ -155,7 +157,7 @@ export default class Selection {
155
157
  public setTransform(transform: Mat33, preview: boolean = true) {
156
158
  this.transform = transform;
157
159
 
158
- if (preview) {
160
+ if (preview && this.hasParent) {
159
161
  this.previewTransformCmds();
160
162
  this.scrollTo();
161
163
  }
@@ -329,6 +331,11 @@ export default class Selection {
329
331
 
330
332
  // @internal
331
333
  public updateUI() {
334
+ // Don't update old selections.
335
+ if (!this.hasParent) {
336
+ return;
337
+ }
338
+
332
339
  // marginLeft, marginTop: Display relative to the top left of the selection overlay.
333
340
  // left, top don't work for this.
334
341
  this.backgroundElem.style.marginLeft = `${this.screenRegion.topLeft.x}px`;
@@ -428,6 +435,7 @@ export default class Selection {
428
435
  }
429
436
 
430
437
  elem.appendChild(this.container);
438
+ this.hasParent = true;
431
439
  }
432
440
 
433
441
  public setToPoint(point: Point2) {
@@ -440,6 +448,7 @@ export default class Selection {
440
448
  this.container.remove();
441
449
  }
442
450
  this.originalRegion = Rect2.empty;
451
+ this.hasParent = false;
443
452
  }
444
453
 
445
454
  public setSelectedObjects(objects: AbstractComponent[], bbox: Rect2) {
@@ -148,7 +148,7 @@ export default class TextTool extends BaseTool {
148
148
  this.textInputElem = document.createElement('textarea');
149
149
  this.textInputElem.value = initialText;
150
150
  this.textInputElem.style.display = 'inline-block';
151
- this.textTargetPosition = textCanvasPos;
151
+ this.textTargetPosition = this.editor.viewport.roundPoint(textCanvasPos);
152
152
  this.textRotation = -this.editor.viewport.getRotationAngle();
153
153
  this.textScale = Vec2.of(1, 1).times(this.editor.viewport.getSizeOfPixelOnCanvas());
154
154
  this.updateTextInput();
@@ -210,6 +210,9 @@ export default class TextTool extends BaseTool {
210
210
  const targetNodes = this.editor.image.getElementsIntersectingRegion(testRegion);
211
211
  const targetTextNodes = targetNodes.filter(node => node instanceof TextComponent) as TextComponent[];
212
212
 
213
+ // End any TextNodes we're currently editing.
214
+ this.flushInput();
215
+
213
216
  if (targetTextNodes.length > 0) {
214
217
  const targetNode = targetTextNodes[targetTextNodes.length - 1];
215
218
  this.setTextStyle(targetNode.getTextStyle());
@@ -290,7 +293,7 @@ export default class TextTool extends BaseTool {
290
293
 
291
294
  private setTextStyle(style: TextStyle) {
292
295
  // Copy the style — we may change parts of it.
293
- this.textStyle = {...style};
296
+ this.textStyle = { ...style, renderingStyle: { ...style.renderingStyle } };
294
297
  this.dispatchUpdateEvent();
295
298
  }
296
299
  }
@@ -14,6 +14,7 @@ import PipetteTool from './PipetteTool';
14
14
  import ToolSwitcherShortcut from './ToolSwitcherShortcut';
15
15
  import PasteHandler from './PasteHandler';
16
16
  import ToolbarShortcutHandler from './ToolbarShortcutHandler';
17
+ import { makePressureSensitiveFreehandLineBuilder } from '../components/builders/PressureSensitiveFreehandLineBuilder';
17
18
 
18
19
  export default class ToolController {
19
20
  private tools: BaseTool[];
@@ -34,7 +35,7 @@ export default class ToolController {
34
35
  new Pen(editor, localization.penTool(2), { color: Color4.clay, thickness: 4 }),
35
36
 
36
37
  // Highlighter-like pen with width=64
37
- new Pen(editor, localization.penTool(3), { color: Color4.ofRGBA(1, 1, 0, 0.5), thickness: 64 }),
38
+ new Pen(editor, localization.penTool(3), { color: Color4.ofRGBA(1, 1, 0, 0.5), thickness: 64 }, makePressureSensitiveFreehandLineBuilder),
38
39
 
39
40
  new Eraser(editor, localization.eraserTool),
40
41
  new SelectionTool(editor, localization.selectionTool),