js-draw 0.6.0 → 0.7.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 (39) hide show
  1. package/.firebase/hosting.ZG9jcw.cache +338 -0
  2. package/.github/ISSUE_TEMPLATE/translation.md +1 -1
  3. package/CHANGELOG.md +8 -0
  4. package/dist/bundle.js +1 -1
  5. package/dist/src/Editor.d.ts +0 -1
  6. package/dist/src/Editor.js +4 -3
  7. package/dist/src/SVGLoader.js +2 -2
  8. package/dist/src/components/Stroke.js +1 -0
  9. package/dist/src/components/Text.d.ts +10 -5
  10. package/dist/src/components/Text.js +49 -15
  11. package/dist/src/components/builders/FreehandLineBuilder.d.ts +9 -2
  12. package/dist/src/components/builders/FreehandLineBuilder.js +127 -28
  13. package/dist/src/components/lib.d.ts +2 -2
  14. package/dist/src/components/lib.js +2 -2
  15. package/dist/src/rendering/renderers/CanvasRenderer.js +2 -2
  16. package/dist/src/rendering/renderers/SVGRenderer.d.ts +2 -0
  17. package/dist/src/rendering/renderers/SVGRenderer.js +49 -22
  18. package/dist/src/toolbar/IconProvider.d.ts +24 -18
  19. package/dist/src/toolbar/IconProvider.js +23 -21
  20. package/dist/src/toolbar/widgets/PenToolWidget.js +8 -5
  21. package/dist/src/tools/PasteHandler.js +2 -22
  22. package/dist/src/tools/TextTool.d.ts +4 -0
  23. package/dist/src/tools/TextTool.js +73 -15
  24. package/package.json +1 -1
  25. package/src/Editor.toSVG.test.ts +27 -0
  26. package/src/Editor.ts +4 -4
  27. package/src/SVGLoader.test.ts +20 -0
  28. package/src/SVGLoader.ts +4 -4
  29. package/src/components/Stroke.ts +1 -0
  30. package/src/components/Text.test.ts +3 -3
  31. package/src/components/Text.ts +62 -19
  32. package/src/components/builders/FreehandLineBuilder.ts +160 -32
  33. package/src/components/lib.ts +3 -3
  34. package/src/rendering/renderers/CanvasRenderer.ts +2 -2
  35. package/src/rendering/renderers/SVGRenderer.ts +50 -24
  36. package/src/toolbar/IconProvider.ts +24 -20
  37. package/src/toolbar/widgets/PenToolWidget.ts +9 -5
  38. package/src/tools/PasteHandler.ts +2 -24
  39. package/src/tools/TextTool.ts +82 -17
@@ -1,6 +1,7 @@
1
1
  import LineSegment2 from '../math/LineSegment2';
2
2
  import Mat33, { Mat33Array } from '../math/Mat33';
3
3
  import Rect2 from '../math/Rect2';
4
+ import { Vec2 } from '../math/Vec2';
4
5
  import AbstractRenderer from '../rendering/renderers/AbstractRenderer';
5
6
  import RenderingStyle, { styleFromJSON, styleToJSON } from '../rendering/RenderingStyle';
6
7
  import AbstractComponent from './AbstractComponent';
@@ -15,16 +16,23 @@ export interface TextStyle {
15
16
  }
16
17
 
17
18
  const componentTypeId = 'text';
18
- export default class Text extends AbstractComponent {
19
+ export default class TextComponent extends AbstractComponent {
19
20
  protected contentBBox: Rect2;
20
21
 
21
22
  public constructor(
22
- protected readonly textObjects: Array<string|Text>,
23
+ protected readonly textObjects: Array<string|TextComponent>,
23
24
  private transform: Mat33,
24
- private readonly style: TextStyle,
25
+ private style: TextStyle,
25
26
  ) {
26
27
  super(componentTypeId);
27
28
  this.recomputeBBox();
29
+
30
+ // If this has no direct children, choose a style representative of this' content
31
+ // (useful for estimating the style of the TextComponent).
32
+ const hasDirectContent = textObjects.some(obj => typeof obj === 'string');
33
+ if (!hasDirectContent && textObjects.length > 0) {
34
+ this.style = (textObjects[0] as TextComponent).getTextStyle();
35
+ }
28
36
  }
29
37
 
30
38
  public static applyTextStyles(ctx: CanvasRenderingContext2D, style: TextStyle) {
@@ -55,13 +63,13 @@ export default class Text extends AbstractComponent {
55
63
 
56
64
  // Returns the bounding box of `text`. This is approximate if no Canvas is available.
57
65
  private static getTextDimens(text: string, style: TextStyle): Rect2 {
58
- Text.textMeasuringCtx ??= document.createElement('canvas').getContext('2d') ?? null;
59
- if (!Text.textMeasuringCtx) {
66
+ TextComponent.textMeasuringCtx ??= document.createElement('canvas').getContext('2d') ?? null;
67
+ if (!TextComponent.textMeasuringCtx) {
60
68
  return this.estimateTextDimens(text, style);
61
69
  }
62
70
 
63
- const ctx = Text.textMeasuringCtx;
64
- Text.applyTextStyles(ctx, style);
71
+ const ctx = TextComponent.textMeasuringCtx;
72
+ TextComponent.applyTextStyles(ctx, style);
65
73
 
66
74
  const measure = ctx.measureText(text);
67
75
 
@@ -71,9 +79,9 @@ export default class Text extends AbstractComponent {
71
79
  return new Rect2(0, textY, measure.width, textHeight);
72
80
  }
73
81
 
74
- private computeBBoxOfPart(part: string|Text) {
82
+ private computeBBoxOfPart(part: string|TextComponent) {
75
83
  if (typeof part === 'string') {
76
- const textBBox = Text.getTextDimens(part, this.style);
84
+ const textBBox = TextComponent.getTextDimens(part, this.style);
77
85
  return textBBox.transformedBoundingBox(this.transform);
78
86
  } else {
79
87
  const bbox = part.contentBBox.transformedBoundingBox(this.transform);
@@ -93,19 +101,23 @@ export default class Text extends AbstractComponent {
93
101
  this.contentBBox = bbox ?? Rect2.empty;
94
102
  }
95
103
 
96
- public render(canvas: AbstractRenderer, _visibleRect?: Rect2): void {
104
+ private renderInternal(canvas: AbstractRenderer) {
97
105
  const cursor = this.transform;
98
106
 
99
- canvas.startObject(this.contentBBox);
100
107
  for (const textObject of this.textObjects) {
101
108
  if (typeof textObject === 'string') {
102
109
  canvas.drawText(textObject, cursor, this.style);
103
110
  } else {
104
111
  canvas.pushTransform(cursor);
105
- textObject.render(canvas);
112
+ textObject.renderInternal(canvas);
106
113
  canvas.popTransform();
107
114
  }
108
115
  }
116
+ }
117
+
118
+ public render(canvas: AbstractRenderer, _visibleRect?: Rect2): void {
119
+ canvas.startObject(this.contentBBox);
120
+ this.renderInternal(canvas);
109
121
  canvas.endObject(this.getLoadSaveData());
110
122
  }
111
123
 
@@ -119,7 +131,7 @@ export default class Text extends AbstractComponent {
119
131
 
120
132
  for (const subObject of this.textObjects) {
121
133
  if (typeof subObject === 'string') {
122
- const textBBox = Text.getTextDimens(subObject, this.style);
134
+ const textBBox = TextComponent.getTextDimens(subObject, this.style);
123
135
 
124
136
  // TODO: Use a better intersection check. Perhaps draw the text onto a CanvasElement and
125
137
  // use pixel-testing to check for intersection with its contour.
@@ -136,13 +148,25 @@ export default class Text extends AbstractComponent {
136
148
  return false;
137
149
  }
138
150
 
151
+ public getBaselinePos() {
152
+ return this.transform.transformVec2(Vec2.zero);
153
+ }
154
+
155
+ public getTextStyle() {
156
+ return this.style;
157
+ }
158
+
159
+ public getTransform(): Mat33 {
160
+ return this.transform;
161
+ }
162
+
139
163
  protected applyTransformation(affineTransfm: Mat33): void {
140
164
  this.transform = affineTransfm.rightMul(this.transform);
141
165
  this.recomputeBBox();
142
166
  }
143
167
 
144
168
  protected createClone(): AbstractComponent {
145
- return new Text(this.textObjects, this.transform, this.style);
169
+ return new TextComponent(this.textObjects, this.transform, this.style);
146
170
  }
147
171
 
148
172
  public getText() {
@@ -188,7 +212,7 @@ export default class Text extends AbstractComponent {
188
212
  };
189
213
  }
190
214
 
191
- public static deserializeFromString(json: any): Text {
215
+ public static deserializeFromString(json: any): TextComponent {
192
216
  const style: TextStyle = {
193
217
  renderingStyle: styleFromJSON(json.style.renderingStyle),
194
218
  size: json.style.size,
@@ -197,12 +221,12 @@ export default class Text extends AbstractComponent {
197
221
  fontFamily: json.style.fontFamily,
198
222
  };
199
223
 
200
- const textObjects: Array<string|Text> = json.textObjects.map((data: any) => {
224
+ const textObjects: Array<string|TextComponent> = json.textObjects.map((data: any) => {
201
225
  if ((data.text ?? null) !== null) {
202
226
  return data.text;
203
227
  }
204
228
 
205
- return Text.deserializeFromString(data.json);
229
+ return TextComponent.deserializeFromString(data.json);
206
230
  });
207
231
 
208
232
  json.transform = json.transform.filter((elem: any) => typeof elem === 'number');
@@ -213,8 +237,27 @@ export default class Text extends AbstractComponent {
213
237
  const transformData = json.transform as Mat33Array;
214
238
  const transform = new Mat33(...transformData);
215
239
 
216
- return new Text(textObjects, transform, style);
240
+ return new TextComponent(textObjects, transform, style);
241
+ }
242
+
243
+ public static fromLines(lines: string[], transform: Mat33, style: TextStyle): AbstractComponent {
244
+ let lastComponent: TextComponent|null = null;
245
+ const components: TextComponent[] = [];
246
+
247
+ for (const line of lines) {
248
+ let position = Vec2.zero;
249
+ if (lastComponent) {
250
+ const lineMargin = Math.floor(style.size);
251
+ position = lastComponent.getBBox().bottomLeft.plus(Vec2.unitY.times(lineMargin));
252
+ }
253
+
254
+ const component = new TextComponent([ line ], Mat33.translation(position), style);
255
+ components.push(component);
256
+ lastComponent = component;
257
+ }
258
+
259
+ return new TextComponent(components, transform, style);
217
260
  }
218
261
  }
219
262
 
220
- AbstractComponent.registerComponent(componentTypeId, (data: string) => Text.deserializeFromString(data));
263
+ AbstractComponent.registerComponent(componentTypeId, (data: string) => TextComponent.deserializeFromString(data));
@@ -11,21 +11,24 @@ import { ComponentBuilder, ComponentBuilderFactory } from './types';
11
11
  import RenderingStyle from '../../rendering/RenderingStyle';
12
12
 
13
13
  export const makeFreehandLineBuilder: ComponentBuilderFactory = (initialPoint: StrokeDataPoint, viewport: Viewport) => {
14
- // Don't smooth if input is more than ± 7 pixels from the true curve, do smooth if
14
+ // Don't smooth if input is more than ± 3 pixels from the true curve, do smooth if
15
15
  // less than ±1 px from the curve.
16
- const maxSmoothingDist = viewport.getSizeOfPixelOnCanvas() * 7;
16
+ const maxSmoothingDist = viewport.getSizeOfPixelOnCanvas() * 3;
17
17
  const minSmoothingDist = viewport.getSizeOfPixelOnCanvas();
18
18
 
19
19
  return new FreehandLineBuilder(
20
- initialPoint, minSmoothingDist, maxSmoothingDist
20
+ initialPoint, minSmoothingDist, maxSmoothingDist, viewport
21
21
  );
22
22
  };
23
23
 
24
24
  type CurrentSegmentToPathResult = {
25
- upperCurve: QuadraticBezierPathCommand,
25
+ upperCurveCommand: QuadraticBezierPathCommand,
26
26
  lowerToUpperConnector: PathCommand,
27
27
  upperToLowerConnector: PathCommand,
28
- lowerCurve: QuadraticBezierPathCommand,
28
+ lowerCurveCommand: QuadraticBezierPathCommand,
29
+
30
+ upperCurve: Bezier,
31
+ lowerCurve: Bezier,
29
32
  };
30
33
 
31
34
  // Handles stroke smoothing and creates Strokes from user/stylus input.
@@ -47,8 +50,11 @@ export default class FreehandLineBuilder implements ComponentBuilder {
47
50
  // least recent edge.
48
51
  // The lowerSegments form a path that goes from the least recent edge to the most
49
52
  // recent edge.
50
- private upperSegments: QuadraticBezierPathCommand[];
51
- private lowerSegments: QuadraticBezierPathCommand[];
53
+ private upperSegments: PathCommand[];
54
+ private lowerSegments: PathCommand[];
55
+ private lastUpperBezier: Bezier|null = null;
56
+ private lastLowerBezier: Bezier|null = null;
57
+ private parts: RenderablePathSpec[] = [];
52
58
 
53
59
  private buffer: Point2[];
54
60
  private lastPoint: StrokeDataPoint;
@@ -69,7 +75,9 @@ export default class FreehandLineBuilder implements ComponentBuilder {
69
75
  // Note that the maximum will be smaller if the stroke width is less than
70
76
  // [maxFitAllowed].
71
77
  private minFitAllowed: number,
72
- private maxFitAllowed: number
78
+ private maxFitAllowed: number,
79
+
80
+ private viewport: Viewport,
73
81
  ) {
74
82
  this.lastPoint = this.startPoint;
75
83
  this.upperSegments = [];
@@ -93,15 +101,19 @@ export default class FreehandLineBuilder implements ComponentBuilder {
93
101
  };
94
102
  }
95
103
 
96
- private previewPath(): RenderablePathSpec|null {
97
- let upperPath: QuadraticBezierPathCommand[];
98
- let lowerPath: QuadraticBezierPathCommand[];
104
+ private previewCurrentPath(): RenderablePathSpec|null {
105
+ const upperPath = this.upperSegments.slice();
106
+ const lowerPath = this.lowerSegments.slice();
99
107
  let lowerToUpperCap: PathCommand;
100
108
  let pathStartConnector: PathCommand;
101
109
  if (this.currentCurve) {
102
- const { upperCurve, lowerToUpperConnector, upperToLowerConnector, lowerCurve } = this.currentSegmentToPath();
103
- upperPath = this.upperSegments.concat(upperCurve);
104
- lowerPath = this.lowerSegments.concat(lowerCurve);
110
+ const {
111
+ upperCurveCommand, lowerToUpperConnector, upperToLowerConnector, lowerCurveCommand
112
+ } = this.currentSegmentToPath();
113
+
114
+ upperPath.push(upperCurveCommand);
115
+ lowerPath.push(lowerCurveCommand);
116
+
105
117
  lowerToUpperCap = lowerToUpperConnector;
106
118
  pathStartConnector = this.pathStartConnector ?? upperToLowerConnector;
107
119
  } else {
@@ -109,13 +121,17 @@ export default class FreehandLineBuilder implements ComponentBuilder {
109
121
  return null;
110
122
  }
111
123
 
112
- upperPath = this.upperSegments.slice();
113
- lowerPath = this.lowerSegments.slice();
114
124
  lowerToUpperCap = this.mostRecentConnector;
115
125
  pathStartConnector = this.pathStartConnector;
116
126
  }
117
- const startPoint = lowerPath[lowerPath.length - 1].endPoint;
118
127
 
128
+ let startPoint: Point2;
129
+ const lastLowerSegment = lowerPath[lowerPath.length - 1];
130
+ if (lastLowerSegment.kind === PathCommandType.LineTo || lastLowerSegment.kind === PathCommandType.MoveTo) {
131
+ startPoint = lastLowerSegment.point;
132
+ } else {
133
+ startPoint = lastLowerSegment.endPoint;
134
+ }
119
135
 
120
136
  return {
121
137
  // Start at the end of the lower curve:
@@ -156,24 +172,37 @@ export default class FreehandLineBuilder implements ComponentBuilder {
156
172
  };
157
173
  }
158
174
 
175
+ private previewFullPath(): RenderablePathSpec[]|null {
176
+ const preview = this.previewCurrentPath();
177
+ if (preview) {
178
+ return [ ...this.parts, preview ];
179
+ }
180
+ return null;
181
+ }
182
+
159
183
  private previewStroke(): Stroke|null {
160
- const pathPreview = this.previewPath();
184
+ const pathPreview = this.previewFullPath();
161
185
 
162
186
  if (pathPreview) {
163
- return new Stroke([ pathPreview ]);
187
+ return new Stroke(pathPreview);
164
188
  }
165
189
  return null;
166
190
  }
167
191
 
168
192
  public preview(renderer: AbstractRenderer) {
169
- const path = this.previewPath();
170
- if (path) {
171
- renderer.drawPath(path);
193
+ const paths = this.previewFullPath();
194
+ if (paths) {
195
+ const approxBBox = this.viewport.visibleRect;
196
+ renderer.startObject(approxBBox);
197
+ for (const path of paths) {
198
+ renderer.drawPath(path);
199
+ }
200
+ renderer.endObject();
172
201
  }
173
202
  }
174
203
 
175
204
  public build(): Stroke {
176
- if (this.lastPoint && (this.lowerSegments.length === 0 || this.approxCurrentCurveLength() > this.curveStartWidth * 2)) {
205
+ if (this.lastPoint) {
177
206
  this.finalizeCurrentCurve();
178
207
  }
179
208
  return this.previewStroke()!;
@@ -189,6 +218,74 @@ export default class FreehandLineBuilder implements ComponentBuilder {
189
218
  return Viewport.roundPoint(point, minFit);
190
219
  }
191
220
 
221
+ // Returns true if, due to overlap with previous segments, a new RenderablePathSpec should be created.
222
+ private shouldStartNewSegment(lowerCurve: Bezier, upperCurve: Bezier): boolean {
223
+ if (!this.lastLowerBezier || !this.lastUpperBezier) {
224
+ return false;
225
+ }
226
+
227
+ const getIntersection = (curve1: Bezier, curve2: Bezier): Point2|null => {
228
+ const intersection = curve1.intersects(curve2) as (string[] | null | undefined);
229
+ if (!intersection || intersection.length === 0) {
230
+ return null;
231
+ }
232
+
233
+ // From http://pomax.github.io/bezierjs/#intersect-curve,
234
+ // .intersects returns an array of 't1/t2' pairs, where curve1.at(t1) gives the point.
235
+ const firstTPair = intersection[0];
236
+ const match = /^([-0-9.eE]+)\/([-0-9.eE]+)$/.exec(firstTPair);
237
+
238
+ if (!match) {
239
+ throw new Error(
240
+ `Incorrect format returned by .intersects: ${intersection} should be array of "number/number"!`
241
+ );
242
+ }
243
+
244
+ const t = parseFloat(match[1]);
245
+ return Vec2.ofXY(curve1.get(t));
246
+ };
247
+
248
+ const getExitDirection = (curve: Bezier): Vec2 => {
249
+ return Vec2.ofXY(curve.points[2]).minus(Vec2.ofXY(curve.points[1])).normalized();
250
+ };
251
+
252
+ const getEnterDirection = (curve: Bezier): Vec2 => {
253
+ return Vec2.ofXY(curve.points[1]).minus(Vec2.ofXY(curve.points[0])).normalized();
254
+ };
255
+
256
+ // Prevent
257
+ // /
258
+ // / /
259
+ // / / /|
260
+ // / / |
261
+ // / |
262
+ // where the next stroke and the previous stroke are in different directions.
263
+ //
264
+ // Are the exit/enter directions of the previous and current curves in different enough directions?
265
+ if (getEnterDirection(upperCurve).dot(getExitDirection(this.lastUpperBezier)) < 0.3
266
+ || getEnterDirection(lowerCurve).dot(getExitDirection(this.lastLowerBezier)) < 0.3
267
+
268
+ // Also handle if the curves exit/enter directions differ
269
+ || getEnterDirection(upperCurve).dot(getExitDirection(upperCurve)) < 0
270
+ || getEnterDirection(lowerCurve).dot(getExitDirection(lowerCurve)) < 0) {
271
+ return true;
272
+ }
273
+
274
+ // Check whether the lower curve intersects the other wall:
275
+ // / / ← lower
276
+ // / / /
277
+ // / / /
278
+ // //
279
+ // / /
280
+ const lowerIntersection = getIntersection(lowerCurve, this.lastUpperBezier);
281
+ const upperIntersection = getIntersection(upperCurve, this.lastLowerBezier);
282
+ if (lowerIntersection || upperIntersection) {
283
+ return true;
284
+ }
285
+
286
+ return false;
287
+ }
288
+
192
289
  // Returns the distance between the start, control, and end points of the curve.
193
290
  private approxCurrentCurveLength() {
194
291
  if (!this.currentCurve) {
@@ -257,9 +354,23 @@ export default class FreehandLineBuilder implements ComponentBuilder {
257
354
  return;
258
355
  }
259
356
 
260
- const { upperCurve, lowerToUpperConnector, upperToLowerConnector, lowerCurve } = this.currentSegmentToPath();
357
+ const {
358
+ upperCurveCommand, lowerToUpperConnector, upperToLowerConnector, lowerCurveCommand,
359
+ lowerCurve, upperCurve,
360
+ } = this.currentSegmentToPath();
261
361
 
262
- if (this.isFirstSegment) {
362
+ const shouldStartNew = this.shouldStartNewSegment(lowerCurve, upperCurve);
363
+ if (shouldStartNew) {
364
+ const part = this.previewCurrentPath();
365
+
366
+ if (part) {
367
+ this.parts.push(part);
368
+ this.upperSegments = [];
369
+ this.lowerSegments = [];
370
+ }
371
+ }
372
+
373
+ if (this.isFirstSegment || shouldStartNew) {
263
374
  // We draw the upper path (reversed), then the lower path, so we need the
264
375
  // upperToLowerConnector to join the two paths.
265
376
  this.pathStartConnector = upperToLowerConnector;
@@ -269,8 +380,11 @@ export default class FreehandLineBuilder implements ComponentBuilder {
269
380
  // upperPath:
270
381
  this.mostRecentConnector = lowerToUpperConnector;
271
382
 
272
- this.upperSegments.push(upperCurve);
273
- this.lowerSegments.push(lowerCurve);
383
+ this.lowerSegments.push(lowerCurveCommand);
384
+ this.upperSegments.push(upperCurveCommand);
385
+
386
+ this.lastLowerBezier = lowerCurve;
387
+ this.lastUpperBezier = upperCurve;
274
388
 
275
389
  const lastPoint = this.buffer[this.buffer.length - 1];
276
390
  this.lastExitingVec = Vec2.ofXY(
@@ -325,12 +439,14 @@ export default class FreehandLineBuilder implements ComponentBuilder {
325
439
  );
326
440
 
327
441
  // Each starts at startPt ± startVec
442
+ const lowerCurveStartPoint = this.roundPoint(startPt.plus(startVec));
328
443
  const lowerCurveControlPoint = this.roundPoint(controlPoint.plus(halfVec));
329
444
  const lowerCurveEndPoint = this.roundPoint(endPt.plus(endVec));
330
445
  const upperCurveControlPoint = this.roundPoint(controlPoint.minus(halfVec));
331
446
  const upperCurveStartPoint = this.roundPoint(endPt.minus(endVec));
447
+ const upperCurveEndPoint = this.roundPoint(startPt.minus(startVec));
332
448
 
333
- const lowerCurve: QuadraticBezierPathCommand = {
449
+ const lowerCurveCommand: QuadraticBezierPathCommand = {
334
450
  kind: PathCommandType.QuadraticBezierTo,
335
451
  controlPoint: lowerCurveControlPoint,
336
452
  endPoint: lowerCurveEndPoint,
@@ -339,7 +455,7 @@ export default class FreehandLineBuilder implements ComponentBuilder {
339
455
  // From the end of the upperCurve to the start of the lowerCurve:
340
456
  const upperToLowerConnector: LinePathCommand = {
341
457
  kind: PathCommandType.LineTo,
342
- point: this.roundPoint(startPt.plus(startVec)),
458
+ point: lowerCurveStartPoint,
343
459
  };
344
460
 
345
461
  // From the end of lowerCurve to the start of upperCurve:
@@ -348,13 +464,19 @@ export default class FreehandLineBuilder implements ComponentBuilder {
348
464
  point: upperCurveStartPoint,
349
465
  };
350
466
 
351
- const upperCurve: QuadraticBezierPathCommand = {
467
+ const upperCurveCommand: QuadraticBezierPathCommand = {
352
468
  kind: PathCommandType.QuadraticBezierTo,
353
469
  controlPoint: upperCurveControlPoint,
354
- endPoint: this.roundPoint(startPt.minus(startVec)),
470
+ endPoint: upperCurveEndPoint,
355
471
  };
356
472
 
357
- return { upperCurve, upperToLowerConnector, lowerToUpperConnector, lowerCurve };
473
+ const upperCurve = new Bezier(upperCurveStartPoint, upperCurveControlPoint, upperCurveEndPoint);
474
+ const lowerCurve = new Bezier(lowerCurveStartPoint, lowerCurveControlPoint, lowerCurveEndPoint);
475
+
476
+ return {
477
+ upperCurveCommand, upperToLowerConnector, lowerToUpperConnector, lowerCurveCommand,
478
+ upperCurve, lowerCurve,
479
+ };
358
480
  }
359
481
 
360
482
  // Compute the direction of the velocity at the end of this.buffer
@@ -397,6 +519,11 @@ export default class FreehandLineBuilder implements ComponentBuilder {
397
519
  const prevEndWidth = this.curveEndWidth;
398
520
  this.curveEndWidth = pointRadius;
399
521
 
522
+ if (this.isFirstSegment) {
523
+ // The start of a curve often lacks accurate pressure information. Update it.
524
+ this.curveStartWidth = (this.curveStartWidth + pointRadius) / 2;
525
+ }
526
+
400
527
  // recompute bbox
401
528
  this.bbox = this.bbox.grownToPoint(newPoint.pos, pointRadius);
402
529
 
@@ -413,6 +540,7 @@ export default class FreehandLineBuilder implements ComponentBuilder {
413
540
  console.assert(!isNaN(p1.magnitude()) && !isNaN(p2.magnitude()) && !isNaN(p3.magnitude()), 'Expected !NaN');
414
541
  }
415
542
 
543
+ // If there isn't an entering vector (e.g. because this.isFirstCurve), approximate it.
416
544
  let enteringVec = this.lastExitingVec;
417
545
  if (!enteringVec) {
418
546
  let sampleIdx = Math.ceil(this.buffer.length / 2);
@@ -4,14 +4,14 @@ export { makeFreehandLineBuilder } from './builders/FreehandLineBuilder';
4
4
  export * from './AbstractComponent';
5
5
  export { default as AbstractComponent } from './AbstractComponent';
6
6
  import Stroke from './Stroke';
7
- import Text from './Text';
7
+ import TextComponent from './Text';
8
8
  import ImageComponent from './ImageComponent';
9
9
 
10
10
  export {
11
11
  Stroke,
12
- Text,
12
+ TextComponent as Text,
13
13
 
14
- Text as TextComponent,
14
+ TextComponent as TextComponent,
15
15
  Stroke as StrokeComponent,
16
16
  ImageComponent,
17
17
  };
@@ -1,5 +1,5 @@
1
1
  import Color4 from '../../Color4';
2
- import Text, { TextStyle } from '../../components/Text';
2
+ import TextComponent, { TextStyle } from '../../components/Text';
3
3
  import Mat33 from '../../math/Mat33';
4
4
  import Rect2 from '../../math/Rect2';
5
5
  import { Point2, Vec2 } from '../../math/Vec2';
@@ -153,7 +153,7 @@ export default class CanvasRenderer extends AbstractRenderer {
153
153
  this.ctx.save();
154
154
  transform = this.getCanvasToScreenTransform().rightMul(transform);
155
155
  this.transformBy(transform);
156
- Text.applyTextStyles(this.ctx, style);
156
+ TextComponent.applyTextStyles(this.ctx, style);
157
157
 
158
158
  if (style.renderingStyle.fill.a !== 0) {
159
159
  this.ctx.fillStyle = style.renderingStyle.fill.toHexString();
@@ -100,39 +100,64 @@ export default class SVGRenderer extends AbstractRenderer {
100
100
  }
101
101
 
102
102
  // Apply [elemTransform] to [elem].
103
- private transformFrom(elemTransform: Mat33, elem: SVGElement) {
104
- let transform = this.getCanvasToScreenTransform().rightMul(elemTransform);
103
+ private transformFrom(elemTransform: Mat33, elem: SVGElement, inCanvasSpace: boolean = false) {
104
+ let transform = !inCanvasSpace ? this.getCanvasToScreenTransform().rightMul(elemTransform) : elemTransform;
105
105
  const translation = transform.transformVec2(Vec2.zero);
106
106
  transform = transform.rightMul(Mat33.translation(translation.times(-1)));
107
107
 
108
- elem.style.transform = `matrix(
109
- ${transform.a1}, ${transform.b1},
110
- ${transform.a2}, ${transform.b2},
111
- ${transform.a3}, ${transform.b3}
112
- )`;
108
+ if (!transform.eq(Mat33.identity)) {
109
+ elem.style.transform = `matrix(
110
+ ${transform.a1}, ${transform.b1},
111
+ ${transform.a2}, ${transform.b2},
112
+ ${transform.a3}, ${transform.b3}
113
+ )`;
114
+ } else {
115
+ elem.style.transform = '';
116
+ }
117
+
113
118
  elem.setAttribute('x', `${toRoundedString(translation.x)}`);
114
119
  elem.setAttribute('y', `${toRoundedString(translation.y)}`);
115
120
  }
116
121
 
122
+ private textContainer: SVGTextElement|null = null;
123
+ private textContainerTransform: Mat33|null = null;
117
124
  public drawText(text: string, transform: Mat33, style: TextStyle): void {
118
- const textElem = document.createElementNS(svgNameSpace, 'text');
119
- textElem.appendChild(document.createTextNode(text));
120
- this.transformFrom(transform, textElem);
121
-
122
- textElem.style.fontFamily = style.fontFamily;
123
- textElem.style.fontVariant = style.fontVariant ?? '';
124
- textElem.style.fontWeight = style.fontWeight ?? '';
125
- textElem.style.fontSize = style.size + 'px';
126
- textElem.style.fill = style.renderingStyle.fill.toHexString();
127
-
128
- if (style.renderingStyle.stroke) {
129
- const strokeStyle = style.renderingStyle.stroke;
130
- textElem.style.stroke = strokeStyle.color.toHexString();
131
- textElem.style.strokeWidth = strokeStyle.width + 'px';
132
- }
125
+ const applyTextStyles = (elem: SVGTextElement|SVGTSpanElement, style: TextStyle) => {
126
+ elem.style.fontFamily = style.fontFamily;
127
+ elem.style.fontVariant = style.fontVariant ?? '';
128
+ elem.style.fontWeight = style.fontWeight ?? '';
129
+ elem.style.fontSize = style.size + 'px';
130
+ elem.style.fill = style.renderingStyle.fill.toHexString();
131
+
132
+ if (style.renderingStyle.stroke) {
133
+ const strokeStyle = style.renderingStyle.stroke;
134
+ elem.style.stroke = strokeStyle.color.toHexString();
135
+ elem.style.strokeWidth = strokeStyle.width + 'px';
136
+ }
137
+ };
138
+ transform = this.getCanvasToScreenTransform().rightMul(transform);
139
+
140
+ if (!this.textContainer) {
141
+ const container = document.createElementNS(svgNameSpace, 'text');
142
+ container.appendChild(document.createTextNode(text));
143
+ this.transformFrom(transform, container, true);
144
+ applyTextStyles(container, style);
145
+
146
+ this.elem.appendChild(container);
147
+ this.objectElems?.push(container);
148
+ if (this.objectLevel > 0) {
149
+ this.textContainer = container;
150
+ this.textContainerTransform = transform;
151
+ }
152
+ } else {
153
+ const elem = document.createElementNS(svgNameSpace, 'tspan');
154
+ elem.appendChild(document.createTextNode(text));
155
+ this.textContainer.appendChild(elem);
133
156
 
134
- this.elem.appendChild(textElem);
135
- this.objectElems?.push(textElem);
157
+ transform = this.textContainerTransform!.inverse().rightMul(transform);
158
+ this.transformFrom(transform, elem, true);
159
+ applyTextStyles(elem, style);
160
+ }
136
161
  }
137
162
 
138
163
  public drawImage(image: RenderableImage) {
@@ -153,6 +178,7 @@ export default class SVGRenderer extends AbstractRenderer {
153
178
  // Only accumulate a path within an object
154
179
  this.lastPathString = [];
155
180
  this.lastPathStyle = null;
181
+ this.textContainer = null;
156
182
  this.objectElems = [];
157
183
  }
158
184