js-draw 0.24.1 → 0.25.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 (69) hide show
  1. package/dist/bundle.js +2 -2
  2. package/dist/bundledStyles.js +1 -1
  3. package/dist/cjs/localizations/de.js +1 -1
  4. package/dist/cjs/localizations/es.js +1 -1
  5. package/dist/cjs/rendering/renderers/CanvasRenderer.js +2 -0
  6. package/dist/cjs/rendering/renderers/SVGRenderer.d.ts +1 -1
  7. package/dist/cjs/rendering/renderers/SVGRenderer.js +4 -2
  8. package/dist/cjs/testing/getUniquePointerId.d.ts +4 -0
  9. package/dist/cjs/testing/getUniquePointerId.js +16 -0
  10. package/dist/cjs/testing/sendPenEvent.d.ts +1 -1
  11. package/dist/cjs/testing/sendPenEvent.js +4 -1
  12. package/dist/cjs/testing/sendTouchEvent.js +2 -9
  13. package/dist/cjs/toolbar/IconProvider.d.ts +1 -1
  14. package/dist/cjs/toolbar/IconProvider.js +76 -10
  15. package/dist/cjs/toolbar/localization.d.ts +2 -2
  16. package/dist/cjs/toolbar/localization.js +2 -2
  17. package/dist/cjs/toolbar/widgets/BaseToolWidget.d.ts +2 -0
  18. package/dist/cjs/toolbar/widgets/BaseToolWidget.js +7 -0
  19. package/dist/cjs/toolbar/widgets/PenToolWidget.d.ts +3 -1
  20. package/dist/cjs/toolbar/widgets/PenToolWidget.js +125 -41
  21. package/dist/cjs/toolbar/widgets/SelectionToolWidget.js +4 -0
  22. package/dist/cjs/tools/BaseTool.d.ts +17 -1
  23. package/dist/cjs/tools/BaseTool.js +18 -0
  24. package/dist/cjs/tools/Pen.d.ts +5 -2
  25. package/dist/cjs/tools/Pen.js +37 -4
  26. package/dist/cjs/tools/ToolController.js +14 -2
  27. package/dist/mjs/localizations/de.mjs +1 -1
  28. package/dist/mjs/localizations/es.mjs +1 -1
  29. package/dist/mjs/rendering/renderers/CanvasRenderer.mjs +2 -0
  30. package/dist/mjs/rendering/renderers/SVGRenderer.d.ts +1 -1
  31. package/dist/mjs/rendering/renderers/SVGRenderer.mjs +4 -2
  32. package/dist/mjs/testing/getUniquePointerId.d.ts +4 -0
  33. package/dist/mjs/testing/getUniquePointerId.mjs +14 -0
  34. package/dist/mjs/testing/sendPenEvent.d.ts +1 -1
  35. package/dist/mjs/testing/sendPenEvent.mjs +4 -1
  36. package/dist/mjs/testing/sendTouchEvent.mjs +2 -9
  37. package/dist/mjs/toolbar/IconProvider.d.ts +1 -1
  38. package/dist/mjs/toolbar/IconProvider.mjs +76 -10
  39. package/dist/mjs/toolbar/localization.d.ts +2 -2
  40. package/dist/mjs/toolbar/localization.mjs +2 -2
  41. package/dist/mjs/toolbar/widgets/BaseToolWidget.d.ts +2 -0
  42. package/dist/mjs/toolbar/widgets/BaseToolWidget.mjs +7 -0
  43. package/dist/mjs/toolbar/widgets/PenToolWidget.d.ts +3 -1
  44. package/dist/mjs/toolbar/widgets/PenToolWidget.mjs +125 -41
  45. package/dist/mjs/toolbar/widgets/SelectionToolWidget.mjs +4 -0
  46. package/dist/mjs/tools/BaseTool.d.ts +17 -1
  47. package/dist/mjs/tools/BaseTool.mjs +18 -0
  48. package/dist/mjs/tools/Pen.d.ts +5 -2
  49. package/dist/mjs/tools/Pen.mjs +37 -4
  50. package/dist/mjs/tools/ToolController.mjs +14 -2
  51. package/package.json +2 -2
  52. package/src/localizations/de.ts +2 -2
  53. package/src/localizations/es.ts +1 -1
  54. package/src/rendering/renderers/CanvasRenderer.ts +2 -0
  55. package/src/rendering/renderers/SVGRenderer.ts +6 -3
  56. package/src/testing/getUniquePointerId.ts +18 -0
  57. package/src/testing/sendPenEvent.ts +6 -1
  58. package/src/testing/sendTouchEvent.ts +2 -9
  59. package/src/toolbar/IconProvider.ts +92 -23
  60. package/src/toolbar/localization.ts +4 -4
  61. package/src/toolbar/toolbar.css +1 -0
  62. package/src/toolbar/widgets/BaseToolWidget.ts +10 -1
  63. package/src/toolbar/widgets/PenToolWidget.css +53 -0
  64. package/src/toolbar/widgets/PenToolWidget.ts +156 -44
  65. package/src/toolbar/widgets/SelectionToolWidget.ts +4 -0
  66. package/src/tools/BaseTool.ts +22 -1
  67. package/src/tools/Pen.test.ts +68 -0
  68. package/src/tools/Pen.ts +42 -4
  69. package/src/tools/ToolController.ts +17 -2
@@ -31,6 +31,9 @@ export default class PenToolWidget extends BaseToolWidget {
31
31
  private updateInputs: ()=> void = () => {};
32
32
  protected penTypes: PenTypeRecord[];
33
33
 
34
+ // A counter variable that ensures different HTML elements are given unique names/ids.
35
+ private static idCounter: number = 0;
36
+
34
37
  public constructor(
35
38
  editor: Editor, private tool: Pen, localization?: ToolbarLocalization
36
39
  ) {
@@ -39,13 +42,13 @@ export default class PenToolWidget extends BaseToolWidget {
39
42
  // Default pen types
40
43
  this.penTypes = [
41
44
  {
42
- name: this.localizationTable.pressureSensitiveFreehandPen,
45
+ name: this.localizationTable.flatTipPen,
43
46
  id: 'pressure-sensitive-pen',
44
47
 
45
48
  factory: makePressureSensitiveFreehandLineBuilder,
46
49
  },
47
50
  {
48
- name: this.localizationTable.freehandPen,
51
+ name: this.localizationTable.roundedTipPen,
49
52
  id: 'freehand-pen',
50
53
 
51
54
  factory: makeFreehandLineBuilder,
@@ -123,43 +126,173 @@ export default class PenToolWidget extends BaseToolWidget {
123
126
  return null;
124
127
  }
125
128
 
126
- protected createIcon(): Element {
127
- const strokeFactory = this.tool.getStrokeFactory();
128
- if (strokeFactory === makeFreehandLineBuilder || strokeFactory === makePressureSensitiveFreehandLineBuilder) {
129
+ private createIconForRecord(record: PenTypeRecord|null) {
130
+ const color = this.tool.getColor();
131
+
132
+ const strokeFactory = record?.factory;
133
+ if (!strokeFactory || strokeFactory === makeFreehandLineBuilder || strokeFactory === makePressureSensitiveFreehandLineBuilder) {
129
134
  // Use a square-root scale to prevent the pen's tip from overflowing.
130
135
  const scale = Math.round(Math.sqrt(this.tool.getThickness()) * 4);
131
- const color = this.tool.getColor();
132
136
  const roundedTip = strokeFactory === makeFreehandLineBuilder;
133
137
 
134
138
  return this.editor.icons.makePenIcon(scale, color.toHexString(), roundedTip);
135
139
  } else {
136
- const strokeFactory = this.tool.getStrokeFactory();
137
- return this.editor.icons.makeIconFromFactory(this.tool, strokeFactory);
140
+ const hasTransparency = color.a < 1;
141
+ return this.editor.icons.makeIconFromFactory(this.tool, strokeFactory, hasTransparency);
138
142
  }
139
143
  }
140
144
 
141
- private static idCounter: number = 0;
145
+ protected createIcon(): Element {
146
+ return this.createIconForRecord(this.getCurrentPenType());
147
+ }
148
+
149
+
150
+ // Creates a widget that allows selecting different pen types
151
+ private createPenTypeSelector() {
152
+ const outerContainer = document.createElement('div');
153
+ outerContainer.classList.add(`${toolbarCSSPrefix}pen-type-selector`);
154
+
155
+ const scrollingContainer = document.createElement('div');
156
+ scrollingContainer.setAttribute('role', 'menu');
157
+ scrollingContainer.id = `${toolbarCSSPrefix}-pen-type-selector-id-${PenToolWidget.idCounter++}`;
158
+
159
+ scrollingContainer.onwheel = (event) => {
160
+ const hasScroll = scrollingContainer.clientWidth !== scrollingContainer.scrollWidth
161
+ && event.deltaX !== 0;
162
+ const eventScrollsPastLeft =
163
+ scrollingContainer.scrollLeft + event.deltaX <= 0;
164
+ const scrollRight = scrollingContainer.scrollLeft + scrollingContainer.clientWidth;
165
+ const eventScrollsPastRight =
166
+ scrollRight + event.deltaX > scrollingContainer.scrollWidth;
167
+
168
+ // Stop the editor from receiving the event if it will scroll the pen type selector
169
+ // instead.
170
+ if (hasScroll && !eventScrollsPastLeft && !eventScrollsPastRight) {
171
+ event.stopPropagation();
172
+ }
173
+ };
174
+
175
+ const label = document.createElement('label');
176
+ label.innerText = this.localizationTable.selectPenType;
177
+ label.htmlFor = scrollingContainer.id;
178
+ outerContainer.appendChild(label);
179
+
180
+ // All buttons in a radiogroup need the same name attribute.
181
+ const radiogroupName = `${toolbarCSSPrefix}-pen-type-selector-${PenToolWidget.idCounter++}`;
182
+
183
+ const createTypeSelectorButton = (record: PenTypeRecord) => {
184
+ const buttonContainer = document.createElement('div');
185
+ buttonContainer.classList.add('pen-type-button');
186
+
187
+ const button = document.createElement('input');
188
+ button.type = 'radio';
189
+ button.name = radiogroupName;
190
+ button.id = `${toolbarCSSPrefix}-pen-type-button-${PenToolWidget.idCounter++}`;
191
+
192
+ const labelContainer = document.createElement('label');
193
+
194
+ const rebuildLabel = () => {
195
+ const labelText = document.createElement('span');
196
+
197
+ const icon = this.createIconForRecord(record);
198
+ icon.classList.add('icon');
199
+
200
+ // The title of the record
201
+ labelText.innerText = record.name;
202
+ labelContainer.htmlFor = button.id;
203
+
204
+ labelContainer.replaceChildren(icon, labelText);
205
+ };
206
+ rebuildLabel();
207
+
208
+ const updateButtonCSS = () => {
209
+ if (button.checked) {
210
+ buttonContainer.classList.add('checked');
211
+ } else {
212
+ buttonContainer.classList.remove('checked');
213
+ }
214
+ };
215
+
216
+ button.oninput = () => {
217
+ // Setting the stroke factory fires an event that causes the value
218
+ // of this button to be set.
219
+ if (button.checked) {
220
+ this.tool.setStrokeFactory(record.factory);
221
+ }
222
+
223
+ updateButtonCSS();
224
+ };
225
+
226
+ buttonContainer.replaceChildren(button, labelContainer);
227
+ scrollingContainer.appendChild(buttonContainer);
228
+
229
+ // Set whether the button is checked, assuming the stroke factory associated
230
+ // with the button was set elsewhere.
231
+ const setChecked = (checked: boolean) => {
232
+ button.checked = checked;
233
+ updateButtonCSS();
234
+
235
+ if (checked) {
236
+ button.scrollIntoView();
237
+ }
238
+ };
239
+ setChecked(false);
240
+
241
+ // Updates the factory's icon based on the current style of the tool.
242
+ const updateIcon = () => {
243
+ rebuildLabel();
244
+ };
245
+
246
+ return { setChecked, updateIcon };
247
+ };
248
+
249
+ const buttons: Array<ReturnType<typeof createTypeSelectorButton>> = [];
250
+ for (const penType of this.penTypes) {
251
+ buttons.push(createTypeSelectorButton(penType));
252
+ }
253
+ // invariant: buttons.length = this.penTypes.length
254
+
255
+ outerContainer.appendChild(scrollingContainer);
256
+
257
+ return {
258
+ setValue: (penTypeIndex: number) => {
259
+ // Select the value specified
260
+ if (penTypeIndex < 0 || penTypeIndex >= this.penTypes.length) {
261
+ console.error('Invalid pen type index', penTypeIndex);
262
+ return;
263
+ }
264
+
265
+ for (let i = 0; i < buttons.length; i++) {
266
+ buttons[i].setChecked(i === penTypeIndex);
267
+ }
268
+ },
269
+
270
+ updateIcons: () => {
271
+ buttons.forEach(button => button.updateIcon());
272
+ },
273
+
274
+ addTo: (parent: HTMLElement) => {
275
+ parent.appendChild(outerContainer);
276
+ },
277
+ };
278
+ }
279
+
142
280
  protected override fillDropdown(dropdown: HTMLElement): boolean {
143
281
  const container = document.createElement('div');
144
282
  container.classList.add(`${toolbarCSSPrefix}spacedList`);
145
283
 
146
284
  const thicknessRow = document.createElement('div');
147
- const objectTypeRow = document.createElement('div');
148
285
 
149
286
  // Thickness: Value of the input is squared to allow for finer control/larger values.
150
287
  const thicknessLabel = document.createElement('label');
151
288
  const thicknessInput = document.createElement('input');
152
- const objectSelectLabel = document.createElement('label');
153
- const objectTypeSelect = document.createElement('select');
289
+ const penTypeSelect = this.createPenTypeSelector();
154
290
 
155
291
  // Give inputs IDs so we can label them with a <label for=...>Label text</label>
156
292
  thicknessInput.id = `${toolbarCSSPrefix}penThicknessInput${PenToolWidget.idCounter++}`;
157
- objectTypeSelect.id = `${toolbarCSSPrefix}penBuilderSelect${PenToolWidget.idCounter++}`;
158
293
 
159
294
  thicknessLabel.innerText = this.localizationTable.thicknessLabel;
160
295
  thicknessLabel.setAttribute('for', thicknessInput.id);
161
- objectSelectLabel.innerText = this.localizationTable.selectPenType;
162
- objectSelectLabel.setAttribute('for', objectTypeSelect.id);
163
296
 
164
297
  // Use a logarithmic scale for thicknessInput (finer control over thinner strokewidths.)
165
298
  const inverseThicknessInputFn = (t: number) => Math.log10(t);
@@ -175,18 +308,6 @@ export default class PenToolWidget extends BaseToolWidget {
175
308
  thicknessRow.appendChild(thicknessLabel);
176
309
  thicknessRow.appendChild(thicknessInput);
177
310
 
178
- objectTypeSelect.oninput = () => {
179
- const penTypeIdx = parseInt(objectTypeSelect.value);
180
- if (penTypeIdx < 0 || penTypeIdx >= this.penTypes.length) {
181
- console.error('Invalid pen type index', penTypeIdx);
182
- return;
183
- }
184
-
185
- this.tool.setStrokeFactory(this.penTypes[penTypeIdx].factory);
186
- };
187
- objectTypeRow.appendChild(objectSelectLabel);
188
- objectTypeRow.appendChild(objectTypeSelect);
189
-
190
311
  const colorRow = document.createElement('div');
191
312
  const colorLabel = document.createElement('label');
192
313
  const [ colorInput, colorInputContainer, setColorInputValue ] = makeColorInput(this.editor, color => {
@@ -204,28 +325,15 @@ export default class PenToolWidget extends BaseToolWidget {
204
325
  setColorInputValue(this.tool.getColor());
205
326
  thicknessInput.value = inverseThicknessInputFn(this.tool.getThickness()).toString();
206
327
 
207
- // Update the list of stroke factories
208
- objectTypeSelect.replaceChildren();
209
- for (let i = 0; i < this.penTypes.length; i ++) {
210
- const penType = this.penTypes[i];
211
- const option = document.createElement('option');
212
- option.value = i.toString();
213
- option.innerText = penType.name;
214
-
215
- objectTypeSelect.appendChild(option);
216
- }
328
+ penTypeSelect.updateIcons();
217
329
 
218
330
  // Update the selected stroke factory.
219
- const strokeFactoryIdx = this.getCurrentPenTypeIdx();
220
- if (strokeFactoryIdx === -1) {
221
- objectTypeSelect.value = '';
222
- } else {
223
- objectTypeSelect.value = strokeFactoryIdx.toString();
224
- }
331
+ penTypeSelect.setValue(this.getCurrentPenTypeIdx());
225
332
  };
226
333
  this.updateInputs();
227
334
 
228
- container.replaceChildren(colorRow, thicknessRow, objectTypeRow);
335
+ container.replaceChildren(colorRow, thicknessRow);
336
+ penTypeSelect.addTo(container);
229
337
  dropdown.replaceChildren(container);
230
338
  return true;
231
339
  }
@@ -246,6 +354,10 @@ export default class PenToolWidget extends BaseToolWidget {
246
354
  }
247
355
  }
248
356
 
357
+ // Run any default actions registered by the parent class.
358
+ if (super.onKeyPress(event)) {
359
+ return true;
360
+ }
249
361
  return false;
250
362
  }
251
363
 
@@ -180,6 +180,10 @@ export default class SelectionToolWidget extends BaseToolWidget {
180
180
  return true;
181
181
  }
182
182
 
183
+ // If we didn't handle the event, allow the superclass to handle it.
184
+ if (super.onKeyPress(event)) {
185
+ return true;
186
+ }
183
187
  return false;
184
188
  }
185
189
 
@@ -5,9 +5,21 @@ export default abstract class BaseTool implements PointerEvtListener {
5
5
  private enabled: boolean = true;
6
6
  private group: ToolEnabledGroup|null = null;
7
7
 
8
+ /**
9
+ * Returns true iff the tool handled the event and thus should receive additional
10
+ * events.
11
+ */
8
12
  public onPointerDown(_event: PointerEvt): boolean { return false; }
9
13
  public onPointerMove(_event: PointerEvt) { }
10
- public onPointerUp(_event: PointerEvt) { }
14
+
15
+ /**
16
+ * Returns true iff there are additional pointers down and the tool should
17
+ * remain active to handle the additional events.
18
+ *
19
+ * For most purposes, this should return `false` or nothing.
20
+ */
21
+ public onPointerUp(_event: PointerEvt): boolean|void { }
22
+
11
23
  public onGestureCancel() { }
12
24
 
13
25
  protected constructor(private notifier: EditorNotifier, public readonly description: string) {
@@ -33,6 +45,15 @@ export default abstract class BaseTool implements PointerEvtListener {
33
45
  return false;
34
46
  }
35
47
 
48
+ /**
49
+ * Return true if, while this tool is active, `_event` can be delivered to
50
+ * another tool that is higher priority than this.
51
+ * @internal May be renamed
52
+ */
53
+ public eventCanBeDeliveredToNonActiveTool(_event: PointerEvt) {
54
+ return true;
55
+ }
56
+
36
57
  public setEnabled(enabled: boolean) {
37
58
  this.enabled = enabled;
38
59
 
@@ -8,6 +8,7 @@ import StrokeComponent from '../components/Stroke';
8
8
  import Mat33 from '../math/Mat33';
9
9
  import { makeFreehandLineBuilder } from '../components/builders/FreehandLineBuilder';
10
10
  import sendPenEvent from '../testing/sendPenEvent';
11
+ import sendTouchEvent from '../testing/sendTouchEvent';
11
12
 
12
13
  describe('Pen', () => {
13
14
  it('should draw horizontal lines', () => {
@@ -153,6 +154,73 @@ describe('Pen', () => {
153
154
  expect(elems[0].getBBox().bottomRight).objEq(Vec2.of(420, 340), 25); // ± 25
154
155
  });
155
156
 
157
+ // if `mainEventIsPen` is false, tests with touch events.
158
+ const testEventCancelation = (mainEventIsPen: boolean) => {
159
+ const editor = createEditor();
160
+
161
+ expect(editor.image.getElementsIntersectingRegion(new Rect2(0, 0, 1000, 1000))).toHaveLength(0);
162
+
163
+ const sendMainEvent = mainEventIsPen ? sendPenEvent : sendTouchEvent;
164
+
165
+ // Start the drawing
166
+ const mainPointer = sendMainEvent(editor, InputEvtType.PointerDownEvt, Vec2.of(417, 24));
167
+ jest.advanceTimersByTime(245);
168
+ sendMainEvent(editor, InputEvtType.PointerMoveEvt, Vec2.of(423, 197));
169
+ jest.advanceTimersByTime(20);
170
+ sendMainEvent(editor, InputEvtType.PointerMoveEvt, Vec2.of(423, 199));
171
+ jest.advanceTimersByTime(12);
172
+ sendMainEvent(editor, InputEvtType.PointerMoveEvt, Vec2.of(423, 201));
173
+ jest.advanceTimersByTime(40);
174
+ sendMainEvent(editor, InputEvtType.PointerMoveEvt, Vec2.of(423, 203));
175
+ jest.advanceTimersByTime(14);
176
+
177
+ // Attempt to cancel the drawing
178
+ let firstPointer = sendTouchEvent(editor, InputEvtType.PointerDownEvt, Vec2.of(0, 0), [ mainPointer ]);
179
+ let secondPointer = sendTouchEvent(editor, InputEvtType.PointerDownEvt, Vec2.of(100, 0), [ firstPointer, mainPointer ]);
180
+
181
+ const maxIterations = 10;
182
+ for (let i = 0; i < maxIterations; i++) {
183
+ jest.advanceTimersByTime(100);
184
+
185
+ const point1 = Vec2.of(-i * 5, 0);
186
+ const point2 = Vec2.of(i * 5 + 100, 0);
187
+
188
+ const eventType = InputEvtType.PointerMoveEvt;
189
+ firstPointer = sendTouchEvent(editor, eventType, point1, [ secondPointer, mainPointer ]);
190
+ secondPointer = sendTouchEvent(editor, eventType, point2, [ firstPointer, mainPointer ]);
191
+
192
+ if (i === maxIterations - 1) {
193
+ jest.advanceTimersByTime(10);
194
+
195
+ sendTouchEvent(editor, InputEvtType.PointerUpEvt, point1, [ secondPointer, mainPointer ]);
196
+ sendTouchEvent(editor, InputEvtType.PointerUpEvt, point2, [ mainPointer ]);
197
+ }
198
+
199
+ jest.advanceTimersByTime(100);
200
+ }
201
+
202
+ // Finish the drawing
203
+ sendMainEvent(editor, InputEvtType.PointerMoveEvt, Vec2.of(420, 333), [firstPointer, secondPointer]);
204
+ jest.advanceTimersByTime(8);
205
+ sendMainEvent(editor, InputEvtType.PointerMoveEvt, Vec2.of(420, 340), [firstPointer, secondPointer]);
206
+ sendMainEvent(editor, InputEvtType.PointerUpEvt, Vec2.of(420, 340), [firstPointer, secondPointer]);
207
+
208
+ const elementsInDrawingArea = editor.image.getElementsIntersectingRegion(new Rect2(0, 0, 1000, 1000));
209
+ if (mainEventIsPen) {
210
+ expect(elementsInDrawingArea).toHaveLength(1);
211
+ } else {
212
+ expect(elementsInDrawingArea).toHaveLength(0);
213
+ }
214
+ };
215
+
216
+ it('pen events should not be cancelable by touch events', () => {
217
+ testEventCancelation(true);
218
+ });
219
+
220
+ it('touch events should be cancelable by touch events', () => {
221
+ testEventCancelation(false);
222
+ });
223
+
156
224
  it('ctrl+z should finalize then undo the current stroke', async () => {
157
225
  const editor = createEditor();
158
226
 
package/src/tools/Pen.ts CHANGED
@@ -20,6 +20,7 @@ export default class Pen extends BaseTool {
20
20
  protected builder: ComponentBuilder|null = null;
21
21
  private lastPoint: StrokeDataPoint|null = null;
22
22
  private startPoint: StrokeDataPoint|null = null;
23
+ private currentDeviceType: PointerDevice|null = null;
23
24
 
24
25
  private snapToGridEnabled: boolean = false;
25
26
  private angleLockEnabled: boolean = false;
@@ -94,7 +95,8 @@ export default class Pen extends BaseTool {
94
95
  this.previewStroke();
95
96
  }
96
97
 
97
- public override onPointerDown({ current, allPointers }: PointerEvt): boolean {
98
+ public override onPointerDown(event: PointerEvt): boolean {
99
+ const { current, allPointers } = event;
98
100
  const isEraser = current.device === PointerDevice.Eraser;
99
101
 
100
102
  let anyDeviceIsStylus = false;
@@ -105,24 +107,57 @@ export default class Pen extends BaseTool {
105
107
  }
106
108
  }
107
109
 
110
+ // Avoid canceling an existing stroke
111
+ if (this.builder && !this.eventCanCancelStroke(event)) {
112
+ return true;
113
+ }
114
+
108
115
  if ((allPointers.length === 1 && !isEraser) || anyDeviceIsStylus) {
109
116
  this.startPoint = this.toStrokePoint(current);
110
117
  this.builder = this.builderFactory(this.startPoint, this.editor.viewport);
118
+ this.currentDeviceType = current.device;
111
119
  return true;
112
120
  }
113
121
 
114
122
  return false;
115
123
  }
116
124
 
125
+ private eventCanCancelStroke(event: PointerEvt) {
126
+ // If there has been a delay since the last input event,
127
+ // it's always okay to cancel
128
+ const lastInputTime = this.lastPoint?.time ?? 0;
129
+ if (event.current.timeStamp - lastInputTime > 1000) {
130
+ return true;
131
+ }
132
+
133
+ const isPenStroke = this.currentDeviceType === PointerDevice.Pen;
134
+ const isTouchEvent = event.current.device === PointerDevice.Touch;
135
+
136
+ // Don't allow pen strokes to be cancelled by touch events.
137
+ if (isPenStroke && isTouchEvent) {
138
+ return false;
139
+ }
140
+
141
+ return true;
142
+ }
143
+
144
+ public override eventCanBeDeliveredToNonActiveTool(event: PointerEvt) {
145
+ return this.eventCanCancelStroke(event);
146
+ }
147
+
117
148
  public override onPointerMove({ current }: PointerEvt): void {
118
149
  if (!this.builder) return;
150
+ if (current.device !== this.currentDeviceType) return;
119
151
 
120
152
  this.addPointToStroke(this.toStrokePoint(current));
121
153
  }
122
154
 
123
- public override onPointerUp({ current }: PointerEvt): void {
124
- if (!this.builder) {
125
- return;
155
+ public override onPointerUp({ current }: PointerEvt) {
156
+ if (!this.builder) return false;
157
+ if (current.device !== this.currentDeviceType) {
158
+ // this.builder still exists, so we're handling events from another
159
+ // device type.
160
+ return true;
126
161
  }
127
162
 
128
163
  // onPointerUp events can have zero pressure. Use the last pressure instead.
@@ -137,9 +172,12 @@ export default class Pen extends BaseTool {
137
172
  if (current.isPrimary) {
138
173
  this.finalizeStroke();
139
174
  }
175
+
176
+ return false;
140
177
  }
141
178
 
142
179
  public override onGestureCancel() {
180
+ this.builder = null;
143
181
  this.editor.clearWetInk();
144
182
  }
145
183
 
@@ -115,7 +115,16 @@ export default class ToolController {
115
115
  public dispatchInputEvent(event: InputEvt): boolean {
116
116
  let handled = false;
117
117
  if (event.kind === InputEvtType.PointerDownEvt) {
118
+ let canOnlySendToActiveTool = false;
119
+ if (this.activeTool && !this.activeTool.eventCanBeDeliveredToNonActiveTool(event)) {
120
+ canOnlySendToActiveTool = true;
121
+ }
122
+
118
123
  for (const tool of this.tools) {
124
+ if (canOnlySendToActiveTool && tool !== this.activeTool) {
125
+ continue;
126
+ }
127
+
119
128
  if (tool.isEnabled() && tool.onPointerDown(event)) {
120
129
  if (this.activeTool !== tool) {
121
130
  this.activeTool?.onGestureCancel();
@@ -127,8 +136,14 @@ export default class ToolController {
127
136
  }
128
137
  }
129
138
  } else if (event.kind === InputEvtType.PointerUpEvt) {
130
- this.activeTool?.onPointerUp(event);
131
- this.activeTool = null;
139
+ const upResult = this.activeTool?.onPointerUp(event);
140
+ const continueHandlingEvents = upResult && event.allPointers.length > 1;
141
+
142
+ // Should the active tool continue handling events (without an additional pointer down?)
143
+ if (!continueHandlingEvents) {
144
+ // No -- Remove the current tool
145
+ this.activeTool = null;
146
+ }
132
147
  handled = true;
133
148
  } else if (event.kind === InputEvtType.PointerMoveEvt) {
134
149
  if (this.activeTool !== null) {