js-draw 0.12.0 → 0.13.1

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 (73) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/bundle.js +1 -1
  3. package/dist/src/Color4.d.ts +12 -0
  4. package/dist/src/Color4.js +16 -0
  5. package/dist/src/Editor.d.ts +33 -18
  6. package/dist/src/Editor.js +22 -19
  7. package/dist/src/EditorImage.d.ts +12 -0
  8. package/dist/src/EditorImage.js +12 -0
  9. package/dist/src/Pointer.d.ts +1 -0
  10. package/dist/src/Pointer.js +8 -0
  11. package/dist/src/SVGLoader.d.ts +5 -0
  12. package/dist/src/SVGLoader.js +6 -1
  13. package/dist/src/Viewport.d.ts +30 -1
  14. package/dist/src/Viewport.js +39 -9
  15. package/dist/src/commands/invertCommand.js +1 -1
  16. package/dist/src/components/AbstractComponent.d.ts +19 -0
  17. package/dist/src/components/AbstractComponent.js +17 -2
  18. package/dist/src/lib.d.ts +6 -3
  19. package/dist/src/lib.js +4 -1
  20. package/dist/src/math/Mat33.d.ts +1 -1
  21. package/dist/src/math/Mat33.js +1 -1
  22. package/dist/src/rendering/Display.d.ts +9 -11
  23. package/dist/src/rendering/Display.js +12 -14
  24. package/dist/src/rendering/lib.d.ts +3 -0
  25. package/dist/src/rendering/lib.js +3 -0
  26. package/dist/src/rendering/renderers/DummyRenderer.js +2 -2
  27. package/dist/src/rendering/renderers/SVGRenderer.js +4 -0
  28. package/dist/src/toolbar/IconProvider.d.ts +1 -1
  29. package/dist/src/toolbar/IconProvider.js +90 -29
  30. package/dist/src/toolbar/makeColorInput.js +8 -1
  31. package/dist/src/tools/PanZoom.d.ts +3 -1
  32. package/dist/src/tools/PanZoom.js +31 -6
  33. package/dist/src/tools/PasteHandler.d.ts +11 -4
  34. package/dist/src/tools/PasteHandler.js +12 -5
  35. package/dist/src/tools/Pen.d.ts +7 -2
  36. package/dist/src/tools/Pen.js +39 -6
  37. package/dist/src/tools/SelectionTool/SelectionHandle.d.ts +3 -0
  38. package/dist/src/tools/SelectionTool/SelectionHandle.js +6 -0
  39. package/dist/src/tools/SelectionTool/SelectionTool.d.ts +3 -1
  40. package/dist/src/tools/SelectionTool/SelectionTool.js +53 -15
  41. package/dist/src/tools/ToolSwitcherShortcut.d.ts +8 -0
  42. package/dist/src/tools/ToolSwitcherShortcut.js +9 -3
  43. package/dist/src/tools/UndoRedoShortcut.js +2 -4
  44. package/package.json +2 -2
  45. package/src/Color4.test.ts +11 -0
  46. package/src/Color4.ts +22 -0
  47. package/src/Editor.ts +36 -22
  48. package/src/EditorImage.ts +12 -0
  49. package/src/Pointer.ts +19 -0
  50. package/src/SVGLoader.ts +6 -1
  51. package/src/Viewport.ts +50 -11
  52. package/src/commands/invertCommand.ts +1 -1
  53. package/src/components/AbstractComponent.ts +33 -2
  54. package/src/lib.ts +6 -3
  55. package/src/math/Mat33.ts +1 -1
  56. package/src/rendering/Display.ts +12 -15
  57. package/src/rendering/RenderingStyle.ts +1 -1
  58. package/src/rendering/lib.ts +4 -0
  59. package/src/rendering/renderers/DummyRenderer.ts +2 -3
  60. package/src/rendering/renderers/SVGRenderer.ts +4 -0
  61. package/src/rendering/renderers/TextOnlyRenderer.ts +0 -1
  62. package/src/toolbar/HTMLToolbar.ts +1 -1
  63. package/src/toolbar/IconProvider.ts +98 -31
  64. package/src/toolbar/makeColorInput.ts +9 -1
  65. package/src/tools/PanZoom.ts +37 -7
  66. package/src/tools/PasteHandler.ts +12 -6
  67. package/src/tools/Pen.test.ts +44 -1
  68. package/src/tools/Pen.ts +53 -8
  69. package/src/tools/SelectionTool/SelectionHandle.ts +9 -0
  70. package/src/tools/SelectionTool/SelectionTool.ts +67 -15
  71. package/src/tools/ToolSwitcherShortcut.ts +10 -5
  72. package/src/tools/UndoRedoShortcut.ts +2 -5
  73. package/typedoc.json +2 -2
package/src/math/Mat33.ts CHANGED
@@ -338,7 +338,7 @@ export default class Mat33 {
338
338
  return result.rightMul(Mat33.translation(center.times(-1)));
339
339
  }
340
340
 
341
- /** @see {@link !fromCSSMatrix} */
341
+ /** @see {@link fromCSSMatrix} */
342
342
  public toCSSMatrix(): string {
343
343
  return `matrix(${this.a1},${this.b1},${this.a2},${this.b2},${this.a3},${this.b3})`;
344
344
  }
@@ -1,18 +1,3 @@
1
- /**
2
- * Handles `HTMLCanvasElement`s (or other drawing surfaces if being used) used to display the editor's contents.
3
- *
4
- * @example
5
- * ```
6
- * const editor = new Editor(document.body);
7
- * const w = editor.display.width;
8
- * const h = editor.display.height;
9
- * const center = Vec2.of(w / 2, h / 2);
10
- * const colorAtCenter = editor.display.getColorAt(center);
11
- * ```
12
- *
13
- * @packageDocumentation
14
- */
15
-
16
1
  import AbstractRenderer from './renderers/AbstractRenderer';
17
2
  import CanvasRenderer from './renderers/CanvasRenderer';
18
3
  import { Editor } from '../Editor';
@@ -29,6 +14,18 @@ export enum RenderingMode {
29
14
  // SVGRenderer is not supported by the main display
30
15
  }
31
16
 
17
+ /**
18
+ * Handles `HTMLCanvasElement`s (or other drawing surfaces if being used) used to display the editor's contents.
19
+ *
20
+ * @example
21
+ * ```
22
+ * const editor = new Editor(document.body);
23
+ * const w = editor.display.width;
24
+ * const h = editor.display.height;
25
+ * const center = Vec2.of(w / 2, h / 2);
26
+ * const colorAtCenter = editor.display.getColorAt(center);
27
+ * ```
28
+ */
32
29
  export default class Display {
33
30
  private dryInkRenderer: AbstractRenderer;
34
31
  private wetInkRenderer: AbstractRenderer;
@@ -13,7 +13,7 @@ export default RenderingStyle;
13
13
  export const stylesEqual = (a: RenderingStyle, b: RenderingStyle): boolean => {
14
14
  const result = a === b || (a.fill.eq(b.fill)
15
15
  && (a.stroke == undefined) === (b.stroke == undefined)
16
- && (a.stroke?.color?.eq(b.stroke?.color) ?? true)
16
+ && (a.stroke?.color?.eq(b.stroke?.color) ?? true)
17
17
  && a.stroke?.width === b.stroke?.width);
18
18
 
19
19
  // Map undefined/null -> false
@@ -0,0 +1,4 @@
1
+
2
+ export { default as AbstractRenderer } from './renderers/AbstractRenderer';
3
+ export { default as DummyRenderer } from './renderers/DummyRenderer';
4
+ export { default as Display } from './Display';
@@ -1,5 +1,3 @@
1
- // Renderer that outputs nothing. Useful for automated tests.
2
-
3
1
  import { TextStyle } from '../../components/TextComponent';
4
2
  import Mat33 from '../../math/Mat33';
5
3
  import Rect2 from '../../math/Rect2';
@@ -9,6 +7,7 @@ import Viewport from '../../Viewport';
9
7
  import RenderingStyle from '../RenderingStyle';
10
8
  import AbstractRenderer, { RenderableImage } from './AbstractRenderer';
11
9
 
10
+ // Renderer that outputs almost nothing. Useful for automated tests.
12
11
  export default class DummyRenderer extends AbstractRenderer {
13
12
  // Variables that track the state of what's been rendered
14
13
  public clearedCount: number = 0;
@@ -28,7 +27,7 @@ export default class DummyRenderer extends AbstractRenderer {
28
27
 
29
28
  public displaySize(): Vec2 {
30
29
  // Do we have a stored viewport size?
31
- const viewportSize = this.getViewport().getResolution();
30
+ const viewportSize = this.getViewport().getScreenRectSize();
32
31
 
33
32
  // Don't use a 0x0 viewport — DummyRenderer is often used
34
33
  // for tests that run without a display, so pretend we have a
@@ -38,6 +38,10 @@ export default class SVGRenderer extends AbstractRenderer {
38
38
  stroke-linecap: round;
39
39
  stroke-linejoin: round;
40
40
  }
41
+
42
+ text {
43
+ white-space: pre;
44
+ }
41
45
  `.replace(/\s+/g, '');
42
46
  styleSheet.setAttribute('id', renderedStylesheetId);
43
47
  this.elem.appendChild(styleSheet);
@@ -9,7 +9,6 @@ import RenderingStyle from '../RenderingStyle';
9
9
  import AbstractRenderer, { RenderableImage } from './AbstractRenderer';
10
10
 
11
11
  // Outputs a description of what was rendered.
12
-
13
12
  export default class TextOnlyRenderer extends AbstractRenderer {
14
13
  private descriptionBuilder: string[] = [];
15
14
  private pathCount: number = 0;
@@ -169,7 +169,7 @@ export default class HTMLToolbar {
169
169
  * toolbar.addSpacer({ grow: 1 });
170
170
  * toolbar.addDefaults();
171
171
  * toolbar.addSpacer({ grow: 1 });
172
- *
172
+ *
173
173
  * toolbar.addActionButton({
174
174
  * label: 'Save',
175
175
  * icon: editor.icons.makeSaveIcon(),
@@ -43,7 +43,7 @@ export default class IconProvider {
43
43
  return this.makeRedoIcon(true);
44
44
  }
45
45
 
46
- // @param mirror - reflect across the x-axis @internal
46
+ // @param mirror - reflect across the x-axis. This parameter is internal.
47
47
  // @returns a redo icon.
48
48
  public makeRedoIcon(mirror: boolean = false): IconType {
49
49
  const icon = document.createElementNS(svgNamespace, 'svg');
@@ -383,52 +383,119 @@ export default class IconProvider {
383
383
  return icon;
384
384
  }
385
385
 
386
- public makePenIcon(tipThickness: number, color: string|Color4, roundedTip?: boolean): IconType {
386
+ public makePenIcon(strokeSize: number, color: string|Color4, rounded?: boolean): IconType {
387
387
  if (color instanceof Color4) {
388
388
  color = color.toHexString();
389
389
  }
390
390
 
391
391
  const icon = document.createElementNS(svgNamespace, 'svg');
392
392
  icon.setAttribute('viewBox', '0 0 100 100');
393
-
394
- const halfThickness = tipThickness / 2;
395
-
396
- // Draw a pen-like shape
397
- const penTipLeft = 50 - halfThickness;
398
- const penTipRight = 50 + halfThickness;
393
+ const tipThickness = strokeSize / 2;
399
394
 
400
- let tipCenterPrimaryPath = `L${penTipLeft},95 L${penTipRight},90`;
401
- let tipCenterBackgroundPath = `L${penTipLeft},85 L${penTipRight},83`;
395
+ const inkTipPath = `
396
+ M ${15 - tipThickness},${80 - tipThickness}
397
+ ${15 - tipThickness},${80 + tipThickness}
398
+ 30,83
399
+ 15,65
400
+ Z
401
+ `;
402
+ const trailStartEndY = 80 + tipThickness;
403
+ const inkTrailPath = `
404
+ m ${15 - tipThickness * 1.1},${trailStartEndY}
405
+ c 35,10 55,15 60,30
406
+ l ${35 + tipThickness * 1.2},${-10 - tipThickness}
407
+ C 80.47,98.32 50.5,${90 + tipThickness} 20,${trailStartEndY} Z
408
+ `;
402
409
 
403
- if (roundedTip) {
404
- tipCenterPrimaryPath = `L${penTipLeft},95 q${halfThickness},10 ${2 * halfThickness},-5`;
405
- tipCenterBackgroundPath = `L${penTipLeft},87 q${halfThickness},10 ${2 * halfThickness},-3`;
410
+ const colorBubblePath = `
411
+ M 72.45,35.67
412
+ A 10,15 41.8 0 1 55,40.2 10,15 41.8 0 1 57.55,22.3 10,15 41.8 0 1 75,17.8 10,15 41.8 0 1 72.5,35.67
413
+ Z
414
+ `;
415
+
416
+ let gripMainPath = 'M 85,-25 25,35 h 10 v 10 h 10 v 10 h 10 v 10 h 10 l -5,10 60,-60 z';
417
+ let gripShadow1Path = 'M 25,35 H 35 L 90,-15 85,-25 Z';
418
+ let gripShadow2Path = 'M 60,75 65,65 H 55 l 55,-55 10,5 z';
419
+
420
+ if (rounded) {
421
+ gripMainPath = 'M 85,-25 25,35 c 15,0 40,30 35,40 l 60,-60 z';
422
+ gripShadow1Path = 'm 25,35 c 3.92361,0.384473 7.644275,0.980572 10,3 l 55,-53 -5,-10 z';
423
+ gripShadow2Path = 'M 60,75 C 61,66 59,65 56,59 l 54,-54 10,10 z';
406
424
  }
407
425
 
408
- const primaryStrokeTipPath = `M14,63 ${tipCenterPrimaryPath} L88,60 Z`;
409
- const backgroundStrokeTipPath = `M14,63 ${tipCenterBackgroundPath} L88,60 Z`;
426
+ const penTipPath = `M 25,35 ${10 - tipThickness / 4},${70 - tipThickness / 2} 20,75 25,85 60,75 70,55 45,25 Z`;
410
427
 
411
- icon.innerHTML = `
412
- <defs>
413
- ${checkerboardPatternDef}
414
- </defs>
415
- <g>
416
- <!-- Pen grip -->
428
+ const pencilTipColor = Color4.fromHex('#f4d7d7');
429
+ const tipColor = pencilTipColor.mix(
430
+ Color4.fromString(color), tipThickness / 40 - 0.1
431
+ ).toHexString();
432
+
433
+ const ink = `
434
+ <path
435
+ fill="${checkerboardPatternRef}"
436
+ d="${inkTipPath}"
437
+ />
438
+ <path
439
+ fill="${checkerboardPatternRef}"
440
+ d="${inkTrailPath}"
441
+ />
442
+ <path
443
+ fill="${color}"
444
+ d="${inkTipPath}"
445
+ />
446
+ <path
447
+ fill="${color}"
448
+ d="${inkTrailPath}"
449
+ />
450
+ `;
451
+
452
+ const penTip = `
453
+ <path
454
+ fill="${checkerboardPatternRef}"
455
+ d="${penTipPath}"
456
+ />
457
+ <path
458
+ fill="${tipColor}"
459
+ stroke="${color}"
460
+ d="${penTipPath}"
461
+ />
462
+ `;
463
+
464
+ const grip = `
417
465
  <path
418
- d='M10,10 L90,10 L90,60 L${50 + halfThickness},80 L${50 - halfThickness},80 L10,60 Z'
419
466
  ${iconColorStrokeFill}
467
+ d="${gripMainPath}"
468
+ />
469
+
470
+ <!-- shadows -->
471
+ <path
472
+ fill="rgba(150, 150, 150, 0.3)"
473
+ d="${gripShadow1Path}"
420
474
  />
421
- </g>
422
- <g>
423
- <!-- Checkerboard background for slightly transparent pens -->
424
- <path d='${backgroundStrokeTipPath}' fill='${checkerboardPatternRef}'/>
425
-
426
- <!-- Actual pen tip -->
427
475
  <path
428
- d='${primaryStrokeTipPath}'
429
- fill='${color}'
430
- stroke='${color}'
476
+ fill="rgba(100, 100, 100, 0.2)"
477
+ d="${gripShadow2Path}"
431
478
  />
479
+
480
+ <!-- color bubble -->
481
+ <path
482
+ fill="${checkerboardPatternRef}"
483
+ d="${colorBubblePath}"
484
+ />
485
+ <path
486
+ fill="${color}"
487
+ d="${colorBubblePath}"
488
+ />
489
+ `;
490
+
491
+ icon.innerHTML = `
492
+ <defs>
493
+ ${checkerboardPatternDef}
494
+ </defs>
495
+ <g>
496
+ ${ink}
497
+ ${penTip}
498
+ ${grip}
432
499
  </g>
433
500
  `;
434
501
  return icon;
@@ -19,7 +19,7 @@ export const makeColorInput = (
19
19
  colorInputContainer.classList.add('color-input-container');
20
20
 
21
21
  colorInputContainer.appendChild(colorInput);
22
- addPipetteTool(editor, colorInputContainer, (color: Color4) => {
22
+ const pipetteController = addPipetteTool(editor, colorInputContainer, (color: Color4) => {
23
23
  colorInput.value = color.toHexString();
24
24
  onInputEnd();
25
25
 
@@ -58,6 +58,7 @@ export const makeColorInput = (
58
58
  kind: EditorEventType.ColorPickerToggled,
59
59
  open: true,
60
60
  });
61
+ pipetteController.cancel();
61
62
  });
62
63
  colorInput.addEventListener('close', () => {
63
64
  editor.notifier.dispatch(EditorEventType.ColorPickerToggled, {
@@ -132,6 +133,13 @@ const addPipetteTool = (editor: Editor, container: HTMLElement, onColorChange: O
132
133
  };
133
134
 
134
135
  container.appendChild(pipetteButton);
136
+
137
+ return {
138
+ // Cancel a pipette color selection if one is in progress.
139
+ cancel: () => {
140
+ endColorSelectMode();
141
+ },
142
+ };
135
143
  };
136
144
 
137
145
  export default makeColorInput;
@@ -90,11 +90,12 @@ class InertialScroller {
90
90
  export default class PanZoom extends BaseTool {
91
91
  private transform: ViewportTransform|null = null;
92
92
 
93
- private lastAngle: number;
94
93
  private lastDist: number;
95
94
  private lastScreenCenter: Point2;
96
95
  private lastTimestamp: number;
97
96
  private lastPointerDownTimestamp: number = 0;
97
+ private initialTouchAngle: number = 0;
98
+ private initialViewportRotation: number = 0;
98
99
 
99
100
  private inertialScroller: InertialScroller|null = null;
100
101
  private velocity: Vec2|null = null;
@@ -132,9 +133,11 @@ export default class PanZoom extends BaseTool {
132
133
 
133
134
  if (allAreTouch && pointers.length === 2 && this.mode & PanZoomMode.TwoFingerTouchGestures) {
134
135
  const { screenCenter, angle, dist } = this.computePinchData(pointers[0], pointers[1]);
135
- this.lastAngle = angle;
136
136
  this.lastDist = dist;
137
137
  this.lastScreenCenter = screenCenter;
138
+ this.initialTouchAngle = angle;
139
+ this.initialViewportRotation = this.editor.viewport.getRotationAngle();
140
+
138
141
  handlingGesture = true;
139
142
  } else if (pointers.length === 1 && (
140
143
  (this.mode & PanZoomMode.OneFingerTouchGestures && allAreTouch)
@@ -188,24 +191,51 @@ export default class PanZoom extends BaseTool {
188
191
  return delta;
189
192
  }
190
193
 
194
+ // Snaps `angle` to common desired rotations. For example, if `touchAngle` corresponds
195
+ // to a viewport rotation of 90.1 degrees, this function returns a rotation delta that,
196
+ // when applied to the viewport, rotates the viewport to 90.0 degrees.
197
+ //
198
+ // Returns a snapped rotation delta that, when applied to the viewport, rotates the viewport,
199
+ // from its position on the last touchDown event, by `touchAngle - initialTouchAngle`.
200
+ private toSnappedRotationDelta(touchAngle: number) {
201
+ const deltaAngle = touchAngle - this.initialTouchAngle;
202
+ let fullRotation = deltaAngle + this.initialViewportRotation;
203
+
204
+ const snapToMultipleOf = Math.PI / 2;
205
+ const roundedFullRotation = Math.round(fullRotation / snapToMultipleOf) * snapToMultipleOf;
206
+
207
+ // The maximum angle for which we snap the given angle to a multiple of
208
+ // `snapToMultipleOf`.
209
+ const maxSnapAngle = 0.07;
210
+
211
+ // Snap the rotation
212
+ if (Math.abs(fullRotation - roundedFullRotation) < maxSnapAngle) {
213
+ fullRotation = roundedFullRotation;
214
+ }
215
+
216
+ return fullRotation - this.editor.viewport.getRotationAngle();
217
+ }
218
+
191
219
  private handleTwoFingerMove(allPointers: Pointer[]) {
192
220
  const { screenCenter, canvasCenter, angle, dist } = this.computePinchData(allPointers[0], allPointers[1]);
193
221
 
194
222
  const delta = this.getCenterDelta(screenCenter);
195
- let rotation = angle - this.lastAngle;
223
+ let deltaRotation;
196
224
 
197
225
  if (this.isRotationLocked()) {
198
- rotation = 0;
226
+ deltaRotation = 0;
227
+ } else {
228
+ deltaRotation = this.toSnappedRotationDelta(angle);
199
229
  }
200
230
 
231
+
201
232
  this.updateVelocity(screenCenter);
202
233
 
203
234
  const transformUpdate = Mat33.translation(delta)
204
235
  .rightMul(Mat33.scaling2D(dist / this.lastDist, canvasCenter))
205
- .rightMul(Mat33.zRotation(rotation, canvasCenter));
236
+ .rightMul(Mat33.zRotation(deltaRotation, canvasCenter));
206
237
  this.lastScreenCenter = screenCenter;
207
238
  this.lastDist = dist;
208
- this.lastAngle = angle;
209
239
  this.transform = Viewport.transformBy(
210
240
  this.transform!.transform.rightMul(transformUpdate)
211
241
  );
@@ -338,7 +368,7 @@ export default class PanZoom extends BaseTool {
338
368
  toCanvas.transformVec3(
339
369
  Vec3.of(-delta.x, -delta.y, 0)
340
370
  );
341
- const pinchZoomScaleFactor = 1.04;
371
+ const pinchZoomScaleFactor = 1.03;
342
372
  const transformUpdate = Mat33.scaling2D(
343
373
  Math.max(0.25, Math.min(Math.pow(pinchZoomScaleFactor, -delta.z), 4)), canvasPos
344
374
  ).rightMul(
@@ -1,8 +1,3 @@
1
- /**
2
- * A tool that handles paste events.
3
- * @packageDocumentation
4
- */
5
-
6
1
  import Editor from '../Editor';
7
2
  import { AbstractComponent, TextComponent } from '../components/lib';
8
3
  import SVGLoader from '../SVGLoader';
@@ -14,12 +9,23 @@ import Color4 from '../Color4';
14
9
  import { TextStyle } from '../components/TextComponent';
15
10
  import ImageComponent from '../components/ImageComponent';
16
11
 
17
- // { @inheritDoc PasteHandler! }
12
+ /**
13
+ * A tool that handles paste events (e.g. as triggered by ctrl+V).
14
+ *
15
+ * @example
16
+ * While `ToolController` has a `PasteHandler` in its default list of tools,
17
+ * if a non-default set is being used, `PasteHandler` can be added as follows:
18
+ * ```ts
19
+ * const toolController = editor.toolController;
20
+ * toolController.addTool(new PasteHandler(editor));
21
+ * ```
22
+ */
18
23
  export default class PasteHandler extends BaseTool {
19
24
  public constructor(private editor: Editor) {
20
25
  super(editor.notifier, editor.localization.pasteHandler);
21
26
  }
22
27
 
28
+ // @internal
23
29
  public onPaste(event: PasteEvent): boolean {
24
30
  const mime = event.mime.toLowerCase();
25
31
 
@@ -1,8 +1,12 @@
1
1
 
2
- import { Rect2 } from '../lib';
2
+ import PenTool from './Pen';
3
3
  import { Vec2 } from '../math/Vec2';
4
4
  import createEditor from '../testing/createEditor';
5
5
  import { InputEvtType } from '../types';
6
+ import Rect2 from '../math/Rect2';
7
+ import StrokeComponent from '../components/Stroke';
8
+ import Mat33 from '../math/Mat33';
9
+ import { makeFreehandLineBuilder } from '../components/builders/FreehandLineBuilder';
6
10
 
7
11
  describe('Pen', () => {
8
12
  it('should draw horizontal lines', () => {
@@ -147,4 +151,43 @@ describe('Pen', () => {
147
151
  expect(elems[0].getBBox().topLeft).objEq(Vec2.of(420, 24), 32); // ± 32
148
152
  expect(elems[0].getBBox().bottomRight).objEq(Vec2.of(420, 340), 25); // ± 25
149
153
  });
154
+
155
+ it('ctrl+z should finalize then undo the current stroke', async () => {
156
+ const editor = createEditor();
157
+
158
+ expect(editor.history.undoStackSize).toBe(0);
159
+
160
+ editor.sendPenEvent(InputEvtType.PointerDownEvt, Vec2.of(10, 10));
161
+ jest.advanceTimersByTime(100);
162
+ editor.sendPenEvent(InputEvtType.PointerMoveEvt, Vec2.of(20, 10));
163
+
164
+ const ctrlKeyDown = true;
165
+ editor.sendKeyboardEvent(InputEvtType.KeyPressEvent, 'z', ctrlKeyDown);
166
+
167
+ // Stroke should have been undone
168
+ expect(editor.history.redoStackSize).toBe(1);
169
+
170
+ // Lifting the pointer up shouldn't clear the redo stack.
171
+ editor.sendPenEvent(InputEvtType.PointerUpEvt, Vec2.of(420, 340));
172
+ expect(editor.history.redoStackSize).toBe(1);
173
+ });
174
+
175
+ it('holding ctrl should snap the stroke to grid', () => {
176
+ const editor = createEditor();
177
+ editor.viewport.resetTransform(Mat33.identity);
178
+
179
+ const penTool = editor.toolController.getMatchingTools(PenTool)[0];
180
+ penTool.setStrokeFactory(makeFreehandLineBuilder);
181
+
182
+ editor.sendPenEvent(InputEvtType.PointerDownEvt, Vec2.of(0.1, 0.1));
183
+ jest.advanceTimersByTime(100);
184
+ editor.sendPenEvent(InputEvtType.PointerMoveEvt, Vec2.of(10.1, 10.1));
185
+ editor.sendPenEvent(InputEvtType.PointerUpEvt, Vec2.of(10.1, 10.1));
186
+
187
+ const allElems = editor.image.getAllElements();
188
+ expect(allElems).toHaveLength(1);
189
+
190
+ const firstStroke = allElems[0] as StrokeComponent;
191
+ expect(firstStroke.getPath().bbox).objEq(new Rect2(0, 0, 10, 10));
192
+ });
150
193
  });
package/src/tools/Pen.ts CHANGED
@@ -3,7 +3,7 @@ import Editor from '../Editor';
3
3
  import EditorImage from '../EditorImage';
4
4
  import Pointer, { PointerDevice } from '../Pointer';
5
5
  import { makeFreehandLineBuilder } from '../components/builders/FreehandLineBuilder';
6
- import { EditorEventType, KeyPressEvent, PointerEvt, StrokeDataPoint } from '../types';
6
+ import { EditorEventType, KeyPressEvent, KeyUpEvent, PointerEvt, StrokeDataPoint } from '../types';
7
7
  import BaseTool from './BaseTool';
8
8
  import { ComponentBuilder, ComponentBuilderFactory } from '../components/builders/types';
9
9
 
@@ -15,6 +15,7 @@ export interface PenStyle {
15
15
  export default class Pen extends BaseTool {
16
16
  protected builder: ComponentBuilder|null = null;
17
17
  private lastPoint: StrokeDataPoint|null = null;
18
+ private ctrlKeyPressed: boolean = false;
18
19
 
19
20
  public constructor(
20
21
  private editor: Editor,
@@ -31,6 +32,10 @@ export default class Pen extends BaseTool {
31
32
 
32
33
  // Converts a `pointer` to a `StrokeDataPoint`.
33
34
  protected toStrokePoint(pointer: Pointer): StrokeDataPoint {
35
+ if (this.isSnappingToGrid()) {
36
+ pointer = pointer.snappedToGrid(this.editor.viewport);
37
+ }
38
+
34
39
  const minPressure = 0.3;
35
40
  let pressure = Math.max(pointer.pressure ?? 1.0, minPressure);
36
41
 
@@ -42,8 +47,10 @@ export default class Pen extends BaseTool {
42
47
  console.assert(isFinite(pointer.screenPos.length()), 'Non-finite screen position!');
43
48
  console.assert(isFinite(pointer.timeStamp), 'Non-finite timeStamp on pointer!');
44
49
 
50
+ const pos = pointer.canvasPos;
51
+
45
52
  return {
46
- pos: pointer.canvasPos,
53
+ pos,
47
54
  width: pressure * this.getPressureMultiplier(),
48
55
  color: this.style.color,
49
56
  time: pointer.timeStamp,
@@ -86,6 +93,8 @@ export default class Pen extends BaseTool {
86
93
  }
87
94
 
88
95
  public onPointerMove({ current }: PointerEvt): void {
96
+ if (!this.builder) return;
97
+
89
98
  this.addPointToStroke(this.toStrokePoint(current));
90
99
  }
91
100
 
@@ -102,7 +111,18 @@ export default class Pen extends BaseTool {
102
111
  };
103
112
 
104
113
  this.addPointToStroke(strokePoint);
105
- if (this.builder && current.isPrimary) {
114
+
115
+ if (current.isPrimary) {
116
+ this.finalizeStroke();
117
+ }
118
+ }
119
+
120
+ public onGestureCancel() {
121
+ this.editor.clearWetInk();
122
+ }
123
+
124
+ private finalizeStroke() {
125
+ if (this.builder) {
106
126
  const stroke = this.builder.build();
107
127
  this.previewStroke();
108
128
 
@@ -118,10 +138,6 @@ export default class Pen extends BaseTool {
118
138
  this.editor.clearWetInk();
119
139
  }
120
140
 
121
- public onGestureCancel() {
122
- this.editor.clearWetInk();
123
- }
124
-
125
141
  private noteUpdated() {
126
142
  this.editor.notifier.dispatch(EditorEventType.ToolUpdated, {
127
143
  kind: EditorEventType.ToolUpdated,
@@ -160,7 +176,15 @@ export default class Pen extends BaseTool {
160
176
  public getColor() { return this.style.color; }
161
177
  public getStrokeFactory() { return this.builderFactory; }
162
178
 
163
- public onKeyPress({ key }: KeyPressEvent): boolean {
179
+ public setEnabled(enabled: boolean): void {
180
+ super.setEnabled(enabled);
181
+
182
+ this.ctrlKeyPressed = false;
183
+ }
184
+
185
+ private isSnappingToGrid() { return this.ctrlKeyPressed; }
186
+
187
+ public onKeyPress({ key, ctrlKey }: KeyPressEvent): boolean {
164
188
  key = key.toLowerCase();
165
189
 
166
190
  let newThickness: number|undefined;
@@ -176,6 +200,27 @@ export default class Pen extends BaseTool {
176
200
  return true;
177
201
  }
178
202
 
203
+ if (key === 'control') {
204
+ this.ctrlKeyPressed = true;
205
+ return true;
206
+ }
207
+
208
+ // Ctrl+Z: End the stroke so that it can be undone/redone.
209
+ if (key === 'z' && ctrlKey && this.builder) {
210
+ this.finalizeStroke();
211
+ }
212
+
213
+ return false;
214
+ }
215
+
216
+ public onKeyUp({ key }: KeyUpEvent): boolean {
217
+ key = key.toLowerCase();
218
+
219
+ if (key === 'control') {
220
+ this.ctrlKeyPressed = false;
221
+ return true;
222
+ }
223
+
179
224
  return false;
180
225
  }
181
226
  }
@@ -18,6 +18,7 @@ export type DragEndCallback = ()=> void;
18
18
 
19
19
  export default class SelectionHandle {
20
20
  private element: HTMLElement;
21
+ private snapToGrid: boolean;
21
22
 
22
23
  // Bounding box in screen coordinates.
23
24
 
@@ -96,4 +97,12 @@ export default class SelectionHandle {
96
97
  }
97
98
  this.onDragEnd();
98
99
  }
100
+
101
+ public setSnapToGrid(snap: boolean) {
102
+ this.snapToGrid = snap;
103
+ }
104
+
105
+ public isSnappingToGrid() {
106
+ return this.snapToGrid;
107
+ }
99
108
  }