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
@@ -193,7 +193,7 @@ export declare class Editor {
193
193
  remove: () => void;
194
194
  };
195
195
  addStyleSheet(content: string): HTMLStyleElement;
196
- sendKeyboardEvent(eventType: InputEvtType.KeyPressEvent | InputEvtType.KeyUpEvent, key: string, ctrlKey?: boolean): void;
196
+ sendKeyboardEvent(eventType: InputEvtType.KeyPressEvent | InputEvtType.KeyUpEvent, key: string, ctrlKey?: boolean, altKey?: boolean): void;
197
197
  sendPenEvent(eventType: InputEvtType.PointerDownEvt | InputEvtType.PointerMoveEvt | InputEvtType.PointerUpEvt, point: Point2, allPointers?: Pointer[]): void;
198
198
  toSVG(): SVGElement;
199
199
  loadFrom(loader: ImageLoader): Promise<void>;
@@ -441,6 +441,7 @@ export class Editor {
441
441
  kind: InputEvtType.KeyPressEvent,
442
442
  key: evt.key,
443
443
  ctrlKey: evt.ctrlKey,
444
+ altKey: evt.altKey,
444
445
  })) {
445
446
  evt.preventDefault();
446
447
  }
@@ -453,6 +454,7 @@ export class Editor {
453
454
  kind: InputEvtType.KeyUpEvent,
454
455
  key: evt.key,
455
456
  ctrlKey: evt.ctrlKey,
457
+ altKey: evt.altKey,
456
458
  })) {
457
459
  evt.preventDefault();
458
460
  }
@@ -598,16 +600,19 @@ export class Editor {
598
600
  }
599
601
  // Dispatch a keyboard event to the currently selected tool.
600
602
  // Intended for unit testing
601
- sendKeyboardEvent(eventType, key, ctrlKey = false) {
603
+ sendKeyboardEvent(eventType, key, ctrlKey = false, altKey = false) {
602
604
  this.toolController.dispatchInputEvent({
603
605
  kind: eventType,
604
606
  key,
605
- ctrlKey
607
+ ctrlKey,
608
+ altKey,
606
609
  });
607
610
  }
608
611
  // Dispatch a pen event to the currently selected tool.
609
612
  // Intended primarially for unit tests.
610
- sendPenEvent(eventType, point, allPointers) {
613
+ sendPenEvent(eventType, point,
614
+ // @deprecated
615
+ allPointers) {
611
616
  const mainPointer = Pointer.ofCanvasPoint(point, eventType !== InputEvtType.PointerUpEvt, this.viewport);
612
617
  this.toolController.dispatchInputEvent({
613
618
  kind: eventType,
@@ -48,6 +48,7 @@ export default class AbstractComponent {
48
48
  transformBy(affineTransfm) {
49
49
  return new AbstractComponent.TransformElementCommand(affineTransfm, this);
50
50
  }
51
+ // Returns a copy of this component.
51
52
  clone() {
52
53
  const clone = this.createClone();
53
54
  for (const attachmentKey in this.loadSaveData) {
@@ -6,7 +6,8 @@ export default class Stroke extends AbstractComponent {
6
6
  constructor(parts) {
7
7
  var _a;
8
8
  super('stroke');
9
- this.parts = parts.map((section) => {
9
+ this.parts = [];
10
+ for (const section of parts) {
10
11
  const path = Path.fromRenderable(section);
11
12
  const pathBBox = this.bboxForPart(path.bbox, section.style);
12
13
  if (!this.contentBBox) {
@@ -15,14 +16,14 @@ export default class Stroke extends AbstractComponent {
15
16
  else {
16
17
  this.contentBBox = this.contentBBox.union(pathBBox);
17
18
  }
18
- return {
19
+ this.parts.push({
19
20
  path,
20
21
  // To implement RenderablePathSpec
21
22
  startPoint: path.startPoint,
22
23
  style: section.style,
23
24
  commands: path.parts,
24
- };
25
- });
25
+ });
26
+ }
26
27
  (_a = this.contentBBox) !== null && _a !== void 0 ? _a : (this.contentBBox = Rect2.empty);
27
28
  }
28
29
  intersects(line) {
@@ -80,11 +81,16 @@ export default class Stroke extends AbstractComponent {
80
81
  });
81
82
  }
82
83
  getPath() {
83
- var _a;
84
- return (_a = this.parts.reduce((accumulator, current) => {
85
- var _a;
86
- return (_a = accumulator === null || accumulator === void 0 ? void 0 : accumulator.union(current.path)) !== null && _a !== void 0 ? _a : current.path;
87
- }, null)) !== null && _a !== void 0 ? _a : Path.empty;
84
+ let result = null;
85
+ for (const part of this.parts) {
86
+ if (result) {
87
+ result = result.union(part.path);
88
+ }
89
+ else {
90
+ result !== null && result !== void 0 ? result : (result = part.path);
91
+ }
92
+ }
93
+ return result !== null && result !== void 0 ? result : Path.empty;
88
94
  }
89
95
  description(localization) {
90
96
  return localization.stroke;
@@ -29,7 +29,7 @@ export default class Text extends AbstractComponent {
29
29
  intersects(lineSegment: LineSegment2): boolean;
30
30
  protected applyTransformation(affineTransfm: Mat33): void;
31
31
  protected createClone(): AbstractComponent;
32
- private getText;
32
+ getText(): string;
33
33
  description(localizationTable: ImageComponentLocalization): string;
34
34
  protected serializeToJSON(): Record<string, any>;
35
35
  static deserializeFromString(json: any, getTextDimens?: GetTextDimensCallback): Text;
@@ -113,7 +113,7 @@ export default class Text extends AbstractComponent {
113
113
  result.push(textObject.getText());
114
114
  }
115
115
  }
116
- return result.join(' ');
116
+ return result.join('\n');
117
117
  }
118
118
  description(localizationTable) {
119
119
  return localizationTable.text(this.getText());
@@ -29,6 +29,7 @@ export default class FreehandLineBuilder implements ComponentBuilder {
29
29
  preview(renderer: AbstractRenderer): void;
30
30
  build(): Stroke;
31
31
  private roundPoint;
32
+ private approxCurrentCurveLength;
32
33
  private finalizeCurrentCurve;
33
34
  private currentSegmentToPath;
34
35
  private computeExitingVec;
@@ -117,7 +117,7 @@ export default class FreehandLineBuilder {
117
117
  }
118
118
  }
119
119
  build() {
120
- if (this.lastPoint) {
120
+ if (this.lastPoint && (this.lowerSegments.length === 0 || this.approxCurrentCurveLength() > this.curveStartWidth * 2)) {
121
121
  this.finalizeCurrentCurve();
122
122
  }
123
123
  return this.previewStroke();
@@ -129,6 +129,18 @@ export default class FreehandLineBuilder {
129
129
  }
130
130
  return Viewport.roundPoint(point, minFit);
131
131
  }
132
+ // Returns the distance between the start, control, and end points of the curve.
133
+ approxCurrentCurveLength() {
134
+ if (!this.currentCurve) {
135
+ return 0;
136
+ }
137
+ const startPt = Vec2.ofXY(this.currentCurve.points[0]);
138
+ const controlPt = Vec2.ofXY(this.currentCurve.points[1]);
139
+ const endPt = Vec2.ofXY(this.currentCurve.points[2]);
140
+ const toControlDist = startPt.minus(controlPt).length();
141
+ const toEndDist = endPt.minus(controlPt).length();
142
+ return toControlDist + toEndDist;
143
+ }
132
144
  finalizeCurrentCurve() {
133
145
  // Case where no points have been added
134
146
  if (!this.currentCurve) {
@@ -204,10 +216,8 @@ export default class FreehandLineBuilder {
204
216
  let endVec = Vec2.ofXY(this.currentCurve.normal(1)).normalized();
205
217
  startVec = startVec.times(this.curveStartWidth / 2);
206
218
  endVec = endVec.times(this.curveEndWidth / 2);
207
- if (isNaN(startVec.magnitude())) {
208
- // TODO: This can happen when events are too close together. Find out why and
209
- // fix.
210
- console.error('startVec is NaN', startVec, endVec, this.currentCurve);
219
+ if (!isFinite(startVec.magnitude())) {
220
+ console.error('Warning: startVec is NaN or ∞', startVec, endVec, this.currentCurve);
211
221
  startVec = endVec;
212
222
  }
213
223
  const startPt = Vec2.ofXY(this.currentCurve.get(0));
@@ -224,28 +234,18 @@ export default class FreehandLineBuilder {
224
234
  }
225
235
  }
226
236
  const halfVecT = projectionT;
227
- let halfVec = Vec2.ofXY(this.currentCurve.normal(halfVecT))
237
+ const halfVec = Vec2.ofXY(this.currentCurve.normal(halfVecT))
228
238
  .normalized().times(this.curveStartWidth / 2 * halfVecT
229
239
  + this.curveEndWidth / 2 * (1 - halfVecT));
230
- // Computes a boundary curve. [direction] should be either +1 or -1 (determines the side
231
- // of the center curve to place the boundary).
232
- const computeBoundaryCurve = (direction, halfVec) => {
233
- return new Bezier(startPt.plus(startVec.times(direction)), controlPoint.plus(halfVec.times(direction)), endPt.plus(endVec.times(direction)));
234
- };
235
- const boundariesIntersect = () => {
236
- const upperBoundary = computeBoundaryCurve(1, halfVec);
237
- const lowerBoundary = computeBoundaryCurve(-1, halfVec);
238
- return upperBoundary.intersects(lowerBoundary).length > 0;
239
- };
240
- // If the boundaries have intersections, increasing the half vector's length could fix this.
241
- if (boundariesIntersect()) {
242
- halfVec = halfVec.times(1.1);
243
- }
244
240
  // Each starts at startPt ± startVec
241
+ const lowerCurveControlPoint = this.roundPoint(controlPoint.plus(halfVec));
242
+ const lowerCurveEndPoint = this.roundPoint(endPt.plus(endVec));
243
+ const upperCurveControlPoint = this.roundPoint(controlPoint.minus(halfVec));
244
+ const upperCurveStartPoint = this.roundPoint(endPt.minus(endVec));
245
245
  const lowerCurve = {
246
246
  kind: PathCommandType.QuadraticBezierTo,
247
- controlPoint: this.roundPoint(controlPoint.plus(halfVec)),
248
- endPoint: this.roundPoint(endPt.plus(endVec)),
247
+ controlPoint: lowerCurveControlPoint,
248
+ endPoint: lowerCurveEndPoint,
249
249
  };
250
250
  // From the end of the upperCurve to the start of the lowerCurve:
251
251
  const upperToLowerConnector = {
@@ -255,11 +255,11 @@ export default class FreehandLineBuilder {
255
255
  // From the end of lowerCurve to the start of upperCurve:
256
256
  const lowerToUpperConnector = {
257
257
  kind: PathCommandType.LineTo,
258
- point: this.roundPoint(endPt.minus(endVec))
258
+ point: upperCurveStartPoint,
259
259
  };
260
260
  const upperCurve = {
261
261
  kind: PathCommandType.QuadraticBezierTo,
262
- controlPoint: this.roundPoint(controlPoint.minus(halfVec)),
262
+ controlPoint: upperCurveControlPoint,
263
263
  endPoint: this.roundPoint(startPt.minus(startVec)),
264
264
  };
265
265
  return { upperCurve, upperToLowerConnector, lowerToUpperConnector, lowerCurve };
@@ -275,7 +275,6 @@ export default class FreehandLineBuilder {
275
275
  const fuzzEq = 1e-10;
276
276
  const deltaTime = newPoint.time - this.lastPoint.time;
277
277
  if (newPoint.pos.eq(this.lastPoint.pos, fuzzEq) || deltaTime === 0) {
278
- console.warn('Discarding identical point');
279
278
  return;
280
279
  }
281
280
  else if (isNaN(newPoint.pos.magnitude())) {
@@ -327,29 +326,29 @@ export default class FreehandLineBuilder {
327
326
  const startEndDist = segmentEnd.minus(segmentStart).magnitude();
328
327
  const maxControlPointDist = maxRelativeLength * startEndDist;
329
328
  // Exit in cases where we would divide by zero
330
- if (maxControlPointDist === 0 || exitingVec.magnitude() === 0 || isNaN(exitingVec.magnitude())) {
329
+ if (maxControlPointDist === 0 || exitingVec.magnitude() === 0 || !isFinite(exitingVec.magnitude())) {
331
330
  return;
332
331
  }
333
- console.assert(!isNaN(enteringVec.magnitude()));
332
+ console.assert(isFinite(enteringVec.magnitude()), 'Pre-normalized enteringVec has NaN or ∞ magnitude!');
334
333
  enteringVec = enteringVec.normalized();
335
334
  exitingVec = exitingVec.normalized();
336
- console.assert(!isNaN(enteringVec.magnitude()));
335
+ console.assert(isFinite(enteringVec.magnitude()), 'Normalized enteringVec has NaN or ∞ magnitude!');
337
336
  const lineFromStart = new LineSegment2(segmentStart, segmentStart.plus(enteringVec.times(maxControlPointDist)));
338
337
  const lineFromEnd = new LineSegment2(segmentEnd.minus(exitingVec.times(maxControlPointDist)), segmentEnd);
339
338
  const intersection = lineFromEnd.intersection(lineFromStart);
340
339
  // Position the control point at this intersection
341
- let controlPoint;
340
+ let controlPoint = null;
342
341
  if (intersection) {
343
342
  controlPoint = intersection.point;
344
343
  }
345
- else {
344
+ // No intersection or the intersection is one of the end points?
345
+ if (!controlPoint || segmentStart.eq(controlPoint) || segmentEnd.eq(controlPoint)) {
346
346
  // Position the control point closer to the first -- the connecting
347
347
  // segment will be roughly a line.
348
348
  controlPoint = segmentStart.plus(enteringVec.times(startEndDist / 4));
349
349
  }
350
- if (isNaN(controlPoint.magnitude()) || isNaN(segmentStart.magnitude())) {
351
- console.error('controlPoint is NaN', intersection, 'Start:', segmentStart, 'End:', segmentEnd, 'in:', enteringVec, 'out:', exitingVec);
352
- }
350
+ console.assert(!segmentStart.eq(controlPoint, 1e-11), 'Start and control points are equal!');
351
+ console.assert(!controlPoint.eq(segmentEnd, 1e-11), 'Control and end points are equal!');
353
352
  const prevCurve = this.currentCurve;
354
353
  this.currentCurve = new Bezier(segmentStart.xy, controlPoint.xy, segmentEnd.xy);
355
354
  if (isNaN(Vec2.ofXY(this.currentCurve.normal(0)).magnitude())) {
@@ -369,8 +368,7 @@ export default class FreehandLineBuilder {
369
368
  }
370
369
  return true;
371
370
  };
372
- const approxCurveLen = controlPoint.minus(segmentStart).magnitude() + segmentEnd.minus(controlPoint).magnitude();
373
- if (this.buffer.length > 3 && approxCurveLen > this.curveEndWidth / 3) {
371
+ if (this.buffer.length > 3 && this.approxCurrentCurveLength() > this.curveStartWidth) {
374
372
  if (!curveMatchesPoints(this.currentCurve)) {
375
373
  // Use a curve that better fits the points
376
374
  this.currentCurve = prevCurve;
@@ -97,7 +97,7 @@ export default class Vec3 {
97
97
  * Vec3.of(1, 2, 3).eq(Vec3.of(4, 5, 6), 2.99); // → false
98
98
  * ```
99
99
  */
100
- eq(other: Vec3, fuzz: number): boolean;
100
+ eq(other: Vec3, fuzz?: number): boolean;
101
101
  toString(): string;
102
102
  static unitX: Vec3;
103
103
  static unitY: Vec3;
@@ -156,7 +156,7 @@ export default class Vec3 {
156
156
  * Vec3.of(1, 2, 3).eq(Vec3.of(4, 5, 6), 2.99); // → false
157
157
  * ```
158
158
  */
159
- eq(other, fuzz) {
159
+ eq(other, fuzz = 1e-10) {
160
160
  for (let i = 0; i < 3; i++) {
161
161
  if (Math.abs(other.at(i) - this.at(i)) > fuzz) {
162
162
  return false;
@@ -72,7 +72,7 @@ export default class SVGRenderer extends AbstractRenderer {
72
72
  drawPath(pathSpec) {
73
73
  var _a;
74
74
  const style = pathSpec.style;
75
- const path = Path.fromRenderable(pathSpec);
75
+ const path = Path.fromRenderable(pathSpec).transformedBy(this.getCanvasToScreenTransform());
76
76
  // Try to extend the previous path, if possible
77
77
  if (!style.fill.eq((_a = this.lastPathStyle) === null || _a === void 0 ? void 0 : _a.fill) || this.lastPathString.length === 0) {
78
78
  this.addPathToSVG();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import loadExpectExtensions from './loadExpectExtensions';
2
+ loadExpectExtensions();
3
+ jest.useFakeTimers();
@@ -1,3 +1,4 @@
1
1
  import Editor from '../Editor';
2
+ /** Creates an editor. Should only be used in test files. */
2
3
  declare const _default: () => Editor;
3
4
  export default _default;
@@ -1,3 +1,9 @@
1
1
  import { RenderingMode } from '../rendering/Display';
2
2
  import Editor from '../Editor';
3
- export default () => new Editor(document.body, { renderingMode: RenderingMode.DummyRenderer });
3
+ /** Creates an editor. Should only be used in test files. */
4
+ export default () => {
5
+ if (jest === undefined) {
6
+ throw new Error('Files in the testing/ folder should only be used in tests!');
7
+ }
8
+ return new Editor(document.body, { renderingMode: RenderingMode.DummyRenderer });
9
+ };
@@ -1,17 +1,2 @@
1
1
  export declare const loadExpectExtensions: () => void;
2
- export interface CustomMatchers<R = unknown> {
3
- objEq(expected: {
4
- eq: (other: any, ...args: any) => boolean;
5
- }, ...opts: any): R;
6
- }
7
- declare global {
8
- export namespace jest {
9
- interface Expect extends CustomMatchers {
10
- }
11
- interface Matchers<R> extends CustomMatchers<R> {
12
- }
13
- interface AsyncAsymmetricMatchers extends CustomMatchers {
14
- }
15
- }
16
- }
17
2
  export default loadExpectExtensions;
@@ -1,4 +1,5 @@
1
1
  import Editor from '../../Editor';
2
+ import { KeyPressEvent } from '../../types';
2
3
  import { ToolbarLocalization } from '../localization';
3
4
  export default abstract class BaseWidget {
4
5
  #private;
@@ -18,6 +19,7 @@ export default abstract class BaseWidget {
18
19
  protected abstract createIcon(): Element;
19
20
  protected fillDropdown(dropdown: HTMLElement): boolean;
20
21
  protected setupActionBtnClickListener(button: HTMLElement): void;
22
+ protected onKeyPress(_event: KeyPressEvent): boolean;
21
23
  protected abstract handleClick(): void;
22
24
  protected get hasDropdown(): boolean;
23
25
  protected addSubWidget(widget: BaseWidget): void;
@@ -10,6 +10,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
10
10
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
11
  };
12
12
  var _BaseWidget_hasDropdown;
13
+ import ToolbarShortcutHandler from '../../tools/ToolbarShortcutHandler';
13
14
  import { EditorEventType, InputEvtType } from '../../types';
14
15
  import { toolbarCSSPrefix } from '../HTMLToolbar';
15
16
  import { makeDropdownIcon } from '../icons';
@@ -33,6 +34,12 @@ export default class BaseWidget {
33
34
  this.label = document.createElement('label');
34
35
  this.button.setAttribute('role', 'button');
35
36
  this.button.tabIndex = 0;
37
+ const toolbarShortcutHandlers = this.editor.toolController.getMatchingTools(ToolbarShortcutHandler);
38
+ // If the onKeyPress function has been extended and the editor is configured to send keypress events to
39
+ // toolbar widgets,
40
+ if (toolbarShortcutHandlers.length > 0 && this.onKeyPress !== BaseWidget.prototype.onKeyPress) {
41
+ toolbarShortcutHandlers[0].registerListener(event => this.onKeyPress(event));
42
+ }
36
43
  }
37
44
  // Add content to the widget's associated dropdown menu.
38
45
  // Returns true if such a menu should be created, false otherwise.
@@ -62,6 +69,7 @@ export default class BaseWidget {
62
69
  kind: InputEvtType.KeyPressEvent,
63
70
  key: evt.key,
64
71
  ctrlKey: evt.ctrlKey,
72
+ altKey: evt.altKey,
65
73
  });
66
74
  }
67
75
  };
@@ -73,6 +81,7 @@ export default class BaseWidget {
73
81
  kind: InputEvtType.KeyUpEvent,
74
82
  key: evt.key,
75
83
  ctrlKey: evt.ctrlKey,
84
+ altKey: evt.altKey,
76
85
  });
77
86
  };
78
87
  button.onclick = () => {
@@ -81,6 +90,12 @@ export default class BaseWidget {
81
90
  }
82
91
  };
83
92
  }
93
+ // Add a listener that is triggered when a key is pressed.
94
+ // Listeners will fire regardless of whether this widget is selected and require that
95
+ // {@link lib!Editor.toolController} to have an enabled {@link lib!ToolbarShortcutHandler} tool.
96
+ onKeyPress(_event) {
97
+ return false;
98
+ }
84
99
  get hasDropdown() {
85
100
  return __classPrivateFieldGet(this, _BaseWidget_hasDropdown, "f");
86
101
  }
@@ -1,6 +1,7 @@
1
1
  import { ComponentBuilderFactory } from '../../components/builders/types';
2
2
  import Editor from '../../Editor';
3
3
  import Pen from '../../tools/Pen';
4
+ import { KeyPressEvent } from '../../types';
4
5
  import { ToolbarLocalization } from '../localization';
5
6
  import BaseToolWidget from './BaseToolWidget';
6
7
  export interface PenTypeRecord {
@@ -16,4 +17,5 @@ export default class PenToolWidget extends BaseToolWidget {
16
17
  protected createIcon(): Element;
17
18
  private static idCounter;
18
19
  protected fillDropdown(dropdown: HTMLElement): boolean;
20
+ protected onKeyPress(event: KeyPressEvent): boolean;
19
21
  }
@@ -127,5 +127,19 @@ export default class PenToolWidget extends BaseToolWidget {
127
127
  dropdown.replaceChildren(container);
128
128
  return true;
129
129
  }
130
+ onKeyPress(event) {
131
+ if (!this.isSelected()) {
132
+ return false;
133
+ }
134
+ // Map alt+0-9 to different pen types.
135
+ if (/^[0-9]$/.exec(event.key) && event.ctrlKey) {
136
+ const penTypeIdx = parseInt(event.key) - 1;
137
+ if (penTypeIdx >= 0 && penTypeIdx < this.penTypes.length) {
138
+ this.tool.setStrokeFactory(this.penTypes[penTypeIdx].factory);
139
+ return true;
140
+ }
141
+ }
142
+ return false;
143
+ }
130
144
  }
131
145
  PenToolWidget.idCounter = 0;
@@ -8,6 +8,7 @@ import Viewport from '../../Viewport';
8
8
  import BaseTool from '../BaseTool';
9
9
  import SVGRenderer from '../../rendering/renderers/SVGRenderer';
10
10
  import Selection from './Selection';
11
+ import TextComponent from '../../components/Text';
11
12
  export const cssPrefix = 'selection-tool-';
12
13
  // {@inheritDoc SelectionTool!}
13
14
  export default class SelectionTool extends BaseTool {
@@ -215,10 +216,17 @@ export default class SelectionTool extends BaseTool {
215
216
  const exportElem = document.createElementNS(svgNameSpace, 'svg');
216
217
  const sanitize = true;
217
218
  const renderer = new SVGRenderer(exportElem, exportViewport, sanitize);
219
+ const text = [];
218
220
  for (const elem of selectedElems) {
219
221
  elem.render(renderer);
222
+ if (elem instanceof TextComponent) {
223
+ text.push(elem.getText());
224
+ }
220
225
  }
221
226
  event.setData('image/svg+xml', exportElem.outerHTML);
227
+ if (text.length > 0) {
228
+ event.setData('text/plain', text.join('\n'));
229
+ }
222
230
  return true;
223
231
  }
224
232
  setEnabled(enabled) {
@@ -10,6 +10,7 @@ import TextTool from './TextTool';
10
10
  import PipetteTool from './PipetteTool';
11
11
  import ToolSwitcherShortcut from './ToolSwitcherShortcut';
12
12
  import PasteHandler from './PasteHandler';
13
+ import ToolbarShortcutHandler from './ToolbarShortcutHandler';
13
14
  export default class ToolController {
14
15
  /** @internal */
15
16
  constructor(editor, localization) {
@@ -35,6 +36,7 @@ export default class ToolController {
35
36
  ...primaryTools,
36
37
  keyboardPanZoomTool,
37
38
  new UndoRedoShortcut(editor),
39
+ new ToolbarShortcutHandler(editor),
38
40
  new ToolSwitcherShortcut(editor),
39
41
  new PasteHandler(editor),
40
42
  ];
@@ -0,0 +1,12 @@
1
+ import Editor from '../Editor';
2
+ import { KeyPressEvent } from '../types';
3
+ import BaseTool from './BaseTool';
4
+ declare type KeyPressListener = (event: KeyPressEvent) => boolean;
5
+ export default class ToolbarShortcutHandler extends BaseTool {
6
+ private listeners;
7
+ constructor(editor: Editor);
8
+ registerListener(listener: KeyPressListener): void;
9
+ removeListener(listener: KeyPressListener): void;
10
+ onKeyPress(event: KeyPressEvent): boolean;
11
+ }
12
+ export {};
@@ -0,0 +1,23 @@
1
+ // Allows the toolbar to register keyboard events.
2
+ // @packageDocumentation
3
+ import BaseTool from './BaseTool';
4
+ export default class ToolbarShortcutHandler extends BaseTool {
5
+ constructor(editor) {
6
+ super(editor.notifier, editor.localization.changeTool);
7
+ this.listeners = new Set([]);
8
+ }
9
+ registerListener(listener) {
10
+ this.listeners.add(listener);
11
+ }
12
+ removeListener(listener) {
13
+ this.listeners.delete(listener);
14
+ }
15
+ onKeyPress(event) {
16
+ for (const listener of this.listeners) {
17
+ if (listener(event)) {
18
+ return true;
19
+ }
20
+ }
21
+ return false;
22
+ }
23
+ }
@@ -12,3 +12,4 @@ export { default as TextTool } from './TextTool';
12
12
  export { default as SelectionTool } from './SelectionTool/SelectionTool';
13
13
  export { default as EraserTool } from './Eraser';
14
14
  export { default as PasteHandler } from './PasteHandler';
15
+ export { default as ToolbarShortcutHandler } from './ToolbarShortcutHandler';
@@ -12,3 +12,4 @@ export { default as TextTool } from './TextTool';
12
12
  export { default as SelectionTool } from './SelectionTool/SelectionTool';
13
13
  export { default as EraserTool } from './Eraser';
14
14
  export { default as PasteHandler } from './PasteHandler';
15
+ export { default as ToolbarShortcutHandler } from './ToolbarShortcutHandler';
@@ -34,12 +34,14 @@ export interface WheelEvt {
34
34
  export interface KeyPressEvent {
35
35
  readonly kind: InputEvtType.KeyPressEvent;
36
36
  readonly key: string;
37
- readonly ctrlKey: boolean;
37
+ readonly ctrlKey: boolean | undefined;
38
+ readonly altKey: boolean | undefined;
38
39
  }
39
40
  export interface KeyUpEvent {
40
41
  readonly kind: InputEvtType.KeyUpEvent;
41
42
  readonly key: string;
42
- readonly ctrlKey: boolean;
43
+ readonly ctrlKey: boolean | undefined;
44
+ readonly altKey: boolean | undefined;
43
45
  }
44
46
  export interface CopyEvent {
45
47
  readonly kind: InputEvtType.CopyEvent;
package/jest.config.js CHANGED
@@ -10,6 +10,10 @@ const config = {
10
10
  'js',
11
11
  ],
12
12
 
13
+ testPathIgnorePatterns: [
14
+ '<rootDir>/dist/', '<rootDir>/node_modules/'
15
+ ],
16
+
13
17
  // Mocks.
14
18
  // See https://jestjs.io/docs/webpack#handling-static-assets
15
19
  moduleNameMapper: {
@@ -19,6 +23,7 @@ const config = {
19
23
  },
20
24
 
21
25
  testEnvironment: 'jsdom',
26
+ setupFilesAfterEnv: [ '<rootDir>/src/testing/beforeEachFile.ts' ],
22
27
  };
23
28
 
24
29
  module.exports = config;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "js-draw",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript. ",
5
5
  "main": "./dist/src/lib.d.ts",
6
6
  "types": "./dist/src/lib.js",
@@ -71,36 +71,37 @@
71
71
  "lint": "eslint .",
72
72
  "linter-precommit": "eslint --fix --ext .js --ext .ts",
73
73
  "lint-staged": "lint-staged",
74
+ "lint-ci": "eslint . --max-warnings=0 --ext .js --ext .ts",
74
75
  "prepare": "husky install && yarn build",
75
76
  "prepack": "yarn build && yarn test && pinst --disable",
76
77
  "postpack": "pinst --enable"
77
78
  },
78
79
  "dependencies": {
79
- "@melloware/coloris": "^0.16.0",
80
+ "@melloware/coloris": "^0.16.1",
80
81
  "bezier-js": "^6.1.0"
81
82
  },
82
83
  "devDependencies": {
83
84
  "@types/bezier-js": "^4.1.0",
84
- "@types/jest": "^28.1.7",
85
+ "@types/jest": "^29.0.3",
85
86
  "@types/jsdom": "^20.0.0",
86
- "@types/node": "^18.7.15",
87
- "@typescript-eslint/eslint-plugin": "^5.36.2",
88
- "@typescript-eslint/parser": "^5.36.2",
87
+ "@types/node": "^18.7.23",
88
+ "@typescript-eslint/eslint-plugin": "^5.38.1",
89
+ "@typescript-eslint/parser": "^5.38.1",
89
90
  "css-loader": "^6.7.1",
90
- "eslint": "^8.23.0",
91
+ "eslint": "^8.24.0",
91
92
  "husky": "^8.0.1",
92
- "jest": "^28.1.3",
93
- "jest-environment-jsdom": "^29.0.2",
93
+ "jest": "^29.0.3",
94
+ "jest-environment-jsdom": "^29.0.3",
94
95
  "jsdom": "^20.0.0",
95
96
  "lint-staged": "^13.0.3",
96
97
  "pinst": "^3.0.0",
97
98
  "style-loader": "^3.3.1",
98
- "terser-webpack-plugin": "^5.3.5",
99
- "ts-jest": "^28.0.8",
100
- "ts-loader": "^9.3.1",
99
+ "terser-webpack-plugin": "^5.3.6",
100
+ "ts-jest": "^29.0.2",
101
+ "ts-loader": "^9.4.1",
101
102
  "ts-node": "^10.9.1",
102
- "typedoc": "^0.23.14",
103
- "typescript": "^4.8.2",
103
+ "typedoc": "^0.23.15",
104
+ "typescript": "^4.8.3",
104
105
  "webpack": "^5.74.0"
105
106
  },
106
107
  "bugs": {