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
@@ -0,0 +1,166 @@
1
+ import { Rect2, LineSegment2 } from '@js-draw/math';
2
+ const makeShapeFitAutocorrect = (sourceFactory) => {
3
+ return (startPoint, viewport) => {
4
+ return new ShapeFitBuilder(sourceFactory, startPoint, viewport);
5
+ };
6
+ };
7
+ export default makeShapeFitAutocorrect;
8
+ const makeLineTemplate = (startPoint, points, _bbox) => {
9
+ const templatePoints = [
10
+ startPoint,
11
+ points[points.length - 1],
12
+ ];
13
+ return { points: templatePoints, };
14
+ };
15
+ const makeRectangleTemplate = (_startPoint, _points, bbox) => {
16
+ return { points: [...bbox.corners, bbox.corners[0]], };
17
+ };
18
+ class ShapeFitBuilder {
19
+ constructor(sourceFactory, startPoint, viewport) {
20
+ this.sourceFactory = sourceFactory;
21
+ this.startPoint = startPoint;
22
+ this.viewport = viewport;
23
+ this.builder = sourceFactory(startPoint, viewport);
24
+ this.points = [startPoint];
25
+ }
26
+ getBBox() {
27
+ return this.builder.getBBox();
28
+ }
29
+ build() {
30
+ return this.builder.build();
31
+ }
32
+ preview(renderer) {
33
+ this.builder.preview(renderer);
34
+ }
35
+ addPoint(point) {
36
+ this.points.push(point);
37
+ this.builder.addPoint(point);
38
+ }
39
+ async autocorrectShape() {
40
+ // Use screen points so that autocorrected shapes rotate with the screen.
41
+ const startPoint = this.viewport.canvasToScreen(this.startPoint.pos);
42
+ const points = this.points.map(point => this.viewport.canvasToScreen(point.pos));
43
+ const bbox = Rect2.bboxOf(points);
44
+ const snappedStartPoint = this.viewport.canvasToScreen(this.viewport.snapToGrid(this.startPoint.pos));
45
+ const snappedPoints = this.points.map(point => this.viewport.canvasToScreen(this.viewport.snapToGrid(point.pos)));
46
+ const snappedBBox = Rect2.bboxOf(snappedPoints);
47
+ // Only fit larger shapes
48
+ if (bbox.maxDimension < 32) {
49
+ return null;
50
+ }
51
+ const maxError = Math.min(30, bbox.maxDimension / 4);
52
+ // Create templates
53
+ const templates = [
54
+ {
55
+ ...makeLineTemplate(snappedStartPoint, snappedPoints, snappedBBox),
56
+ toleranceMultiplier: 0.5,
57
+ },
58
+ makeLineTemplate(startPoint, points, bbox),
59
+ {
60
+ ...makeRectangleTemplate(snappedStartPoint, snappedPoints, snappedBBox),
61
+ toleranceMultiplier: 0.6,
62
+ },
63
+ makeRectangleTemplate(startPoint, points, bbox),
64
+ ];
65
+ // Find a good fit fit
66
+ const selectTemplate = (maximumAllowedError) => {
67
+ for (const template of templates) {
68
+ const templatePoints = template.points;
69
+ // Maximum square error to accept the template
70
+ const acceptMaximumSquareError = maximumAllowedError * maximumAllowedError * (template.toleranceMultiplier ?? 1);
71
+ // Gets the point at index, wrapping the the start of the template if
72
+ // outside the array of points.
73
+ const templateAt = (index) => {
74
+ while (index < 0) {
75
+ index += templatePoints.length;
76
+ }
77
+ index %= templatePoints.length;
78
+ return templatePoints[index];
79
+ };
80
+ let closestToFirst = null;
81
+ let closestToFirstSqrDist = Infinity;
82
+ let templateStartIndex = 0;
83
+ // Find the closest point to the startPoint
84
+ for (let i = 0; i < templatePoints.length; i++) {
85
+ const current = templatePoints[i];
86
+ const currentSqrDist = current.minus(startPoint).magnitudeSquared();
87
+ if (!closestToFirst || currentSqrDist < closestToFirstSqrDist) {
88
+ closestToFirstSqrDist = currentSqrDist;
89
+ closestToFirst = current;
90
+ templateStartIndex = i;
91
+ }
92
+ }
93
+ // Walk through the points and find the maximum error
94
+ let maximumSqrError = 0;
95
+ let templateIndex = templateStartIndex;
96
+ for (const point of points) {
97
+ let minimumCurrentSqrError = Infinity;
98
+ let minimumErrorAtIndex = templateIndex;
99
+ const windowRadius = 6;
100
+ for (let i = -windowRadius; i <= windowRadius; i++) {
101
+ const index = templateIndex + i;
102
+ const prevTemplatePoint = templateAt(index - 1);
103
+ const currentTemplatePoint = templateAt(index);
104
+ const nextTemplatePoint = templateAt(index + 1);
105
+ const prevToCurrent = new LineSegment2(prevTemplatePoint, currentTemplatePoint);
106
+ const currentToNext = new LineSegment2(currentTemplatePoint, nextTemplatePoint);
107
+ const prevToCurrentDist = prevToCurrent.distance(point);
108
+ const nextToCurrentDist = currentToNext.distance(point);
109
+ const error = Math.min(prevToCurrentDist, nextToCurrentDist);
110
+ const squareError = error * error;
111
+ if (squareError < minimumCurrentSqrError) {
112
+ minimumCurrentSqrError = squareError;
113
+ minimumErrorAtIndex = index;
114
+ }
115
+ }
116
+ templateIndex = minimumErrorAtIndex;
117
+ maximumSqrError = Math.max(minimumCurrentSqrError, maximumSqrError);
118
+ if (maximumSqrError > acceptMaximumSquareError) {
119
+ break;
120
+ }
121
+ }
122
+ if (maximumSqrError < acceptMaximumSquareError) {
123
+ return templatePoints;
124
+ }
125
+ }
126
+ return null;
127
+ };
128
+ const template = selectTemplate(maxError);
129
+ if (!template) {
130
+ return null;
131
+ }
132
+ const lastDataPoint = this.points[this.points.length - 1];
133
+ const startWidth = this.startPoint.width;
134
+ const endWidth = lastDataPoint.width;
135
+ const startColor = this.startPoint.color;
136
+ const endColor = lastDataPoint.color;
137
+ const startTime = this.startPoint.time;
138
+ const endTime = lastDataPoint.time;
139
+ const templateIndexToStrokeDataPoint = (index) => {
140
+ const prevPoint = template[Math.max(0, Math.floor(index))];
141
+ const nextPoint = template[Math.min(Math.ceil(index), template.length - 1)];
142
+ const point = prevPoint.lerp(nextPoint, index - Math.floor(index));
143
+ const fractionToEnd = index / template.length;
144
+ return {
145
+ pos: this.viewport.screenToCanvas(point),
146
+ width: startWidth * (1 - fractionToEnd) + endWidth * fractionToEnd,
147
+ color: startColor.mix(endColor, fractionToEnd),
148
+ time: startTime * (1 - fractionToEnd) + endTime * fractionToEnd,
149
+ };
150
+ };
151
+ const builder = this.sourceFactory(templateIndexToStrokeDataPoint(0), this.viewport);
152
+ // Prevent the original builder from doing stroke smoothing if the template is short
153
+ // enough to likely have sharp corners.
154
+ const preventSmoothing = template.length < 10;
155
+ for (let i = 0; i < template.length; i++) {
156
+ if (preventSmoothing) {
157
+ builder.addPoint(templateIndexToStrokeDataPoint(i - 0.001));
158
+ }
159
+ builder.addPoint(templateIndexToStrokeDataPoint(i));
160
+ if (preventSmoothing) {
161
+ builder.addPoint(templateIndexToStrokeDataPoint(i + 0.001));
162
+ }
163
+ }
164
+ return builder.build();
165
+ }
166
+ }
@@ -0,0 +1,3 @@
1
+ import { ComponentBuilderFactory } from '../types';
2
+ declare const makeSnapToGridAutocorrect: (sourceFactory: ComponentBuilderFactory) => ComponentBuilderFactory;
3
+ export default makeSnapToGridAutocorrect;
@@ -0,0 +1,44 @@
1
+ const makeSnapToGridAutocorrect = (sourceFactory) => {
2
+ return (startPoint, viewport) => {
3
+ return new SnapToGridAutocompleteBuilder(sourceFactory, startPoint, viewport);
4
+ };
5
+ };
6
+ export default makeSnapToGridAutocorrect;
7
+ class SnapToGridAutocompleteBuilder {
8
+ constructor(sourceFactory, startPoint, viewport) {
9
+ this.sourceFactory = sourceFactory;
10
+ this.startPoint = startPoint;
11
+ this.viewport = viewport;
12
+ this.builder = sourceFactory(startPoint, viewport);
13
+ this.points = [startPoint];
14
+ }
15
+ getBBox() {
16
+ return this.builder.getBBox();
17
+ }
18
+ build() {
19
+ return this.builder.build();
20
+ }
21
+ preview(renderer) {
22
+ this.builder.preview(renderer);
23
+ }
24
+ addPoint(point) {
25
+ this.points.push(point);
26
+ this.builder.addPoint(point);
27
+ }
28
+ async autocorrectShape() {
29
+ const snapToGrid = (point) => {
30
+ return {
31
+ ...point,
32
+ pos: this.viewport.snapToGrid(point.pos),
33
+ };
34
+ };
35
+ // Use screen points so that snapped shapes rotate with the screen.
36
+ const startPoint = snapToGrid(this.startPoint);
37
+ const builder = this.sourceFactory(startPoint, this.viewport);
38
+ const points = this.points.map(point => snapToGrid(point));
39
+ for (const point of points) {
40
+ builder.addPoint(point);
41
+ }
42
+ return builder.build();
43
+ }
44
+ }
@@ -7,6 +7,7 @@ export interface ComponentBuilder {
7
7
  getBBox(): Rect2;
8
8
  build(): AbstractComponent;
9
9
  preview(renderer: AbstractRenderer): void;
10
+ autocorrectShape?: () => Promise<AbstractComponent | null>;
10
11
  addPoint(point: StrokeDataPoint): void;
11
12
  }
12
13
  export type ComponentBuilderFactory = (startPoint: StrokeDataPoint, viewport: Viewport) => ComponentBuilder;
@@ -87,6 +87,9 @@ export default class EditorImage {
87
87
  * rendered onto the main rendering canvas instead of doing a full re-render.
88
88
  *
89
89
  * @see {@link Display.flatten}
90
+ *
91
+ * @example
92
+ * [[include:doc-pages/inline-examples/adding-a-stroke.md]]
90
93
  */
91
94
  static addElement(elem: AbstractComponent, applyByFlattening?: boolean): SerializableCommand;
92
95
  /** @see EditorImage.addElement */
@@ -105,8 +108,36 @@ export default class EditorImage {
105
108
  * autoresize (if it was previously enabled).
106
109
  */
107
110
  setImportExportRect(imageRect: Rect2): SerializableCommand;
111
+ /** @see {@link setAutoresizeEnabled} */
108
112
  getAutoresizeEnabled(): boolean;
109
- /** Returns a `Command` that sets whether the image should autoresize. */
113
+ /**
114
+ * Returns a `Command` that sets whether the image should autoresize when
115
+ * {@link AbstractComponent}s are added/removed.
116
+ *
117
+ * @example
118
+ *
119
+ * ```ts,runnable
120
+ * import { Editor } from 'js-draw';
121
+ *
122
+ * const editor = new Editor(document.body);
123
+ * const toolbar = editor.addToolbar();
124
+ *
125
+ * // Add a save button to demonstrate what the output looks like
126
+ * // (it should change size to fit whatever was drawn)
127
+ * toolbar.addSaveButton(() => {
128
+ * document.body.replaceChildren(editor.toSVG({ sanitize: true }));
129
+ * });
130
+ *
131
+ * // Actually using setAutoresizeEnabled:
132
+ * //
133
+ * // To set autoresize without announcing for accessibility/making undoable
134
+ * const addToHistory = false;
135
+ * editor.dispatchNoAnnounce(editor.image.setAutoresizeEnabled(true), addToHistory);
136
+ *
137
+ * // Add to undo history **and** announce for accessibility
138
+ * //editor.dispatch(editor.image.setAutoresizeEnabled(true), true);
139
+ * ```
140
+ */
110
141
  setAutoresizeEnabled(autoresize: boolean): Command;
111
142
  private setAutoresizeEnabledDirectly;
112
143
  /** Updates the size/position of the viewport */
@@ -180,6 +180,9 @@ class EditorImage {
180
180
  * rendered onto the main rendering canvas instead of doing a full re-render.
181
181
  *
182
182
  * @see {@link Display.flatten}
183
+ *
184
+ * @example
185
+ * [[include:doc-pages/inline-examples/adding-a-stroke.md]]
183
186
  */
184
187
  static addElement(elem, applyByFlattening = false) {
185
188
  return new _a.AddElementCommand(elem, applyByFlattening);
@@ -207,10 +210,38 @@ class EditorImage {
207
210
  setImportExportRect(imageRect) {
208
211
  return _a.SetImportExportRectCommand.of(this, imageRect, false);
209
212
  }
213
+ /** @see {@link setAutoresizeEnabled} */
210
214
  getAutoresizeEnabled() {
211
215
  return this.shouldAutoresizeExportViewport;
212
216
  }
213
- /** Returns a `Command` that sets whether the image should autoresize. */
217
+ /**
218
+ * Returns a `Command` that sets whether the image should autoresize when
219
+ * {@link AbstractComponent}s are added/removed.
220
+ *
221
+ * @example
222
+ *
223
+ * ```ts,runnable
224
+ * import { Editor } from 'js-draw';
225
+ *
226
+ * const editor = new Editor(document.body);
227
+ * const toolbar = editor.addToolbar();
228
+ *
229
+ * // Add a save button to demonstrate what the output looks like
230
+ * // (it should change size to fit whatever was drawn)
231
+ * toolbar.addSaveButton(() => {
232
+ * document.body.replaceChildren(editor.toSVG({ sanitize: true }));
233
+ * });
234
+ *
235
+ * // Actually using setAutoresizeEnabled:
236
+ * //
237
+ * // To set autoresize without announcing for accessibility/making undoable
238
+ * const addToHistory = false;
239
+ * editor.dispatchNoAnnounce(editor.image.setAutoresizeEnabled(true), addToHistory);
240
+ *
241
+ * // Add to undo history **and** announce for accessibility
242
+ * //editor.dispatch(editor.image.setAutoresizeEnabled(true), true);
243
+ * ```
244
+ */
214
245
  setAutoresizeEnabled(autoresize) {
215
246
  if (autoresize === this.shouldAutoresizeExportViewport) {
216
247
  return Command.empty;
@@ -132,6 +132,9 @@ export default class Display {
132
132
  // Ensure correct drawing operations on high-resolution screens.
133
133
  // See
134
134
  // https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas#scaling_for_high_resolution_displays
135
+ //
136
+ // This scaling causes the rendering contexts to automatically convert
137
+ // between screen coordinates and pixel coordinates.
135
138
  wetInkCtx.resetTransform();
136
139
  dryInkCtx.resetTransform();
137
140
  dryInkCtx.scale(this.devicePixelRatio, this.devicePixelRatio);
@@ -150,7 +153,11 @@ export default class Display {
150
153
  dryInkCtx.restore();
151
154
  };
152
155
  this.getColorAt = (screenPos) => {
153
- const pixel = dryInkCtx.getImageData(screenPos.x, screenPos.y, 1, 1);
156
+ // getImageData isn't affected by a transformation matrix -- we need to
157
+ // pre-transform screenPos to convert it from screen coordinates into pixel
158
+ // coordinates.
159
+ const adjustedScreenPos = screenPos.times(this.devicePixelRatio);
160
+ const pixel = dryInkCtx.getImageData(adjustedScreenPos.x, adjustedScreenPos.y, 1, 1);
154
161
  const data = pixel?.data;
155
162
  if (data) {
156
163
  const color = Color4.ofRGBA(data[0] / 255, data[1] / 255, data[2] / 255, data[3] / 255);
@@ -6,11 +6,15 @@ interface RenderablePathSpec {
6
6
  style: RenderingStyle;
7
7
  path?: Path;
8
8
  }
9
- interface RenderablePathSpecWithPath extends RenderablePathSpec {
9
+ export interface RenderablePathSpecWithPath extends RenderablePathSpec {
10
10
  path: Path;
11
11
  }
12
12
  /** Converts a renderable path (a path with a `startPoint`, `commands`, and `style`). */
13
13
  export declare const pathFromRenderable: (renderable: RenderablePathSpec) => Path;
14
+ /**
15
+ * Converts `path` into a format that can be rendered (by passing to a {@link Stroke} constructor
16
+ * or directly to an {@link AbstractRenderer.drawPath}).
17
+ */
14
18
  export declare const pathToRenderable: (path: Path, style: RenderingStyle) => RenderablePathSpecWithPath;
15
19
  interface RectangleSimplificationResult {
16
20
  rectangle: Rect2;
@@ -6,6 +6,10 @@ export const pathFromRenderable = (renderable) => {
6
6
  }
7
7
  return new Path(renderable.startPoint, renderable.commands);
8
8
  };
9
+ /**
10
+ * Converts `path` into a format that can be rendered (by passing to a {@link Stroke} constructor
11
+ * or directly to an {@link AbstractRenderer.drawPath}).
12
+ */
9
13
  export const pathToRenderable = (path, style) => {
10
14
  return {
11
15
  startPoint: path.startPoint,
@@ -51,6 +51,8 @@ export default class IconProvider {
51
51
  makePenIcon(penStyle: PenStyle): IconElemType;
52
52
  makeIconFromFactory(penStyle: PenStyle): IconElemType;
53
53
  makePipetteIcon(color?: Color4): IconElemType;
54
+ makeShapeAutocorrectIcon(): IconElemType;
55
+ makeStrokeSmoothingIcon(): IconElemType;
54
56
  /** Unused. @deprecated */
55
57
  makeFormatSelectionIcon(): IconElemType;
56
58
  makeResizeImageToSelectionIcon(): IconElemType;
@@ -644,6 +644,23 @@ class IconProvider {
644
644
  icon.setAttribute('viewBox', '5 -40 140 140');
645
645
  return icon;
646
646
  }
647
+ makeShapeAutocorrectIcon() {
648
+ const fill = 'none';
649
+ const strokeColor = 'var(--icon-color)';
650
+ return this.makeIconFromPath(`
651
+ m 79.129476,33.847107 9.967823,-0.03218 v 55 h -55 l 0.03218,-9.96782
652
+ M 71.1,40.8 a 30,30 0 0 1 -30,30 30,30 0 0 1 -30,-30 30,30 0 0 1 30,-30 30,30 0 0 1 30,30 L 71.1,40.8
653
+ M 34.1,58.8 v -25 h 25 v 0
654
+ `, fill, strokeColor, '7px');
655
+ }
656
+ makeStrokeSmoothingIcon() {
657
+ const fill = 'none';
658
+ const strokeColor = 'var(--icon-color)';
659
+ return this.makeIconFromPath(`
660
+ m 31,83.2 c -50,0 30,-65 -20,-65
661
+ M 75,17.3 40,59.7 38.2,77.6 55.5,72.4 90.5,30 Z
662
+ `, fill, strokeColor, '7px');
663
+ }
647
664
  /** Unused. @deprecated */
648
665
  makeFormatSelectionIcon() {
649
666
  return this.makeIconFromPath(`
@@ -10,6 +10,8 @@ export interface ToolbarLocalization {
10
10
  arrowPen: string;
11
11
  image: string;
12
12
  inputAltText: string;
13
+ decreaseImageSize: string;
14
+ resetImage: string;
13
15
  chooseFile: string;
14
16
  dragAndDropHereOrBrowse: string;
15
17
  cancel: string;
@@ -48,6 +50,7 @@ export interface ToolbarLocalization {
48
50
  toggleOverflow: string;
49
51
  about: string;
50
52
  inputStabilization: string;
53
+ strokeAutocorrect: string;
51
54
  errorImageHasZeroSize: string;
52
55
  closeSidebar: (toolName: string) => string;
53
56
  dropdownShown: (toolName: string) => string;
@@ -7,6 +7,8 @@ export const defaultToolbarLocalization = {
7
7
  image: 'Image',
8
8
  reformatSelection: 'Format selection',
9
9
  inputAltText: 'Alt text',
10
+ decreaseImageSize: 'Decrease size',
11
+ resetImage: 'Reset',
10
12
  chooseFile: 'Choose file',
11
13
  dragAndDropHereOrBrowse: 'Drag and drop here\nor\n{{browse}}',
12
14
  submit: 'Submit',
@@ -37,7 +39,8 @@ export const defaultToolbarLocalization = {
37
39
  enableAutoresizeOption: 'Auto-resize',
38
40
  toggleOverflow: 'More',
39
41
  about: 'About',
40
- inputStabilization: 'Input stabilization',
42
+ inputStabilization: 'Stabilization',
43
+ strokeAutocorrect: 'Autocorrect',
41
44
  touchPanning: 'Touchscreen panning',
42
45
  roundedTipPen: 'Round',
43
46
  flatTipPen: 'Flat',
@@ -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;
@@ -9,10 +9,48 @@ import BaseWidget from './BaseWidget.mjs';
9
9
  import { EditorEventType } from '../../types.mjs';
10
10
  import { toolbarCSSPrefix } from '../constants.mjs';
11
11
  import makeFileInput from './components/makeFileInput.mjs';
12
+ class ImageWrapper {
13
+ constructor(imageBase64Url, preview, onUrlUpdate) {
14
+ this.imageBase64Url = imageBase64Url;
15
+ this.preview = preview;
16
+ this.onUrlUpdate = onUrlUpdate;
17
+ this.originalSrc = imageBase64Url;
18
+ preview.src = imageBase64Url;
19
+ }
20
+ updateImageData(base64DataUrl) {
21
+ this.preview.src = base64DataUrl;
22
+ this.imageBase64Url = base64DataUrl;
23
+ this.onUrlUpdate();
24
+ }
25
+ decreaseSize(resizeFactor = 3 / 4) {
26
+ const canvas = document.createElement('canvas');
27
+ canvas.width = this.preview.naturalWidth * resizeFactor;
28
+ canvas.height = this.preview.naturalHeight * resizeFactor;
29
+ const ctx = canvas.getContext('2d');
30
+ ctx?.drawImage(this.preview, 0, 0, canvas.width, canvas.height);
31
+ // JPEG can be much smaller than PNG for the same image size. Prefer it if
32
+ // the image is already a JPEG.
33
+ const format = this.originalSrc?.startsWith('data:image/jpeg;') ? 'image/jpeg' : 'image/png';
34
+ this.updateImageData(canvas.toDataURL(format));
35
+ }
36
+ reset() {
37
+ this.updateImageData(this.originalSrc);
38
+ }
39
+ isChanged() {
40
+ return this.imageBase64Url !== this.originalSrc;
41
+ }
42
+ getBase64Url() {
43
+ return this.imageBase64Url;
44
+ }
45
+ static fromSrcAndPreview(initialBase64Src, preview, onUrlUpdate) {
46
+ return new ImageWrapper(initialBase64Src, preview, onUrlUpdate);
47
+ }
48
+ }
12
49
  class InsertImageWidget extends BaseWidget {
13
50
  constructor(editor, localization) {
14
51
  localization ??= editor.localization;
15
52
  super(editor, 'insert-image-widget', localization);
53
+ this.image = null;
16
54
  // Make the dropdown showable
17
55
  this.container.classList.add('dropdownShowable');
18
56
  editor.notifier.on(EditorEventType.SelectionUpdated, event => {
@@ -46,6 +84,7 @@ class InsertImageWidget extends BaseWidget {
46
84
  this.statusView = document.createElement('div');
47
85
  const actionButtonRow = document.createElement('div');
48
86
  actionButtonRow.classList.add('action-button-row');
87
+ this.statusView.classList.add('insert-image-image-status-view');
49
88
  this.submitButton = document.createElement('button');
50
89
  this.selectedFiles = selectedFiles;
51
90
  this.imageAltTextInput = document.createElement('input');
@@ -60,9 +99,8 @@ class InsertImageWidget extends BaseWidget {
60
99
  this.submitButton.innerText = this.localizationTable.submit;
61
100
  this.selectedFiles.onUpdateAndNow(async (files) => {
62
101
  if (files.length === 0) {
63
- this.imagePreview.style.display = 'none';
64
- this.submitButton.disabled = true;
65
- this.submitButton.style.display = 'none';
102
+ this.image = null;
103
+ this.onImageDataUpdate();
66
104
  return;
67
105
  }
68
106
  this.imagePreview.style.display = 'block';
@@ -74,18 +112,13 @@ class InsertImageWidget extends BaseWidget {
74
112
  catch (e) {
75
113
  this.statusView.innerText = this.localizationTable.imageLoadError(e);
76
114
  }
77
- this.imageBase64URL = data;
78
115
  if (data) {
79
- this.imagePreview.src = data;
80
- this.submitButton.disabled = false;
81
- this.submitButton.style.display = '';
82
- this.updateImageSizeDisplay();
116
+ this.image = ImageWrapper.fromSrcAndPreview(data, this.imagePreview, () => this.onImageDataUpdate());
83
117
  }
84
118
  else {
85
- this.submitButton.disabled = true;
86
- this.submitButton.style.display = 'none';
87
- this.statusView.innerText = '';
119
+ this.image = null;
88
120
  }
121
+ this.onImageDataUpdate();
89
122
  });
90
123
  altTextRow.replaceChildren(imageAltTextLabel, this.imageAltTextInput);
91
124
  actionButtonRow.replaceChildren(this.submitButton);
@@ -93,11 +126,27 @@ class InsertImageWidget extends BaseWidget {
93
126
  dropdown.replaceChildren(container);
94
127
  return true;
95
128
  }
129
+ onImageDataUpdate() {
130
+ const base64Data = this.image?.getBase64Url();
131
+ if (base64Data) {
132
+ this.submitButton.disabled = false;
133
+ this.submitButton.style.display = '';
134
+ this.imagePreview.style.display = '';
135
+ this.updateImageSizeDisplay();
136
+ }
137
+ else {
138
+ this.submitButton.disabled = true;
139
+ this.submitButton.style.display = 'none';
140
+ this.statusView.innerText = '';
141
+ this.imagePreview.style.display = 'none';
142
+ this.submitButton.disabled = true;
143
+ }
144
+ }
96
145
  hideDialog() {
97
146
  this.setDropdownVisible(false);
98
147
  }
99
148
  updateImageSizeDisplay() {
100
- const imageData = this.imageBase64URL ?? '';
149
+ const imageData = this.image?.getBase64Url() ?? '';
101
150
  const sizeInKiB = imageData.length / 1024;
102
151
  const sizeInMiB = sizeInKiB / 1024;
103
152
  let units = 'KiB';
@@ -106,7 +155,27 @@ class InsertImageWidget extends BaseWidget {
106
155
  size = sizeInMiB;
107
156
  units = 'MiB';
108
157
  }
109
- this.statusView.innerText = this.localizationTable.imageSize(Math.round(size), units);
158
+ const sizeText = document.createElement('span');
159
+ sizeText.innerText = this.localizationTable.imageSize(Math.round(size), units);
160
+ // Add a button to allow decreasing the size of large images.
161
+ const decreaseSizeButton = document.createElement('button');
162
+ decreaseSizeButton.innerText = this.localizationTable.decreaseImageSize;
163
+ decreaseSizeButton.onclick = () => {
164
+ this.image?.decreaseSize();
165
+ };
166
+ const resetSizeButton = document.createElement('button');
167
+ resetSizeButton.innerText = this.localizationTable.resetImage;
168
+ resetSizeButton.onclick = () => {
169
+ this.image?.reset();
170
+ };
171
+ this.statusView.replaceChildren(sizeText);
172
+ const largeImageThreshold = 0.25; // MiB
173
+ if (sizeInMiB > largeImageThreshold) {
174
+ this.statusView.appendChild(decreaseSizeButton);
175
+ }
176
+ else if (this.image?.isChanged()) {
177
+ this.statusView.appendChild(resetSizeButton);
178
+ }
110
179
  }
111
180
  updateInputs() {
112
181
  const resetInputs = () => {
@@ -126,11 +195,8 @@ class InsertImageWidget extends BaseWidget {
126
195
  if (selectedObjects.length === 1 && selectedObjects[0] instanceof ImageComponent) {
127
196
  editingImage = selectedObjects[0];
128
197
  this.imageAltTextInput.value = editingImage.getAltText() ?? '';
129
- this.imagePreview.style.display = 'block';
130
- this.submitButton.disabled = false;
131
- this.imageBase64URL = editingImage.getURL();
132
- this.imagePreview.src = this.imageBase64URL;
133
- this.updateImageSizeDisplay();
198
+ this.image = ImageWrapper.fromSrcAndPreview(editingImage.getURL(), this.imagePreview, () => this.onImageDataUpdate());
199
+ this.onImageDataUpdate();
134
200
  }
135
201
  else if (selectedObjects.length > 0) {
136
202
  // If not, clear the selection.
@@ -144,13 +210,21 @@ class InsertImageWidget extends BaseWidget {
144
210
  }
145
211
  };
146
212
  this.submitButton.onclick = async () => {
147
- if (!this.imageBase64URL) {
213
+ if (!this.image) {
148
214
  return;
149
215
  }
150
216
  const image = new Image();
151
- image.src = this.imageBase64URL;
217
+ image.src = this.image.getBase64Url();
152
218
  image.setAttribute('alt', this.imageAltTextInput.value);
153
- const component = await ImageComponent.fromImage(image, Mat33.identity);
219
+ let component;
220
+ try {
221
+ component = await ImageComponent.fromImage(image, Mat33.identity);
222
+ }
223
+ catch (error) {
224
+ console.error('Error loading image', error);
225
+ this.statusView.innerText = this.localizationTable.imageLoadError(error);
226
+ return;
227
+ }
154
228
  if (component.getBBox().area === 0) {
155
229
  this.statusView.innerText = this.localizationTable.errorImageHasZeroSize;
156
230
  return;
@@ -158,9 +232,15 @@ class InsertImageWidget extends BaseWidget {
158
232
  this.hideDialog();
159
233
  if (editingImage) {
160
234
  const eraseCommand = new Erase([editingImage]);
235
+ // Try to preserve the original width
236
+ const originalTransform = editingImage.getTransformation();
237
+ // || 1: Prevent division by zero
238
+ const originalWidth = editingImage.getBBox().width || 1;
239
+ const newWidth = component.getBBox().transformedBoundingBox(originalTransform).width || 1;
240
+ const widthAdjustTransform = Mat33.scaling2D(originalWidth / newWidth);
161
241
  await this.editor.dispatch(uniteCommands([
162
242
  EditorImage.addElement(component),
163
- component.transformBy(editingImage.getTransformation()),
243
+ component.transformBy(originalTransform.rightMul(widthAdjustTransform)),
164
244
  component.setZIndex(editingImage.getZIndex()),
165
245
  eraseCommand,
166
246
  ]));