js-draw 0.4.0 → 0.5.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 (66) hide show
  1. package/.github/pull_request_template.md +15 -0
  2. package/.github/workflows/firebase-hosting-merge.yml +7 -0
  3. package/.github/workflows/firebase-hosting-pull-request.yml +10 -0
  4. package/.github/workflows/github-pages.yml +2 -0
  5. package/CHANGELOG.md +15 -0
  6. package/dist/bundle.js +1 -1
  7. package/dist/src/Editor.d.ts +1 -1
  8. package/dist/src/Editor.js +8 -3
  9. package/dist/src/components/AbstractComponent.js +1 -0
  10. package/dist/src/components/Stroke.js +15 -9
  11. package/dist/src/components/Text.d.ts +1 -1
  12. package/dist/src/components/Text.js +1 -1
  13. package/dist/src/components/builders/FreehandLineBuilder.d.ts +1 -0
  14. package/dist/src/components/builders/FreehandLineBuilder.js +33 -35
  15. package/dist/src/math/Vec3.d.ts +1 -1
  16. package/dist/src/math/Vec3.js +1 -1
  17. package/dist/src/rendering/renderers/SVGRenderer.js +1 -1
  18. package/dist/src/testing/beforeEachFile.d.ts +1 -0
  19. package/dist/src/testing/beforeEachFile.js +3 -0
  20. package/dist/src/testing/createEditor.d.ts +1 -0
  21. package/dist/src/testing/createEditor.js +7 -1
  22. package/dist/src/testing/loadExpectExtensions.d.ts +0 -15
  23. package/dist/src/toolbar/widgets/BaseWidget.d.ts +2 -0
  24. package/dist/src/toolbar/widgets/BaseWidget.js +15 -0
  25. package/dist/src/toolbar/widgets/PenToolWidget.d.ts +2 -0
  26. package/dist/src/toolbar/widgets/PenToolWidget.js +14 -0
  27. package/dist/src/tools/SelectionTool/SelectionTool.js +8 -0
  28. package/dist/src/tools/ToolController.js +2 -0
  29. package/dist/src/tools/ToolbarShortcutHandler.d.ts +12 -0
  30. package/dist/src/tools/ToolbarShortcutHandler.js +23 -0
  31. package/dist/src/tools/lib.d.ts +1 -0
  32. package/dist/src/tools/lib.js +1 -0
  33. package/dist/src/types.d.ts +4 -2
  34. package/jest.config.js +5 -0
  35. package/package.json +15 -14
  36. package/src/Editor.ts +8 -2
  37. package/src/components/AbstractComponent.ts +2 -0
  38. package/src/components/Stroke.test.ts +0 -3
  39. package/src/components/Stroke.ts +14 -7
  40. package/src/components/Text.test.ts +0 -3
  41. package/src/components/Text.ts +2 -2
  42. package/src/components/builders/FreehandLineBuilder.ts +36 -42
  43. package/src/language/assertions.ts +2 -2
  44. package/src/math/LineSegment2.test.ts +8 -10
  45. package/src/math/Mat33.test.ts +0 -2
  46. package/src/math/Rect2.test.ts +0 -3
  47. package/src/math/Vec2.test.ts +0 -3
  48. package/src/math/Vec3.test.ts +0 -3
  49. package/src/math/Vec3.ts +1 -1
  50. package/src/rendering/renderers/SVGRenderer.ts +1 -1
  51. package/src/testing/beforeEachFile.ts +3 -0
  52. package/src/testing/createEditor.ts +8 -1
  53. package/src/testing/global.d.ts +17 -0
  54. package/src/testing/loadExpectExtensions.ts +0 -15
  55. package/src/toolbar/toolbar.css +3 -2
  56. package/src/toolbar/widgets/BaseWidget.ts +19 -1
  57. package/src/toolbar/widgets/PenToolWidget.ts +18 -1
  58. package/src/tools/Pen.test.ts +150 -0
  59. package/src/tools/SelectionTool/SelectionTool.css +1 -1
  60. package/src/tools/SelectionTool/SelectionTool.ts +9 -0
  61. package/src/tools/ToolController.ts +2 -0
  62. package/src/tools/ToolbarShortcutHandler.ts +34 -0
  63. package/src/tools/UndoRedoShortcut.test.ts +3 -0
  64. package/src/tools/lib.ts +1 -0
  65. package/src/types.ts +13 -8
  66. package/tsconfig.json +3 -1
package/src/Editor.ts CHANGED
@@ -586,6 +586,7 @@ export class Editor {
586
586
  kind: InputEvtType.KeyPressEvent,
587
587
  key: evt.key,
588
588
  ctrlKey: evt.ctrlKey,
589
+ altKey: evt.altKey,
589
590
  })) {
590
591
  evt.preventDefault();
591
592
  } else if (evt.key === 'Escape') {
@@ -598,6 +599,7 @@ export class Editor {
598
599
  kind: InputEvtType.KeyUpEvent,
599
600
  key: evt.key,
600
601
  ctrlKey: evt.ctrlKey,
602
+ altKey: evt.altKey,
601
603
  })) {
602
604
  evt.preventDefault();
603
605
  }
@@ -783,12 +785,14 @@ export class Editor {
783
785
  public sendKeyboardEvent(
784
786
  eventType: InputEvtType.KeyPressEvent|InputEvtType.KeyUpEvent,
785
787
  key: string,
786
- ctrlKey: boolean = false
788
+ ctrlKey: boolean = false,
789
+ altKey: boolean = false,
787
790
  ) {
788
791
  this.toolController.dispatchInputEvent({
789
792
  kind: eventType,
790
793
  key,
791
- ctrlKey
794
+ ctrlKey,
795
+ altKey,
792
796
  });
793
797
  }
794
798
 
@@ -797,6 +801,8 @@ export class Editor {
797
801
  public sendPenEvent(
798
802
  eventType: InputEvtType.PointerDownEvt|InputEvtType.PointerMoveEvt|InputEvtType.PointerUpEvt,
799
803
  point: Point2,
804
+
805
+ // @deprecated
800
806
  allPointers?: Pointer[]
801
807
  ) {
802
808
  const mainPointer = Pointer.ofCanvasPoint(
@@ -206,8 +206,10 @@ export default abstract class AbstractComponent {
206
206
 
207
207
  public abstract description(localizationTable: ImageComponentLocalization): string;
208
208
 
209
+ // Component-specific implementation of {@link clone}.
209
210
  protected abstract createClone(): AbstractComponent;
210
211
 
212
+ // Returns a copy of this component.
211
213
  public clone() {
212
214
  const clone = this.createClone();
213
215
 
@@ -2,12 +2,9 @@ import Color4 from '../Color4';
2
2
  import Path from '../math/Path';
3
3
  import { Vec2 } from '../math/Vec2';
4
4
  import Stroke from './Stroke';
5
- import { loadExpectExtensions } from '../testing/loadExpectExtensions';
6
5
  import createEditor from '../testing/createEditor';
7
6
  import Mat33 from '../math/Mat33';
8
7
 
9
- loadExpectExtensions();
10
-
11
8
  describe('Stroke', () => {
12
9
  it('empty stroke should have an empty bounding box', () => {
13
10
  const stroke = new Stroke([{
@@ -18,7 +18,8 @@ export default class Stroke extends AbstractComponent {
18
18
  public constructor(parts: RenderablePathSpec[]) {
19
19
  super('stroke');
20
20
 
21
- this.parts = parts.map((section): StrokePart => {
21
+ this.parts = [];
22
+ for (const section of parts) {
22
23
  const path = Path.fromRenderable(section);
23
24
  const pathBBox = this.bboxForPart(path.bbox, section.style);
24
25
 
@@ -28,15 +29,15 @@ export default class Stroke extends AbstractComponent {
28
29
  this.contentBBox = this.contentBBox.union(pathBBox);
29
30
  }
30
31
 
31
- return {
32
+ this.parts.push({
32
33
  path,
33
34
 
34
35
  // To implement RenderablePathSpec
35
36
  startPoint: path.startPoint,
36
37
  style: section.style,
37
38
  commands: path.parts,
38
- };
39
- });
39
+ });
40
+ }
40
41
  this.contentBBox ??= Rect2.empty;
41
42
  }
42
43
 
@@ -104,9 +105,15 @@ export default class Stroke extends AbstractComponent {
104
105
  }
105
106
 
106
107
  public getPath() {
107
- return this.parts.reduce((accumulator: Path|null, current: StrokePart) => {
108
- return accumulator?.union(current.path) ?? current.path;
109
- }, null) ?? Path.empty;
108
+ let result: Path|null = null;
109
+ for (const part of this.parts) {
110
+ if (result) {
111
+ result = result.union(part.path);
112
+ } else {
113
+ result ??= part.path;
114
+ }
115
+ }
116
+ return result ?? Path.empty;
110
117
  }
111
118
 
112
119
  public description(localization: ImageComponentLocalization): string {
@@ -3,9 +3,6 @@ import Mat33 from '../math/Mat33';
3
3
  import Rect2 from '../math/Rect2';
4
4
  import AbstractComponent from './AbstractComponent';
5
5
  import Text, { TextStyle } from './Text';
6
- import { loadExpectExtensions } from '../testing/loadExpectExtensions';
7
-
8
- loadExpectExtensions();
9
6
 
10
7
  const estimateTextBounds = (text: string, style: TextStyle): Rect2 => {
11
8
  const widthEst = text.length * style.size;
@@ -135,7 +135,7 @@ export default class Text extends AbstractComponent {
135
135
  return new Text(this.textObjects, this.transform, this.style);
136
136
  }
137
137
 
138
- private getText() {
138
+ public getText() {
139
139
  const result: string[] = [];
140
140
 
141
141
  for (const textObject of this.textObjects) {
@@ -146,7 +146,7 @@ export default class Text extends AbstractComponent {
146
146
  }
147
147
  }
148
148
 
149
- return result.join(' ');
149
+ return result.join('\n');
150
150
  }
151
151
 
152
152
  public description(localizationTable: ImageComponentLocalization): string {
@@ -173,7 +173,7 @@ export default class FreehandLineBuilder implements ComponentBuilder {
173
173
  }
174
174
 
175
175
  public build(): Stroke {
176
- if (this.lastPoint) {
176
+ if (this.lastPoint && (this.lowerSegments.length === 0 || this.approxCurrentCurveLength() > this.curveStartWidth * 2)) {
177
177
  this.finalizeCurrentCurve();
178
178
  }
179
179
  return this.previewStroke()!;
@@ -189,6 +189,19 @@ export default class FreehandLineBuilder implements ComponentBuilder {
189
189
  return Viewport.roundPoint(point, minFit);
190
190
  }
191
191
 
192
+ // Returns the distance between the start, control, and end points of the curve.
193
+ private approxCurrentCurveLength() {
194
+ if (!this.currentCurve) {
195
+ return 0;
196
+ }
197
+ const startPt = Vec2.ofXY(this.currentCurve.points[0]);
198
+ const controlPt = Vec2.ofXY(this.currentCurve.points[1]);
199
+ const endPt = Vec2.ofXY(this.currentCurve.points[2]);
200
+ const toControlDist = startPt.minus(controlPt).length();
201
+ const toEndDist = endPt.minus(controlPt).length();
202
+ return toControlDist + toEndDist;
203
+ }
204
+
192
205
  private finalizeCurrentCurve() {
193
206
  // Case where no points have been added
194
207
  if (!this.currentCurve) {
@@ -285,10 +298,8 @@ export default class FreehandLineBuilder implements ComponentBuilder {
285
298
  startVec = startVec.times(this.curveStartWidth / 2);
286
299
  endVec = endVec.times(this.curveEndWidth / 2);
287
300
 
288
- if (isNaN(startVec.magnitude())) {
289
- // TODO: This can happen when events are too close together. Find out why and
290
- // fix.
291
- console.error('startVec is NaN', startVec, endVec, this.currentCurve);
301
+ if (!isFinite(startVec.magnitude())) {
302
+ console.error('Warning: startVec is NaN or ∞', startVec, endVec, this.currentCurve);
292
303
  startVec = endVec;
293
304
  }
294
305
 
@@ -307,39 +318,22 @@ export default class FreehandLineBuilder implements ComponentBuilder {
307
318
  }
308
319
 
309
320
  const halfVecT = projectionT;
310
- let halfVec = Vec2.ofXY(this.currentCurve.normal(halfVecT))
321
+ const halfVec = Vec2.ofXY(this.currentCurve.normal(halfVecT))
311
322
  .normalized().times(
312
323
  this.curveStartWidth / 2 * halfVecT
313
324
  + this.curveEndWidth / 2 * (1 - halfVecT)
314
325
  );
315
326
 
316
- // Computes a boundary curve. [direction] should be either +1 or -1 (determines the side
317
- // of the center curve to place the boundary).
318
- const computeBoundaryCurve = (direction: number, halfVec: Vec2) => {
319
- return new Bezier(
320
- startPt.plus(startVec.times(direction)),
321
- controlPoint.plus(halfVec.times(direction)),
322
- endPt.plus(endVec.times(direction)),
323
- );
324
- };
325
-
326
- const boundariesIntersect = () => {
327
- const upperBoundary = computeBoundaryCurve(1, halfVec);
328
- const lowerBoundary = computeBoundaryCurve(-1, halfVec);
329
- return upperBoundary.intersects(lowerBoundary).length > 0;
330
- };
331
-
332
- // If the boundaries have intersections, increasing the half vector's length could fix this.
333
- if (boundariesIntersect()) {
334
- halfVec = halfVec.times(1.1);
335
- }
336
-
337
327
  // Each starts at startPt ± startVec
328
+ const lowerCurveControlPoint = this.roundPoint(controlPoint.plus(halfVec));
329
+ const lowerCurveEndPoint = this.roundPoint(endPt.plus(endVec));
330
+ const upperCurveControlPoint = this.roundPoint(controlPoint.minus(halfVec));
331
+ const upperCurveStartPoint = this.roundPoint(endPt.minus(endVec));
338
332
 
339
333
  const lowerCurve: QuadraticBezierPathCommand = {
340
334
  kind: PathCommandType.QuadraticBezierTo,
341
- controlPoint: this.roundPoint(controlPoint.plus(halfVec)),
342
- endPoint: this.roundPoint(endPt.plus(endVec)),
335
+ controlPoint: lowerCurveControlPoint,
336
+ endPoint: lowerCurveEndPoint,
343
337
  };
344
338
 
345
339
  // From the end of the upperCurve to the start of the lowerCurve:
@@ -351,12 +345,12 @@ export default class FreehandLineBuilder implements ComponentBuilder {
351
345
  // From the end of lowerCurve to the start of upperCurve:
352
346
  const lowerToUpperConnector: LinePathCommand = {
353
347
  kind: PathCommandType.LineTo,
354
- point: this.roundPoint(endPt.minus(endVec))
348
+ point: upperCurveStartPoint,
355
349
  };
356
350
 
357
351
  const upperCurve: QuadraticBezierPathCommand = {
358
352
  kind: PathCommandType.QuadraticBezierTo,
359
- controlPoint: this.roundPoint(controlPoint.minus(halfVec)),
353
+ controlPoint: upperCurveControlPoint,
360
354
  endPoint: this.roundPoint(startPt.minus(startVec)),
361
355
  };
362
356
 
@@ -374,7 +368,6 @@ export default class FreehandLineBuilder implements ComponentBuilder {
374
368
  const fuzzEq = 1e-10;
375
369
  const deltaTime = newPoint.time - this.lastPoint.time;
376
370
  if (newPoint.pos.eq(this.lastPoint.pos, fuzzEq) || deltaTime === 0) {
377
- console.warn('Discarding identical point');
378
371
  return;
379
372
  } else if (isNaN(newPoint.pos.magnitude())) {
380
373
  console.warn('Discarding NaN point.', newPoint);
@@ -440,16 +433,16 @@ export default class FreehandLineBuilder implements ComponentBuilder {
440
433
  const maxControlPointDist = maxRelativeLength * startEndDist;
441
434
 
442
435
  // Exit in cases where we would divide by zero
443
- if (maxControlPointDist === 0 || exitingVec.magnitude() === 0 || isNaN(exitingVec.magnitude())) {
436
+ if (maxControlPointDist === 0 || exitingVec.magnitude() === 0 || !isFinite(exitingVec.magnitude())) {
444
437
  return;
445
438
  }
446
439
 
447
- console.assert(!isNaN(enteringVec.magnitude()));
440
+ console.assert(isFinite(enteringVec.magnitude()), 'Pre-normalized enteringVec has NaN or ∞ magnitude!');
448
441
 
449
442
  enteringVec = enteringVec.normalized();
450
443
  exitingVec = exitingVec.normalized();
451
444
 
452
- console.assert(!isNaN(enteringVec.magnitude()));
445
+ console.assert(isFinite(enteringVec.magnitude()), 'Normalized enteringVec has NaN or ∞ magnitude!');
453
446
 
454
447
  const lineFromStart = new LineSegment2(
455
448
  segmentStart,
@@ -462,18 +455,20 @@ export default class FreehandLineBuilder implements ComponentBuilder {
462
455
  const intersection = lineFromEnd.intersection(lineFromStart);
463
456
 
464
457
  // Position the control point at this intersection
465
- let controlPoint: Point2;
458
+ let controlPoint: Point2|null = null;
466
459
  if (intersection) {
467
460
  controlPoint = intersection.point;
468
- } else {
461
+ }
462
+
463
+ // No intersection or the intersection is one of the end points?
464
+ if (!controlPoint || segmentStart.eq(controlPoint) || segmentEnd.eq(controlPoint)) {
469
465
  // Position the control point closer to the first -- the connecting
470
466
  // segment will be roughly a line.
471
467
  controlPoint = segmentStart.plus(enteringVec.times(startEndDist / 4));
472
468
  }
473
469
 
474
- if (isNaN(controlPoint.magnitude()) || isNaN(segmentStart.magnitude())) {
475
- console.error('controlPoint is NaN', intersection, 'Start:', segmentStart, 'End:', segmentEnd, 'in:', enteringVec, 'out:', exitingVec);
476
- }
470
+ console.assert(!segmentStart.eq(controlPoint, 1e-11), 'Start and control points are equal!');
471
+ console.assert(!controlPoint.eq(segmentEnd, 1e-11), 'Control and end points are equal!');
477
472
 
478
473
  const prevCurve = this.currentCurve;
479
474
  this.currentCurve = new Bezier(segmentStart.xy, controlPoint.xy, segmentEnd.xy);
@@ -502,8 +497,7 @@ export default class FreehandLineBuilder implements ComponentBuilder {
502
497
  return true;
503
498
  };
504
499
 
505
- const approxCurveLen = controlPoint.minus(segmentStart).magnitude() + segmentEnd.minus(controlPoint).magnitude();
506
- if (this.buffer.length > 3 && approxCurveLen > this.curveEndWidth / 3) {
500
+ if (this.buffer.length > 3 && this.approxCurrentCurveLength() > this.curveStartWidth) {
507
501
  if (!curveMatchesPoints(this.currentCurve)) {
508
502
  // Use a curve that better fits the points
509
503
  this.currentCurve = prevCurve;
@@ -2,5 +2,5 @@
2
2
  // Compile-time assertion that a branch of code is unreachable.
3
3
  // See https://stackoverflow.com/a/39419171/17055750
4
4
  export const assertUnreachable = (key: never): never => {
5
- throw new Error(`Should be unreachable. Key: ${key}.`);
6
- };
5
+ throw new Error(`Should be unreachable. Key: ${key}.`);
6
+ };
@@ -1,9 +1,7 @@
1
1
  import LineSegment2 from './LineSegment2';
2
- import { loadExpectExtensions } from '../testing/loadExpectExtensions';
3
2
  import { Vec2 } from './Vec2';
4
3
  import Mat33 from './Mat33';
5
4
 
6
- loadExpectExtensions();
7
5
 
8
6
  describe('Line2', () => {
9
7
  it('x and y axes should intersect at (0, 0)', () => {
@@ -14,13 +12,13 @@ describe('Line2', () => {
14
12
  });
15
13
 
16
14
  it('y = -2x + 2 and y = 2x - 2 should intersect at (1,0)', () => {
17
- // y = -2x + 2
15
+ // y = -4x + 2
18
16
  const line1 = new LineSegment2(Vec2.of(0, 2), Vec2.of(1, -2));
19
- // y = 2x - 2
17
+ // y = 4x - 2
20
18
  const line2 = new LineSegment2(Vec2.of(0, -2), Vec2.of(1, 2));
21
19
 
22
- expect(line1.intersection(line2)?.point).objEq(Vec2.of(1, 0));
23
- expect(line2.intersection(line1)?.point).objEq(Vec2.of(1, 0));
20
+ expect(line1.intersection(line2)?.point).objEq(Vec2.of(0.5, 0));
21
+ expect(line2.intersection(line1)?.point).objEq(Vec2.of(0.5, 0));
24
22
  });
25
23
 
26
24
  it('line from (10, 10) to (-100, 10) should intersect with the y-axis at t = 10', () => {
@@ -81,14 +79,14 @@ describe('Line2', () => {
81
79
  expect(line.closestPointTo(Vec2.zero)).objEq(Vec2.of(1, 0));
82
80
  });
83
81
 
84
- it('Closest point from (-1,2) to segment((1,1) -> (2,4)) should be (1,1)', () => {
82
+ it('Closest point from (-1,-2) to segment((1,1) -> (2,4)) should be (1,1)', () => {
85
83
  const line = new LineSegment2(Vec2.of(1, 1), Vec2.of(2, 4));
86
- expect(line.closestPointTo(Vec2.of(-1, 2))).objEq(Vec2.of(1, 1));
84
+ expect(line.closestPointTo(Vec2.of(-1, -2))).objEq(Vec2.of(1, 1));
87
85
  });
88
86
 
89
- it('Closest point from (5,2) to segment((1,1) -> (2,4)) should be (2,4)', () => {
87
+ it('Closest point from (5,8) to segment((1,1) -> (2,4)) should be (2,4)', () => {
90
88
  const line = new LineSegment2(Vec2.of(1, 1), Vec2.of(2, 4));
91
- expect(line.closestPointTo(Vec2.of(5, 2))).objEq(Vec2.of(2, 4));
89
+ expect(line.closestPointTo(Vec2.of(5, 8))).objEq(Vec2.of(2, 4));
92
90
  });
93
91
 
94
92
  it('Should translate when translated by a translation matrix', () => {
@@ -1,9 +1,7 @@
1
1
  import Mat33 from './Mat33';
2
2
  import { Vec2 } from './Vec2';
3
- import { loadExpectExtensions } from '../testing/loadExpectExtensions';
4
3
  import Vec3 from './Vec3';
5
4
 
6
- loadExpectExtensions();
7
5
 
8
6
  describe('Mat33 tests', () => {
9
7
  it('equality', () => {
@@ -1,11 +1,8 @@
1
1
 
2
2
  import Rect2 from './Rect2';
3
3
  import { Vec2 } from './Vec2';
4
- import loadExpectExtensions from '../testing/loadExpectExtensions';
5
4
  import Mat33 from './Mat33';
6
5
 
7
- loadExpectExtensions();
8
-
9
6
  describe('Rect2', () => {
10
7
  it('width, height should always be positive', () => {
11
8
  expect(new Rect2(-1, -2, -3, 4)).objEq(new Rect2(-4, -2, 3, 4));
@@ -1,8 +1,5 @@
1
1
  import { Vec2 } from './Vec2';
2
2
  import Vec3 from './Vec3';
3
- import { loadExpectExtensions } from '../testing/loadExpectExtensions';
4
-
5
- loadExpectExtensions();
6
3
 
7
4
  describe('Vec2', () => {
8
5
  it('Magnitude', () => {
@@ -1,9 +1,6 @@
1
1
 
2
- import { loadExpectExtensions } from '../testing/loadExpectExtensions';
3
2
  import Vec3 from './Vec3';
4
3
 
5
- loadExpectExtensions();
6
-
7
4
  describe('Vec3', () => {
8
5
  it('.xy should contain the x and y components', () => {
9
6
  const vec = Vec3.of(1, 2, 3);
package/src/math/Vec3.ts CHANGED
@@ -196,7 +196,7 @@ export default class Vec3 {
196
196
  * Vec3.of(1, 2, 3).eq(Vec3.of(4, 5, 6), 2.99); // → false
197
197
  * ```
198
198
  */
199
- public eq(other: Vec3, fuzz: number): boolean {
199
+ public eq(other: Vec3, fuzz: number = 1e-10): boolean {
200
200
  for (let i = 0; i < 3; i++) {
201
201
  if (Math.abs(other.at(i) - this.at(i)) > fuzz) {
202
202
  return false;
@@ -88,7 +88,7 @@ export default class SVGRenderer extends AbstractRenderer {
88
88
 
89
89
  public drawPath(pathSpec: RenderablePathSpec) {
90
90
  const style = pathSpec.style;
91
- const path = Path.fromRenderable(pathSpec);
91
+ const path = Path.fromRenderable(pathSpec).transformedBy(this.getCanvasToScreenTransform());
92
92
 
93
93
  // Try to extend the previous path, if possible
94
94
  if (!style.fill.eq(this.lastPathStyle?.fill) || this.lastPathString.length === 0) {
@@ -0,0 +1,3 @@
1
+ import loadExpectExtensions from './loadExpectExtensions';
2
+ loadExpectExtensions();
3
+ jest.useFakeTimers();
@@ -1,4 +1,11 @@
1
1
  import { RenderingMode } from '../rendering/Display';
2
2
  import Editor from '../Editor';
3
3
 
4
- export default () => new Editor(document.body, { renderingMode: RenderingMode.DummyRenderer });
4
+ /** Creates an editor. Should only be used in test files. */
5
+ export default () => {
6
+ if (jest === undefined) {
7
+ throw new Error('Files in the testing/ folder should only be used in tests!');
8
+ }
9
+
10
+ return new Editor(document.body, { renderingMode: RenderingMode.DummyRenderer });
11
+ };
@@ -0,0 +1,17 @@
1
+
2
+
3
+ // Type declarations for custom matchers
4
+ interface CustomMatchers<R = unknown> {
5
+ objEq(expected: {
6
+ eq: (other: any, ...args: any)=> boolean;
7
+ }, ...opts: any): R;
8
+ }
9
+
10
+ declare namespace jest {
11
+ interface Expect extends CustomMatchers {}
12
+ interface Matchers<R> extends CustomMatchers<R> {}
13
+ interface AsyncAsymmetricMatchers extends CustomMatchers {}
14
+ }
15
+
16
+ declare interface JestMatchers<T> extends CustomMatchers<T> {
17
+ }
@@ -22,19 +22,4 @@ export const loadExpectExtensions = () => {
22
22
  });
23
23
  };
24
24
 
25
- // Type declarations for custom matchers
26
- export interface CustomMatchers<R = unknown> {
27
- objEq(expected: {
28
- eq: (other: any, ...args: any)=> boolean;
29
- }, ...opts: any): R;
30
- }
31
-
32
- declare global {
33
- export namespace jest {
34
- interface Expect extends CustomMatchers {}
35
- interface Matchers<R> extends CustomMatchers<R> {}
36
- interface AsyncAsymmetricMatchers extends CustomMatchers {}
37
- }
38
- }
39
-
40
25
  export default loadExpectExtensions;
@@ -34,7 +34,8 @@
34
34
  }
35
35
 
36
36
  .toolbar-button.disabled {
37
- filter: opacity(0.8) saturate(0.1);
37
+ filter: opacity(0.5) sepia(0.2);
38
+ cursor: unset;
38
39
  }
39
40
 
40
41
  .toolbar-button, .toolbar-root button {
@@ -75,7 +76,7 @@
75
76
  width: 6em;
76
77
  }
77
78
 
78
- .toolbar-button:hover, .toolbar-root button:not(:disabled):hover {
79
+ .toolbar-button:not(.disabled):hover, .toolbar-root button:not(:disabled):hover {
79
80
  box-shadow: 0px 2px 4px var(--primary-shadow-color);
80
81
  }
81
82
 
@@ -1,5 +1,6 @@
1
1
  import Editor from '../../Editor';
2
- import { EditorEventType, InputEvtType } from '../../types';
2
+ import ToolbarShortcutHandler from '../../tools/ToolbarShortcutHandler';
3
+ import { EditorEventType, InputEvtType, KeyPressEvent } from '../../types';
3
4
  import { toolbarCSSPrefix } from '../HTMLToolbar';
4
5
  import { makeDropdownIcon } from '../icons';
5
6
  import { ToolbarLocalization } from '../localization';
@@ -33,6 +34,14 @@ export default abstract class BaseWidget {
33
34
  this.label = document.createElement('label');
34
35
  this.button.setAttribute('role', 'button');
35
36
  this.button.tabIndex = 0;
37
+
38
+ const toolbarShortcutHandlers = this.editor.toolController.getMatchingTools(ToolbarShortcutHandler);
39
+
40
+ // If the onKeyPress function has been extended and the editor is configured to send keypress events to
41
+ // toolbar widgets,
42
+ if (toolbarShortcutHandlers.length > 0 && this.onKeyPress !== BaseWidget.prototype.onKeyPress) {
43
+ toolbarShortcutHandlers[0].registerListener(event => this.onKeyPress(event));
44
+ }
36
45
  }
37
46
 
38
47
  protected abstract getTitle(): string;
@@ -70,6 +79,7 @@ export default abstract class BaseWidget {
70
79
  kind: InputEvtType.KeyPressEvent,
71
80
  key: evt.key,
72
81
  ctrlKey: evt.ctrlKey,
82
+ altKey: evt.altKey,
73
83
  });
74
84
  }
75
85
  };
@@ -83,6 +93,7 @@ export default abstract class BaseWidget {
83
93
  kind: InputEvtType.KeyUpEvent,
84
94
  key: evt.key,
85
95
  ctrlKey: evt.ctrlKey,
96
+ altKey: evt.altKey,
86
97
  });
87
98
  };
88
99
 
@@ -93,6 +104,13 @@ export default abstract class BaseWidget {
93
104
  };
94
105
  }
95
106
 
107
+ // Add a listener that is triggered when a key is pressed.
108
+ // Listeners will fire regardless of whether this widget is selected and require that
109
+ // {@link lib!Editor.toolController} to have an enabled {@link lib!ToolbarShortcutHandler} tool.
110
+ protected onKeyPress(_event: KeyPressEvent): boolean {
111
+ return false;
112
+ }
113
+
96
114
  protected abstract handleClick(): void;
97
115
 
98
116
  protected get hasDropdown() {
@@ -5,7 +5,7 @@ import { makeFilledRectangleBuilder, makeOutlinedRectangleBuilder } from '../../
5
5
  import { ComponentBuilderFactory } from '../../components/builders/types';
6
6
  import Editor from '../../Editor';
7
7
  import Pen from '../../tools/Pen';
8
- import { EditorEventType } from '../../types';
8
+ import { EditorEventType, KeyPressEvent } from '../../types';
9
9
  import { toolbarCSSPrefix } from '../HTMLToolbar';
10
10
  import { makeIconFromFactory, makePenIcon } from '../icons';
11
11
  import { ToolbarLocalization } from '../localization';
@@ -165,4 +165,21 @@ export default class PenToolWidget extends BaseToolWidget {
165
165
  dropdown.replaceChildren(container);
166
166
  return true;
167
167
  }
168
+
169
+ protected onKeyPress(event: KeyPressEvent): boolean {
170
+ if (!this.isSelected()) {
171
+ return false;
172
+ }
173
+
174
+ // Map alt+0-9 to different pen types.
175
+ if (/^[0-9]$/.exec(event.key) && event.ctrlKey) {
176
+ const penTypeIdx = parseInt(event.key) - 1;
177
+ if (penTypeIdx >= 0 && penTypeIdx < this.penTypes.length) {
178
+ this.tool.setStrokeFactory(this.penTypes[penTypeIdx].factory);
179
+ return true;
180
+ }
181
+ }
182
+
183
+ return false;
184
+ }
168
185
  }