js-draw 1.9.0 → 1.10.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 (109) hide show
  1. package/dist/Editor.css +48 -1
  2. package/dist/bundle.js +2 -2
  3. package/dist/bundledStyles.js +1 -1
  4. package/dist/cjs/Editor.d.ts +41 -0
  5. package/dist/cjs/Editor.js +33 -13
  6. package/dist/cjs/Pointer.js +1 -1
  7. package/dist/cjs/Viewport.d.ts +6 -0
  8. package/dist/cjs/Viewport.js +6 -1
  9. package/dist/cjs/commands/Erase.d.ts +22 -2
  10. package/dist/cjs/commands/Erase.js +22 -2
  11. package/dist/cjs/commands/uniteCommands.d.ts +36 -0
  12. package/dist/cjs/commands/uniteCommands.js +36 -0
  13. package/dist/cjs/components/ImageComponent.d.ts +12 -0
  14. package/dist/cjs/components/ImageComponent.js +16 -9
  15. package/dist/cjs/components/Stroke.d.ts +16 -2
  16. package/dist/cjs/components/Stroke.js +17 -1
  17. package/dist/cjs/components/builders/ArrowBuilder.js +3 -3
  18. package/dist/cjs/components/builders/CircleBuilder.js +3 -3
  19. package/dist/cjs/components/builders/FreehandLineBuilder.js +3 -3
  20. package/dist/cjs/components/builders/LineBuilder.js +3 -3
  21. package/dist/cjs/components/builders/PressureSensitiveFreehandLineBuilder.js +3 -3
  22. package/dist/cjs/components/builders/RectangleBuilder.js +5 -6
  23. package/dist/cjs/components/builders/autocorrect/makeShapeFitAutocorrect.d.ts +3 -0
  24. package/dist/cjs/components/builders/autocorrect/makeShapeFitAutocorrect.js +168 -0
  25. package/dist/cjs/components/builders/autocorrect/makeSnapToGridAutocorrect.d.ts +3 -0
  26. package/dist/cjs/components/builders/autocorrect/makeSnapToGridAutocorrect.js +46 -0
  27. package/dist/cjs/components/builders/types.d.ts +1 -0
  28. package/dist/cjs/image/EditorImage.d.ts +32 -1
  29. package/dist/cjs/image/EditorImage.js +32 -1
  30. package/dist/cjs/rendering/Display.js +8 -1
  31. package/dist/cjs/rendering/RenderablePathSpec.d.ts +5 -1
  32. package/dist/cjs/rendering/RenderablePathSpec.js +4 -0
  33. package/dist/cjs/toolbar/IconProvider.d.ts +2 -0
  34. package/dist/cjs/toolbar/IconProvider.js +17 -0
  35. package/dist/cjs/toolbar/localization.d.ts +3 -0
  36. package/dist/cjs/toolbar/localization.js +4 -1
  37. package/dist/cjs/toolbar/widgets/InsertImageWidget.d.ts +2 -1
  38. package/dist/cjs/toolbar/widgets/InsertImageWidget.js +102 -22
  39. package/dist/cjs/toolbar/widgets/PenToolWidget.d.ts +1 -2
  40. package/dist/cjs/toolbar/widgets/PenToolWidget.js +50 -20
  41. package/dist/cjs/tools/Pen.d.ts +9 -0
  42. package/dist/cjs/tools/Pen.js +77 -3
  43. package/dist/cjs/tools/TextTool.js +5 -1
  44. package/dist/cjs/tools/util/StationaryPenDetector.d.ts +22 -0
  45. package/dist/cjs/tools/util/StationaryPenDetector.js +95 -0
  46. package/dist/cjs/util/ReactiveValue.d.ts +2 -0
  47. package/dist/cjs/util/ReactiveValue.js +2 -0
  48. package/dist/cjs/util/lib.d.ts +1 -0
  49. package/dist/cjs/util/lib.js +4 -1
  50. package/dist/cjs/util/waitForImageLoaded.d.ts +2 -0
  51. package/dist/cjs/util/waitForImageLoaded.js +12 -0
  52. package/dist/cjs/version.js +1 -1
  53. package/dist/mjs/Editor.d.ts +41 -0
  54. package/dist/mjs/Editor.mjs +33 -13
  55. package/dist/mjs/Pointer.mjs +1 -1
  56. package/dist/mjs/Viewport.d.ts +6 -0
  57. package/dist/mjs/Viewport.mjs +6 -1
  58. package/dist/mjs/commands/Erase.d.ts +22 -2
  59. package/dist/mjs/commands/Erase.mjs +22 -2
  60. package/dist/mjs/commands/uniteCommands.d.ts +36 -0
  61. package/dist/mjs/commands/uniteCommands.mjs +36 -0
  62. package/dist/mjs/components/ImageComponent.d.ts +12 -0
  63. package/dist/mjs/components/ImageComponent.mjs +16 -9
  64. package/dist/mjs/components/Stroke.d.ts +16 -2
  65. package/dist/mjs/components/Stroke.mjs +17 -1
  66. package/dist/mjs/components/builders/ArrowBuilder.mjs +3 -2
  67. package/dist/mjs/components/builders/CircleBuilder.mjs +3 -2
  68. package/dist/mjs/components/builders/FreehandLineBuilder.mjs +3 -2
  69. package/dist/mjs/components/builders/LineBuilder.mjs +3 -2
  70. package/dist/mjs/components/builders/PressureSensitiveFreehandLineBuilder.mjs +3 -2
  71. package/dist/mjs/components/builders/RectangleBuilder.mjs +5 -4
  72. package/dist/mjs/components/builders/autocorrect/makeShapeFitAutocorrect.d.ts +3 -0
  73. package/dist/mjs/components/builders/autocorrect/makeShapeFitAutocorrect.mjs +166 -0
  74. package/dist/mjs/components/builders/autocorrect/makeSnapToGridAutocorrect.d.ts +3 -0
  75. package/dist/mjs/components/builders/autocorrect/makeSnapToGridAutocorrect.mjs +44 -0
  76. package/dist/mjs/components/builders/types.d.ts +1 -0
  77. package/dist/mjs/image/EditorImage.d.ts +32 -1
  78. package/dist/mjs/image/EditorImage.mjs +32 -1
  79. package/dist/mjs/rendering/Display.mjs +8 -1
  80. package/dist/mjs/rendering/RenderablePathSpec.d.ts +5 -1
  81. package/dist/mjs/rendering/RenderablePathSpec.mjs +4 -0
  82. package/dist/mjs/toolbar/IconProvider.d.ts +2 -0
  83. package/dist/mjs/toolbar/IconProvider.mjs +17 -0
  84. package/dist/mjs/toolbar/localization.d.ts +3 -0
  85. package/dist/mjs/toolbar/localization.mjs +4 -1
  86. package/dist/mjs/toolbar/widgets/InsertImageWidget.d.ts +2 -1
  87. package/dist/mjs/toolbar/widgets/InsertImageWidget.mjs +102 -22
  88. package/dist/mjs/toolbar/widgets/PenToolWidget.d.ts +1 -2
  89. package/dist/mjs/toolbar/widgets/PenToolWidget.mjs +50 -20
  90. package/dist/mjs/tools/Pen.d.ts +9 -0
  91. package/dist/mjs/tools/Pen.mjs +77 -3
  92. package/dist/mjs/tools/TextTool.mjs +5 -1
  93. package/dist/mjs/tools/util/StationaryPenDetector.d.ts +22 -0
  94. package/dist/mjs/tools/util/StationaryPenDetector.mjs +92 -0
  95. package/dist/mjs/util/ReactiveValue.d.ts +2 -0
  96. package/dist/mjs/util/ReactiveValue.mjs +2 -0
  97. package/dist/mjs/util/lib.d.ts +1 -0
  98. package/dist/mjs/util/lib.mjs +1 -0
  99. package/dist/mjs/util/waitForImageLoaded.d.ts +2 -0
  100. package/dist/mjs/util/waitForImageLoaded.mjs +10 -0
  101. package/dist/mjs/version.mjs +1 -1
  102. package/package.json +3 -3
  103. package/src/Editor.scss +7 -0
  104. package/src/toolbar/AbstractToolbar.scss +20 -0
  105. package/src/toolbar/toolbar.scss +1 -1
  106. package/src/toolbar/widgets/InsertImageWidget.scss +6 -1
  107. package/src/toolbar/widgets/PenToolWidget.scss +33 -0
  108. package/src/tools/SelectionTool/SelectionTool.scss +6 -0
  109. package/src/toolbar/widgets/PenToolWidget.css +0 -2
@@ -3,10 +3,10 @@ import { ToolbarLocalization } from '../localization';
3
3
  import BaseWidget from './BaseWidget';
4
4
  export default class InsertImageWidget extends BaseWidget {
5
5
  private imagePreview;
6
+ private image;
6
7
  private selectedFiles;
7
8
  private imageAltTextInput;
8
9
  private statusView;
9
- private imageBase64URL;
10
10
  private submitButton;
11
11
  constructor(editor: Editor, localization?: ToolbarLocalization);
12
12
  protected getTitle(): string;
@@ -15,6 +15,7 @@ export default class InsertImageWidget extends BaseWidget {
15
15
  protected handleClick(): void;
16
16
  private static nextInputId;
17
17
  protected fillDropdown(dropdown: HTMLElement): boolean;
18
+ private onImageDataUpdate;
18
19
  private hideDialog;
19
20
  private updateImageSizeDisplay;
20
21
  private updateInputs;
@@ -14,10 +14,48 @@ const BaseWidget_1 = __importDefault(require("./BaseWidget"));
14
14
  const types_1 = require("../../types");
15
15
  const constants_1 = require("../constants");
16
16
  const makeFileInput_1 = __importDefault(require("./components/makeFileInput"));
17
+ class ImageWrapper {
18
+ constructor(imageBase64Url, preview, onUrlUpdate) {
19
+ this.imageBase64Url = imageBase64Url;
20
+ this.preview = preview;
21
+ this.onUrlUpdate = onUrlUpdate;
22
+ this.originalSrc = imageBase64Url;
23
+ preview.src = imageBase64Url;
24
+ }
25
+ updateImageData(base64DataUrl) {
26
+ this.preview.src = base64DataUrl;
27
+ this.imageBase64Url = base64DataUrl;
28
+ this.onUrlUpdate();
29
+ }
30
+ decreaseSize(resizeFactor = 3 / 4) {
31
+ const canvas = document.createElement('canvas');
32
+ canvas.width = this.preview.naturalWidth * resizeFactor;
33
+ canvas.height = this.preview.naturalHeight * resizeFactor;
34
+ const ctx = canvas.getContext('2d');
35
+ ctx?.drawImage(this.preview, 0, 0, canvas.width, canvas.height);
36
+ // JPEG can be much smaller than PNG for the same image size. Prefer it if
37
+ // the image is already a JPEG.
38
+ const format = this.originalSrc?.startsWith('data:image/jpeg;') ? 'image/jpeg' : 'image/png';
39
+ this.updateImageData(canvas.toDataURL(format));
40
+ }
41
+ reset() {
42
+ this.updateImageData(this.originalSrc);
43
+ }
44
+ isChanged() {
45
+ return this.imageBase64Url !== this.originalSrc;
46
+ }
47
+ getBase64Url() {
48
+ return this.imageBase64Url;
49
+ }
50
+ static fromSrcAndPreview(initialBase64Src, preview, onUrlUpdate) {
51
+ return new ImageWrapper(initialBase64Src, preview, onUrlUpdate);
52
+ }
53
+ }
17
54
  class InsertImageWidget extends BaseWidget_1.default {
18
55
  constructor(editor, localization) {
19
56
  localization ??= editor.localization;
20
57
  super(editor, 'insert-image-widget', localization);
58
+ this.image = null;
21
59
  // Make the dropdown showable
22
60
  this.container.classList.add('dropdownShowable');
23
61
  editor.notifier.on(types_1.EditorEventType.SelectionUpdated, event => {
@@ -51,6 +89,7 @@ class InsertImageWidget extends BaseWidget_1.default {
51
89
  this.statusView = document.createElement('div');
52
90
  const actionButtonRow = document.createElement('div');
53
91
  actionButtonRow.classList.add('action-button-row');
92
+ this.statusView.classList.add('insert-image-image-status-view');
54
93
  this.submitButton = document.createElement('button');
55
94
  this.selectedFiles = selectedFiles;
56
95
  this.imageAltTextInput = document.createElement('input');
@@ -65,9 +104,8 @@ class InsertImageWidget extends BaseWidget_1.default {
65
104
  this.submitButton.innerText = this.localizationTable.submit;
66
105
  this.selectedFiles.onUpdateAndNow(async (files) => {
67
106
  if (files.length === 0) {
68
- this.imagePreview.style.display = 'none';
69
- this.submitButton.disabled = true;
70
- this.submitButton.style.display = 'none';
107
+ this.image = null;
108
+ this.onImageDataUpdate();
71
109
  return;
72
110
  }
73
111
  this.imagePreview.style.display = 'block';
@@ -79,18 +117,13 @@ class InsertImageWidget extends BaseWidget_1.default {
79
117
  catch (e) {
80
118
  this.statusView.innerText = this.localizationTable.imageLoadError(e);
81
119
  }
82
- this.imageBase64URL = data;
83
120
  if (data) {
84
- this.imagePreview.src = data;
85
- this.submitButton.disabled = false;
86
- this.submitButton.style.display = '';
87
- this.updateImageSizeDisplay();
121
+ this.image = ImageWrapper.fromSrcAndPreview(data, this.imagePreview, () => this.onImageDataUpdate());
88
122
  }
89
123
  else {
90
- this.submitButton.disabled = true;
91
- this.submitButton.style.display = 'none';
92
- this.statusView.innerText = '';
124
+ this.image = null;
93
125
  }
126
+ this.onImageDataUpdate();
94
127
  });
95
128
  altTextRow.replaceChildren(imageAltTextLabel, this.imageAltTextInput);
96
129
  actionButtonRow.replaceChildren(this.submitButton);
@@ -98,11 +131,27 @@ class InsertImageWidget extends BaseWidget_1.default {
98
131
  dropdown.replaceChildren(container);
99
132
  return true;
100
133
  }
134
+ onImageDataUpdate() {
135
+ const base64Data = this.image?.getBase64Url();
136
+ if (base64Data) {
137
+ this.submitButton.disabled = false;
138
+ this.submitButton.style.display = '';
139
+ this.imagePreview.style.display = '';
140
+ this.updateImageSizeDisplay();
141
+ }
142
+ else {
143
+ this.submitButton.disabled = true;
144
+ this.submitButton.style.display = 'none';
145
+ this.statusView.innerText = '';
146
+ this.imagePreview.style.display = 'none';
147
+ this.submitButton.disabled = true;
148
+ }
149
+ }
101
150
  hideDialog() {
102
151
  this.setDropdownVisible(false);
103
152
  }
104
153
  updateImageSizeDisplay() {
105
- const imageData = this.imageBase64URL ?? '';
154
+ const imageData = this.image?.getBase64Url() ?? '';
106
155
  const sizeInKiB = imageData.length / 1024;
107
156
  const sizeInMiB = sizeInKiB / 1024;
108
157
  let units = 'KiB';
@@ -111,7 +160,27 @@ class InsertImageWidget extends BaseWidget_1.default {
111
160
  size = sizeInMiB;
112
161
  units = 'MiB';
113
162
  }
114
- this.statusView.innerText = this.localizationTable.imageSize(Math.round(size), units);
163
+ const sizeText = document.createElement('span');
164
+ sizeText.innerText = this.localizationTable.imageSize(Math.round(size), units);
165
+ // Add a button to allow decreasing the size of large images.
166
+ const decreaseSizeButton = document.createElement('button');
167
+ decreaseSizeButton.innerText = this.localizationTable.decreaseImageSize;
168
+ decreaseSizeButton.onclick = () => {
169
+ this.image?.decreaseSize();
170
+ };
171
+ const resetSizeButton = document.createElement('button');
172
+ resetSizeButton.innerText = this.localizationTable.resetImage;
173
+ resetSizeButton.onclick = () => {
174
+ this.image?.reset();
175
+ };
176
+ this.statusView.replaceChildren(sizeText);
177
+ const largeImageThreshold = 0.25; // MiB
178
+ if (sizeInMiB > largeImageThreshold) {
179
+ this.statusView.appendChild(decreaseSizeButton);
180
+ }
181
+ else if (this.image?.isChanged()) {
182
+ this.statusView.appendChild(resetSizeButton);
183
+ }
115
184
  }
116
185
  updateInputs() {
117
186
  const resetInputs = () => {
@@ -131,11 +200,8 @@ class InsertImageWidget extends BaseWidget_1.default {
131
200
  if (selectedObjects.length === 1 && selectedObjects[0] instanceof ImageComponent_1.default) {
132
201
  editingImage = selectedObjects[0];
133
202
  this.imageAltTextInput.value = editingImage.getAltText() ?? '';
134
- this.imagePreview.style.display = 'block';
135
- this.submitButton.disabled = false;
136
- this.imageBase64URL = editingImage.getURL();
137
- this.imagePreview.src = this.imageBase64URL;
138
- this.updateImageSizeDisplay();
203
+ this.image = ImageWrapper.fromSrcAndPreview(editingImage.getURL(), this.imagePreview, () => this.onImageDataUpdate());
204
+ this.onImageDataUpdate();
139
205
  }
140
206
  else if (selectedObjects.length > 0) {
141
207
  // If not, clear the selection.
@@ -149,13 +215,21 @@ class InsertImageWidget extends BaseWidget_1.default {
149
215
  }
150
216
  };
151
217
  this.submitButton.onclick = async () => {
152
- if (!this.imageBase64URL) {
218
+ if (!this.image) {
153
219
  return;
154
220
  }
155
221
  const image = new Image();
156
- image.src = this.imageBase64URL;
222
+ image.src = this.image.getBase64Url();
157
223
  image.setAttribute('alt', this.imageAltTextInput.value);
158
- const component = await ImageComponent_1.default.fromImage(image, math_1.Mat33.identity);
224
+ let component;
225
+ try {
226
+ component = await ImageComponent_1.default.fromImage(image, math_1.Mat33.identity);
227
+ }
228
+ catch (error) {
229
+ console.error('Error loading image', error);
230
+ this.statusView.innerText = this.localizationTable.imageLoadError(error);
231
+ return;
232
+ }
159
233
  if (component.getBBox().area === 0) {
160
234
  this.statusView.innerText = this.localizationTable.errorImageHasZeroSize;
161
235
  return;
@@ -163,9 +237,15 @@ class InsertImageWidget extends BaseWidget_1.default {
163
237
  this.hideDialog();
164
238
  if (editingImage) {
165
239
  const eraseCommand = new Erase_1.default([editingImage]);
240
+ // Try to preserve the original width
241
+ const originalTransform = editingImage.getTransformation();
242
+ // || 1: Prevent division by zero
243
+ const originalWidth = editingImage.getBBox().width || 1;
244
+ const newWidth = component.getBBox().transformedBoundingBox(originalTransform).width || 1;
245
+ const widthAdjustTransform = math_1.Mat33.scaling2D(originalWidth / newWidth);
166
246
  await this.editor.dispatch((0, uniteCommands_1.default)([
167
247
  EditorImage_1.default.addElement(component),
168
- component.transformBy(editingImage.getTransformation()),
248
+ component.transformBy(originalTransform.rightMul(widthAdjustTransform)),
169
249
  component.setZIndex(editingImage.getZIndex()),
170
250
  eraseCommand,
171
251
  ]));
@@ -24,8 +24,7 @@ export default class PenToolWidget extends BaseToolWidget {
24
24
  private createIconForRecord;
25
25
  protected createIcon(): Element;
26
26
  private createPenTypeSelector;
27
- private setInputStabilizationEnabled;
28
- protected createStabilizationOption(): {
27
+ protected createStrokeCorrectionOptions(): {
29
28
  update: () => void;
30
29
  addTo: (parent: HTMLElement) => void;
31
30
  };
@@ -152,27 +152,51 @@ class PenToolWidget extends BaseToolWidget_1.default {
152
152
  },
153
153
  };
154
154
  }
155
- setInputStabilizationEnabled(enabled) {
156
- this.tool.setHasStabilization(enabled);
157
- }
158
- createStabilizationOption() {
159
- const stabilizationOption = document.createElement('div');
160
- const stabilizationCheckbox = document.createElement('input');
161
- const stabilizationLabel = document.createElement('label');
162
- stabilizationLabel.innerText = this.localizationTable.inputStabilization;
163
- stabilizationCheckbox.type = 'checkbox';
164
- stabilizationCheckbox.id = `${constants_1.toolbarCSSPrefix}-penInputStabilizationCheckbox-${PenToolWidget.idCounter++}`;
165
- stabilizationLabel.htmlFor = stabilizationCheckbox.id;
166
- stabilizationOption.replaceChildren(stabilizationLabel, stabilizationCheckbox);
167
- stabilizationCheckbox.oninput = () => {
168
- this.setInputStabilizationEnabled(stabilizationCheckbox.checked);
155
+ createStrokeCorrectionOptions() {
156
+ const container = document.createElement('div');
157
+ container.classList.add('action-button-row', `${constants_1.toolbarCSSPrefix}-pen-tool-toggle-buttons`);
158
+ const addToggleButton = (labelText, icon) => {
159
+ const button = document.createElement('button');
160
+ button.classList.add(`${constants_1.toolbarCSSPrefix}-toggle-button`);
161
+ const iconElement = icon.cloneNode(true);
162
+ iconElement.classList.add('icon');
163
+ const label = document.createElement('span');
164
+ label.innerText = labelText;
165
+ button.replaceChildren(iconElement, label);
166
+ button.setAttribute('role', 'switch');
167
+ container.appendChild(button);
168
+ let checked = false;
169
+ let onChangeListener = (_checked) => { };
170
+ const result = {
171
+ setChecked(newChecked) {
172
+ checked = newChecked;
173
+ button.setAttribute('aria-checked', `${checked}`);
174
+ onChangeListener(checked);
175
+ },
176
+ setOnInputListener(listener) {
177
+ onChangeListener = listener;
178
+ },
179
+ };
180
+ button.onclick = () => {
181
+ result.setChecked(!checked);
182
+ };
183
+ return result;
169
184
  };
185
+ const stabilizationOption = addToggleButton(this.localizationTable.inputStabilization, this.editor.icons.makeStrokeSmoothingIcon());
186
+ stabilizationOption.setOnInputListener(enabled => {
187
+ this.tool.setHasStabilization(enabled);
188
+ });
189
+ const autocorrectOption = addToggleButton(this.localizationTable.strokeAutocorrect, this.editor.icons.makeShapeAutocorrectIcon());
190
+ autocorrectOption.setOnInputListener(enabled => {
191
+ this.tool.setStrokeAutocorrectEnabled(enabled);
192
+ });
170
193
  return {
171
194
  update: () => {
172
- stabilizationCheckbox.checked = !!this.tool.getInputMapper();
195
+ stabilizationOption.setChecked(!!this.tool.getInputMapper());
196
+ autocorrectOption.setChecked(this.tool.getStrokeAutocorrectionEnabled());
173
197
  },
174
198
  addTo: (parent) => {
175
- parent.appendChild(stabilizationOption);
199
+ parent.appendChild(container);
176
200
  }
177
201
  };
178
202
  }
@@ -194,20 +218,22 @@ class PenToolWidget extends BaseToolWidget_1.default {
194
218
  colorLabel.setAttribute('for', colorInput.id);
195
219
  colorRow.appendChild(colorLabel);
196
220
  colorRow.appendChild(colorInputContainer);
197
- const stabilizationOption = this.createStabilizationOption();
221
+ const toggleButtonRow = this.createStrokeCorrectionOptions();
198
222
  this.updateInputs = () => {
199
223
  setColorInputValue(this.tool.getColor());
200
224
  setThickness(this.tool.getThickness());
201
225
  penTypeSelect.updateIcons();
202
226
  // Update the selected stroke factory.
203
227
  penTypeSelect.setValue(this.getCurrentPenTypeIdx());
204
- stabilizationOption.update();
228
+ toggleButtonRow.update();
205
229
  };
206
230
  this.updateInputs();
207
231
  container.replaceChildren(colorRow, thicknessRow);
208
232
  penTypeSelect.addTo(container);
209
- stabilizationOption.addTo(container);
210
233
  dropdown.replaceChildren(container);
234
+ // Add the toggle button row *outside* of the main content (use different
235
+ // spacing with respect to the sides of the container).
236
+ toggleButtonRow.addTo(dropdown);
211
237
  return true;
212
238
  }
213
239
  onKeyPress(event) {
@@ -237,6 +263,7 @@ class PenToolWidget extends BaseToolWidget_1.default {
237
263
  thickness: this.tool.getThickness(),
238
264
  strokeFactoryId: this.getCurrentPenType()?.id,
239
265
  inputStabilization: !!this.tool.getInputMapper(),
266
+ strokeAutocorrect: this.tool.getStrokeAutocorrectionEnabled(),
240
267
  };
241
268
  }
242
269
  deserializeFrom(state) {
@@ -267,7 +294,10 @@ class PenToolWidget extends BaseToolWidget_1.default {
267
294
  }
268
295
  }
269
296
  if (state.inputStabilization !== undefined) {
270
- this.setInputStabilizationEnabled(!!state.inputStabilization);
297
+ this.tool.setHasStabilization(!!state.inputStabilization);
298
+ }
299
+ if (state.strokeAutocorrect !== undefined) {
300
+ this.tool.setStrokeAutocorrectEnabled(!!state.strokeAutocorrect);
271
301
  }
272
302
  }
273
303
  }
@@ -19,6 +19,11 @@ export default class Pen extends BaseTool {
19
19
  private currentDeviceType;
20
20
  private styleValue;
21
21
  private style;
22
+ private shapeAutocompletionEnabled;
23
+ private autocorrectedShape;
24
+ private lastAutocorrectedShape;
25
+ private removedAutocorrectedShapeTime;
26
+ private stationaryDetector;
22
27
  constructor(editor: Editor, description: string, style: Partial<PenStyle>);
23
28
  private getPressureMultiplier;
24
29
  protected toStrokePoint(pointer: Pointer): StrokeDataPoint;
@@ -30,12 +35,16 @@ export default class Pen extends BaseTool {
30
35
  onPointerMove({ current }: PointerEvt): void;
31
36
  onPointerUp({ current }: PointerEvt): boolean;
32
37
  onGestureCancel(): void;
38
+ private removedAutocorrectedShapeRecently;
39
+ private autocorrectShape;
33
40
  private finalizeStroke;
34
41
  private noteUpdated;
35
42
  setColor(color: Color4): void;
36
43
  setThickness(thickness: number): void;
37
44
  setStrokeFactory(factory: ComponentBuilderFactory): void;
38
45
  setHasStabilization(hasStabilization: boolean): void;
46
+ setStrokeAutocorrectEnabled(enabled: boolean): void;
47
+ getStrokeAutocorrectionEnabled(): boolean;
39
48
  getThickness(): number;
40
49
  getColor(): Color4;
41
50
  getStrokeFactory(): ComponentBuilderFactory;
@@ -13,6 +13,7 @@ const keybindings_1 = require("./keybindings");
13
13
  const keybindings_2 = require("./keybindings");
14
14
  const InputStabilizer_1 = __importDefault(require("./InputFilter/InputStabilizer"));
15
15
  const ReactiveValue_1 = require("../util/ReactiveValue");
16
+ const StationaryPenDetector_1 = __importDefault(require("./util/StationaryPenDetector"));
16
17
  class Pen extends BaseTool_1.default {
17
18
  constructor(editor, description, style) {
18
19
  super(editor.notifier, description);
@@ -21,6 +22,11 @@ class Pen extends BaseTool_1.default {
21
22
  this.lastPoint = null;
22
23
  this.startPoint = null;
23
24
  this.currentDeviceType = null;
25
+ this.shapeAutocompletionEnabled = false;
26
+ this.autocorrectedShape = null;
27
+ this.lastAutocorrectedShape = null;
28
+ this.removedAutocorrectedShapeTime = 0;
29
+ this.stationaryDetector = null;
24
30
  this.styleValue = ReactiveValue_1.ReactiveValue.fromInitialValue({
25
31
  factory: FreehandLineBuilder_1.makeFreehandLineBuilder,
26
32
  color: math_1.Color4.blue,
@@ -58,7 +64,14 @@ class Pen extends BaseTool_1.default {
58
64
  // Displays the stroke that is currently being built with the display's `wetInkRenderer`.
59
65
  previewStroke() {
60
66
  this.editor.clearWetInk();
61
- this.builder?.preview(this.editor.display.getWetInkRenderer());
67
+ const wetInkRenderer = this.editor.display.getWetInkRenderer();
68
+ if (this.autocorrectedShape) {
69
+ const visibleRect = this.editor.viewport.visibleRect;
70
+ this.autocorrectedShape.render(wetInkRenderer, visibleRect);
71
+ }
72
+ else {
73
+ this.builder?.preview(wetInkRenderer);
74
+ }
62
75
  }
63
76
  // Throws if no stroke builder exists.
64
77
  addPointToStroke(point) {
@@ -87,6 +100,19 @@ class Pen extends BaseTool_1.default {
87
100
  this.startPoint = this.toStrokePoint(current);
88
101
  this.builder = this.style.factory(this.startPoint, this.editor.viewport);
89
102
  this.currentDeviceType = current.device;
103
+ if (this.shapeAutocompletionEnabled) {
104
+ const stationaryDetectionConfig = {
105
+ maxSpeed: 5,
106
+ maxRadius: 10,
107
+ minTimeSeconds: 0.5, // s
108
+ };
109
+ this.stationaryDetector = new StationaryPenDetector_1.default(current, stationaryDetectionConfig, pointer => this.autocorrectShape(pointer));
110
+ }
111
+ else {
112
+ this.stationaryDetector = null;
113
+ }
114
+ this.lastAutocorrectedShape = null;
115
+ this.removedAutocorrectedShapeTime = 0;
90
116
  return true;
91
117
  }
92
118
  return false;
@@ -114,7 +140,14 @@ class Pen extends BaseTool_1.default {
114
140
  return;
115
141
  if (current.device !== this.currentDeviceType)
116
142
  return;
117
- this.addPointToStroke(this.toStrokePoint(current));
143
+ const isStationary = this.stationaryDetector?.onPointerMove(current);
144
+ if (!isStationary) {
145
+ this.addPointToStroke(this.toStrokePoint(current));
146
+ if (this.autocorrectedShape) {
147
+ this.removedAutocorrectedShapeTime = performance.now();
148
+ this.autocorrectedShape = null;
149
+ }
150
+ }
118
151
  }
119
152
  onPointerUp({ current }) {
120
153
  if (!this.builder)
@@ -124,6 +157,7 @@ class Pen extends BaseTool_1.default {
124
157
  // device type.
125
158
  return true;
126
159
  }
160
+ this.stationaryDetector?.onPointerUp(current);
127
161
  // onPointerUp events can have zero pressure. Use the last pressure instead.
128
162
  const currentPoint = this.toStrokePoint(current);
129
163
  const strokePoint = {
@@ -139,10 +173,37 @@ class Pen extends BaseTool_1.default {
139
173
  onGestureCancel() {
140
174
  this.builder = null;
141
175
  this.editor.clearWetInk();
176
+ this.stationaryDetector?.destroy();
177
+ this.stationaryDetector = null;
178
+ }
179
+ removedAutocorrectedShapeRecently() {
180
+ return this.removedAutocorrectedShapeTime > performance.now() - 320;
181
+ }
182
+ async autocorrectShape(_lastPointer) {
183
+ if (!this.builder || !this.builder.autocorrectShape)
184
+ return;
185
+ if (!this.shapeAutocompletionEnabled)
186
+ return;
187
+ // If already corrected, do nothing
188
+ if (this.autocorrectedShape)
189
+ return;
190
+ // Activate stroke fitting
191
+ const correctedShape = await this.builder.autocorrectShape();
192
+ if (!this.builder || !correctedShape) {
193
+ return;
194
+ }
195
+ this.autocorrectedShape = correctedShape;
196
+ this.lastAutocorrectedShape = correctedShape;
197
+ this.previewStroke();
142
198
  }
143
199
  finalizeStroke() {
144
200
  if (this.builder) {
145
- const stroke = this.builder.build();
201
+ // If autocorrectedShape was cleared recently enough, it was
202
+ // probably by mistake. Reset it.
203
+ if (this.lastAutocorrectedShape && this.removedAutocorrectedShapeRecently()) {
204
+ this.autocorrectedShape = this.lastAutocorrectedShape;
205
+ }
206
+ const stroke = this.autocorrectedShape ?? this.builder.build();
146
207
  this.previewStroke();
147
208
  if (stroke.getBBox().area > 0) {
148
209
  const canFlatten = true;
@@ -155,7 +216,11 @@ class Pen extends BaseTool_1.default {
155
216
  }
156
217
  this.builder = null;
157
218
  this.lastPoint = null;
219
+ this.autocorrectedShape = null;
220
+ this.lastAutocorrectedShape = null;
158
221
  this.editor.clearWetInk();
222
+ this.stationaryDetector?.destroy();
223
+ this.stationaryDetector = null;
159
224
  }
160
225
  noteUpdated() {
161
226
  this.editor.notifier.dispatch(types_1.EditorEventType.ToolUpdated, {
@@ -201,6 +266,15 @@ class Pen extends BaseTool_1.default {
201
266
  }
202
267
  this.noteUpdated();
203
268
  }
269
+ setStrokeAutocorrectEnabled(enabled) {
270
+ if (enabled !== this.shapeAutocompletionEnabled) {
271
+ this.shapeAutocompletionEnabled = enabled;
272
+ this.noteUpdated();
273
+ }
274
+ }
275
+ getStrokeAutocorrectionEnabled() {
276
+ return this.shapeAutocompletionEnabled;
277
+ }
204
278
  getThickness() { return this.style.thickness; }
205
279
  getColor() { return this.style.color; }
206
280
  getStrokeFactory() { return this.style.factory; }
@@ -44,6 +44,10 @@ class TextTool extends BaseTool_1.default {
44
44
  .${overlayCSSClass} {
45
45
  height: 0;
46
46
  overflow: visible;
47
+
48
+ /* Allows absolutely-positioned textareas to scroll with
49
+ the containing overlay. */
50
+ position: relative;
47
51
  }
48
52
 
49
53
  .${overlayCSSClass} textarea {
@@ -126,7 +130,7 @@ class TextTool extends BaseTool_1.default {
126
130
  this.textInputElem.style.fontWeight = this.textStyle.fontWeight ?? '';
127
131
  this.textInputElem.style.fontSize = `${this.textStyle.size}px`;
128
132
  this.textInputElem.style.color = this.textStyle.renderingStyle.fill.toHexString();
129
- this.textInputElem.style.position = 'relative';
133
+ this.textInputElem.style.position = 'absolute';
130
134
  this.textInputElem.style.left = `${textScreenPos.x}px`;
131
135
  this.textInputElem.style.top = `${textScreenPos.y}px`;
132
136
  this.textInputElem.style.margin = '0';
@@ -0,0 +1,22 @@
1
+ import Pointer from '../../Pointer';
2
+ interface Config {
3
+ maxSpeed: number;
4
+ minTimeSeconds: number;
5
+ maxRadius: number;
6
+ }
7
+ type OnStationaryCallback = (lastPointer: Pointer) => void;
8
+ export default class StationaryPenDetector {
9
+ private config;
10
+ private onStationary;
11
+ private stationaryStartPointer;
12
+ private lastPointer;
13
+ private averageVelocity;
14
+ private timeout;
15
+ constructor(startPointer: Pointer, config: Config, onStationary: OnStationaryCallback);
16
+ onPointerMove(currentPointer: Pointer): boolean | undefined;
17
+ onPointerUp(pointer: Pointer): void;
18
+ destroy(): void;
19
+ private cancelStationaryTimeout;
20
+ private setStationaryTimeout;
21
+ }
22
+ export {};
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const math_1 = require("@js-draw/math");
4
+ class StationaryPenDetector {
5
+ // Only handles one pen. As such, `startPointer` should be the same device/finger
6
+ // as `updatedPointer` in `onPointerMove`.
7
+ //
8
+ // A new `StationaryPenDetector` should be created for each gesture.
9
+ constructor(startPointer, config, onStationary) {
10
+ this.config = config;
11
+ this.onStationary = onStationary;
12
+ this.timeout = null;
13
+ this.stationaryStartPointer = startPointer;
14
+ this.lastPointer = startPointer;
15
+ this.averageVelocity = math_1.Vec2.zero;
16
+ }
17
+ // Returns true if stationary
18
+ onPointerMove(currentPointer) {
19
+ if (!this.stationaryStartPointer) {
20
+ // Destoroyed
21
+ return;
22
+ }
23
+ if (currentPointer.id !== this.stationaryStartPointer.id) {
24
+ return false;
25
+ }
26
+ // dx: "Δx" Displacement from last.
27
+ const dxFromLast = currentPointer.screenPos.minus(this.lastPointer.screenPos);
28
+ const dxFromStationaryStart = currentPointer.screenPos.minus(this.stationaryStartPointer.screenPos);
29
+ // dt: Delta time:
30
+ // /1000: Convert to s.
31
+ let dtFromLast = (currentPointer.timeStamp - this.lastPointer.timeStamp) / 1000; // s
32
+ // Don't divide by zero
33
+ if (dtFromLast === 0) {
34
+ dtFromLast = 1;
35
+ }
36
+ const currentVelocity = dxFromLast.times(1 / dtFromLast); // px/s
37
+ // Slight smoothing of the velocity to prevent input jitter from affecting the
38
+ // velocity too significantly.
39
+ this.averageVelocity = this.averageVelocity.lerp(currentVelocity, 0.5); // px/s
40
+ const dtFromStart = currentPointer.timeStamp - this.stationaryStartPointer.timeStamp; // ms
41
+ // If not stationary
42
+ if (dxFromStationaryStart.length() > this.config.maxRadius
43
+ || this.averageVelocity.length() > this.config.maxSpeed
44
+ || dtFromStart < this.config.minTimeSeconds) {
45
+ this.stationaryStartPointer = currentPointer;
46
+ this.lastPointer = currentPointer;
47
+ this.setStationaryTimeout(this.config.minTimeSeconds * 1000);
48
+ return false;
49
+ }
50
+ const stationaryTimeoutMs = this.config.minTimeSeconds * 1000 - dtFromStart;
51
+ this.lastPointer = currentPointer;
52
+ return stationaryTimeoutMs <= 0;
53
+ }
54
+ onPointerUp(pointer) {
55
+ if (pointer.id !== this.stationaryStartPointer?.id) {
56
+ this.cancelStationaryTimeout();
57
+ }
58
+ }
59
+ destroy() {
60
+ this.cancelStationaryTimeout();
61
+ this.stationaryStartPointer = null;
62
+ }
63
+ cancelStationaryTimeout() {
64
+ if (this.timeout !== null) {
65
+ clearTimeout(this.timeout);
66
+ this.timeout = null;
67
+ }
68
+ }
69
+ setStationaryTimeout(timeoutMs) {
70
+ if (this.timeout !== null) {
71
+ return;
72
+ }
73
+ if (timeoutMs <= 0) {
74
+ this.onStationary(this.lastPointer);
75
+ }
76
+ else {
77
+ this.timeout = setTimeout(() => {
78
+ this.timeout = null;
79
+ if (!this.stationaryStartPointer) {
80
+ // Destroyed
81
+ return;
82
+ }
83
+ const timeSinceStationaryStart = performance.now() - this.stationaryStartPointer.timeStamp;
84
+ const timeRemaining = this.config.minTimeSeconds * 1000 - timeSinceStationaryStart;
85
+ if (timeRemaining <= 0) {
86
+ this.onStationary(this.lastPointer);
87
+ }
88
+ else {
89
+ this.setStationaryTimeout(timeRemaining);
90
+ }
91
+ }, timeoutMs);
92
+ }
93
+ }
94
+ }
95
+ exports.default = StationaryPenDetector;
@@ -13,6 +13,8 @@ type UpdateCallback<T> = (value: T) => void;
13
13
  *
14
14
  * Static methods in the `ReactiveValue` and `MutableReactiveValue` classes are
15
15
  * constructors (e.g. `fromImmutable`).
16
+ *
17
+ * Avoid extending this class from an external library, as that may not be stable.
16
18
  */
17
19
  export declare abstract class ReactiveValue<T> {
18
20
  /**