js-draw 0.5.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 (92) hide show
  1. package/.firebase/hosting.ZG9jcw.cache +338 -0
  2. package/.github/ISSUE_TEMPLATE/translation.md +1 -1
  3. package/CHANGELOG.md +19 -0
  4. package/dist/bundle.js +1 -1
  5. package/dist/src/Editor.d.ts +8 -6
  6. package/dist/src/Editor.js +8 -4
  7. package/dist/src/EditorImage.d.ts +3 -0
  8. package/dist/src/EditorImage.js +7 -0
  9. package/dist/src/SVGLoader.js +7 -8
  10. package/dist/src/components/AbstractComponent.d.ts +1 -0
  11. package/dist/src/components/AbstractComponent.js +4 -0
  12. package/dist/src/components/SVGGlobalAttributesObject.d.ts +1 -0
  13. package/dist/src/components/SVGGlobalAttributesObject.js +3 -0
  14. package/dist/src/components/Stroke.js +1 -0
  15. package/dist/src/components/Text.d.ts +11 -8
  16. package/dist/src/components/Text.js +63 -20
  17. package/dist/src/components/UnknownSVGObject.d.ts +1 -0
  18. package/dist/src/components/UnknownSVGObject.js +3 -0
  19. package/dist/src/components/builders/FreehandLineBuilder.d.ts +9 -2
  20. package/dist/src/components/builders/FreehandLineBuilder.js +129 -30
  21. package/dist/src/components/lib.d.ts +2 -2
  22. package/dist/src/components/lib.js +2 -2
  23. package/dist/src/rendering/renderers/CanvasRenderer.js +2 -2
  24. package/dist/src/rendering/renderers/SVGRenderer.d.ts +2 -0
  25. package/dist/src/rendering/renderers/SVGRenderer.js +49 -22
  26. package/dist/src/testing/beforeEachFile.js +4 -0
  27. package/dist/src/toolbar/HTMLToolbar.js +2 -3
  28. package/dist/src/toolbar/IconProvider.d.ts +30 -0
  29. package/dist/src/toolbar/IconProvider.js +417 -0
  30. package/dist/src/toolbar/lib.d.ts +1 -1
  31. package/dist/src/toolbar/lib.js +1 -2
  32. package/dist/src/toolbar/localization.d.ts +0 -1
  33. package/dist/src/toolbar/localization.js +0 -1
  34. package/dist/src/toolbar/makeColorInput.js +1 -2
  35. package/dist/src/toolbar/widgets/BaseWidget.js +1 -2
  36. package/dist/src/toolbar/widgets/EraserToolWidget.js +1 -2
  37. package/dist/src/toolbar/widgets/HandToolWidget.d.ts +5 -3
  38. package/dist/src/toolbar/widgets/HandToolWidget.js +35 -12
  39. package/dist/src/toolbar/widgets/PenToolWidget.js +10 -8
  40. package/dist/src/toolbar/widgets/SelectionToolWidget.d.ts +3 -0
  41. package/dist/src/toolbar/widgets/SelectionToolWidget.js +20 -7
  42. package/dist/src/toolbar/widgets/TextToolWidget.js +1 -2
  43. package/dist/src/tools/PanZoom.d.ts +1 -1
  44. package/dist/src/tools/PanZoom.js +4 -1
  45. package/dist/src/tools/PasteHandler.js +2 -22
  46. package/dist/src/tools/SelectionTool/SelectionTool.d.ts +3 -0
  47. package/dist/src/tools/SelectionTool/SelectionTool.js +66 -3
  48. package/dist/src/tools/TextTool.d.ts +4 -0
  49. package/dist/src/tools/TextTool.js +73 -15
  50. package/dist/src/tools/ToolController.js +1 -0
  51. package/dist/src/tools/localization.d.ts +1 -0
  52. package/dist/src/tools/localization.js +1 -0
  53. package/package.json +1 -1
  54. package/src/Editor.toSVG.test.ts +27 -0
  55. package/src/Editor.ts +15 -9
  56. package/src/EditorImage.ts +9 -0
  57. package/src/SVGLoader.test.ts +57 -0
  58. package/src/SVGLoader.ts +9 -10
  59. package/src/components/AbstractComponent.ts +5 -0
  60. package/src/components/SVGGlobalAttributesObject.ts +4 -0
  61. package/src/components/Stroke.ts +1 -0
  62. package/src/components/Text.test.ts +3 -18
  63. package/src/components/Text.ts +78 -25
  64. package/src/components/UnknownSVGObject.ts +4 -0
  65. package/src/components/builders/FreehandLineBuilder.ts +162 -34
  66. package/src/components/lib.ts +3 -3
  67. package/src/rendering/renderers/CanvasRenderer.ts +2 -2
  68. package/src/rendering/renderers/SVGRenderer.ts +50 -24
  69. package/src/testing/beforeEachFile.ts +6 -1
  70. package/src/toolbar/HTMLToolbar.ts +2 -3
  71. package/src/toolbar/IconProvider.ts +480 -0
  72. package/src/toolbar/lib.ts +1 -1
  73. package/src/toolbar/localization.ts +0 -2
  74. package/src/toolbar/makeColorInput.ts +1 -2
  75. package/src/toolbar/widgets/BaseWidget.ts +1 -2
  76. package/src/toolbar/widgets/EraserToolWidget.ts +1 -2
  77. package/src/toolbar/widgets/HandToolWidget.ts +42 -20
  78. package/src/toolbar/widgets/PenToolWidget.ts +11 -8
  79. package/src/toolbar/widgets/SelectionToolWidget.ts +24 -8
  80. package/src/toolbar/widgets/TextToolWidget.ts +1 -2
  81. package/src/tools/PanZoom.ts +4 -1
  82. package/src/tools/PasteHandler.ts +2 -24
  83. package/src/tools/SelectionTool/SelectionTool.css +1 -0
  84. package/src/tools/SelectionTool/SelectionTool.test.ts +40 -0
  85. package/src/tools/SelectionTool/SelectionTool.ts +73 -4
  86. package/src/tools/TextTool.ts +82 -17
  87. package/src/tools/ToolController.ts +1 -0
  88. package/src/tools/localization.ts +4 -0
  89. package/typedoc.json +5 -1
  90. package/dist/src/toolbar/icons.d.ts +0 -20
  91. package/dist/src/toolbar/icons.js +0 -385
  92. package/src/toolbar/icons.ts +0 -443
@@ -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,10 +540,11 @@ 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
- let sampleIdx = Math.ceil(this.buffer.length / 3);
419
- if (sampleIdx === 0) {
546
+ let sampleIdx = Math.ceil(this.buffer.length / 2);
547
+ if (sampleIdx === 0 || sampleIdx >= this.buffer.length) {
420
548
  sampleIdx = this.buffer.length - 1;
421
549
  }
422
550
 
@@ -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
 
@@ -1,3 +1,8 @@
1
1
  import loadExpectExtensions from './loadExpectExtensions';
2
2
  loadExpectExtensions();
3
- jest.useFakeTimers();
3
+ jest.useFakeTimers();
4
+
5
+ // jsdom doesn't support HTMLCanvasElement#getContext — it logs an error
6
+ // to the console. Make it return null so we can handle a non-existent Canvas
7
+ // at runtime (e.g. use something else, if available).
8
+ HTMLCanvasElement.prototype.getContext = () => null;
@@ -5,7 +5,6 @@ import { coloris, init as colorisInit } from '@melloware/coloris';
5
5
  import Color4 from '../Color4';
6
6
  import { defaultToolbarLocalization, ToolbarLocalization } from './localization';
7
7
  import { ActionButtonIcon } from './types';
8
- import { makeRedoIcon, makeUndoIcon } from './icons';
9
8
  import SelectionTool from '../tools/SelectionTool/SelectionTool';
10
9
  import PanZoomTool from '../tools/PanZoom';
11
10
  import TextTool from '../tools/TextTool';
@@ -156,13 +155,13 @@ export default class HTMLToolbar {
156
155
 
157
156
  const undoButton = this.addActionButton({
158
157
  label: this.localizationTable.undo,
159
- icon: makeUndoIcon()
158
+ icon: this.editor.icons.makeUndoIcon()
160
159
  }, () => {
161
160
  this.editor.history.undo();
162
161
  }, undoRedoGroup);
163
162
  const redoButton = this.addActionButton({
164
163
  label: this.localizationTable.redo,
165
- icon: makeRedoIcon(),
164
+ icon: this.editor.icons.makeRedoIcon(),
166
165
  }, () => {
167
166
  this.editor.history.redo();
168
167
  }, undoRedoGroup);