js-draw 1.17.0 → 1.18.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 (63) hide show
  1. package/README.md +70 -10
  2. package/dist/bundle.js +2 -2
  3. package/dist/cjs/Editor.d.ts +18 -20
  4. package/dist/cjs/Editor.js +5 -2
  5. package/dist/cjs/components/AbstractComponent.d.ts +17 -5
  6. package/dist/cjs/components/AbstractComponent.js +15 -15
  7. package/dist/cjs/components/Stroke.d.ts +4 -1
  8. package/dist/cjs/components/Stroke.js +158 -2
  9. package/dist/cjs/components/builders/PolylineBuilder.d.ts +1 -1
  10. package/dist/cjs/components/builders/PolylineBuilder.js +9 -2
  11. package/dist/cjs/components/builders/PressureSensitiveFreehandLineBuilder.d.ts +1 -1
  12. package/dist/cjs/components/builders/PressureSensitiveFreehandLineBuilder.js +44 -51
  13. package/dist/cjs/image/EditorImage.js +1 -1
  14. package/dist/cjs/localizations/de.js +1 -1
  15. package/dist/cjs/localizations/es.js +1 -1
  16. package/dist/cjs/testing/createEditor.d.ts +2 -2
  17. package/dist/cjs/testing/createEditor.js +2 -2
  18. package/dist/cjs/toolbar/IconProvider.d.ts +3 -1
  19. package/dist/cjs/toolbar/IconProvider.js +15 -3
  20. package/dist/cjs/toolbar/localization.d.ts +6 -1
  21. package/dist/cjs/toolbar/localization.js +7 -2
  22. package/dist/cjs/toolbar/widgets/EraserToolWidget.d.ts +6 -1
  23. package/dist/cjs/toolbar/widgets/EraserToolWidget.js +45 -5
  24. package/dist/cjs/toolbar/widgets/PenToolWidget.js +10 -3
  25. package/dist/cjs/toolbar/widgets/PenToolWidget.test.d.ts +1 -0
  26. package/dist/cjs/toolbar/widgets/keybindings.js +1 -1
  27. package/dist/cjs/tools/Eraser.d.ts +24 -4
  28. package/dist/cjs/tools/Eraser.js +107 -20
  29. package/dist/cjs/tools/PasteHandler.js +0 -1
  30. package/dist/cjs/tools/lib.d.ts +1 -4
  31. package/dist/cjs/tools/lib.js +2 -4
  32. package/dist/cjs/version.js +1 -1
  33. package/dist/mjs/Editor.d.ts +18 -20
  34. package/dist/mjs/Editor.mjs +5 -2
  35. package/dist/mjs/components/AbstractComponent.d.ts +17 -5
  36. package/dist/mjs/components/AbstractComponent.mjs +15 -15
  37. package/dist/mjs/components/Stroke.d.ts +4 -1
  38. package/dist/mjs/components/Stroke.mjs +159 -3
  39. package/dist/mjs/components/builders/PolylineBuilder.d.ts +1 -1
  40. package/dist/mjs/components/builders/PolylineBuilder.mjs +10 -3
  41. package/dist/mjs/components/builders/PressureSensitiveFreehandLineBuilder.d.ts +1 -1
  42. package/dist/mjs/components/builders/PressureSensitiveFreehandLineBuilder.mjs +45 -52
  43. package/dist/mjs/image/EditorImage.mjs +1 -1
  44. package/dist/mjs/localizations/de.mjs +1 -1
  45. package/dist/mjs/localizations/es.mjs +1 -1
  46. package/dist/mjs/testing/createEditor.d.ts +2 -2
  47. package/dist/mjs/testing/createEditor.mjs +2 -2
  48. package/dist/mjs/toolbar/IconProvider.d.ts +3 -1
  49. package/dist/mjs/toolbar/IconProvider.mjs +15 -3
  50. package/dist/mjs/toolbar/localization.d.ts +6 -1
  51. package/dist/mjs/toolbar/localization.mjs +7 -2
  52. package/dist/mjs/toolbar/widgets/EraserToolWidget.d.ts +6 -1
  53. package/dist/mjs/toolbar/widgets/EraserToolWidget.mjs +47 -6
  54. package/dist/mjs/toolbar/widgets/PenToolWidget.mjs +10 -3
  55. package/dist/mjs/toolbar/widgets/PenToolWidget.test.d.ts +1 -0
  56. package/dist/mjs/toolbar/widgets/keybindings.mjs +1 -1
  57. package/dist/mjs/tools/Eraser.d.ts +24 -4
  58. package/dist/mjs/tools/Eraser.mjs +107 -21
  59. package/dist/mjs/tools/PasteHandler.mjs +0 -1
  60. package/dist/mjs/tools/lib.d.ts +1 -4
  61. package/dist/mjs/tools/lib.mjs +1 -4
  62. package/dist/mjs/version.mjs +1 -1
  63. package/package.json +3 -3
@@ -79,30 +79,28 @@ export interface EditorSettings {
79
79
  * Configures the default pen tools.
80
80
  *
81
81
  * **Example**:
82
- * ```ts,runnable
83
- * import { Editor, makePolylineBuilder } from 'js-draw';
84
- *
85
- * const editor = new Editor(document.body, {
86
- * pens: {
87
- * additionalPenTypes: [{
88
- * name: 'Polyline (For debugging)',
89
- * id: 'custom-polyline',
90
- * factory: makePolylineBuilder,
91
- *
92
- * // The pen doesn't create fixed shapes (e.g. squares, rectangles, etc)
93
- * // and so should go under the "pens" section.
94
- * isShapeBuilder: false,
95
- * }],
96
- * },
97
- * });
98
- * editor.addToolbar();
99
- * ```
82
+ * [[include:doc-pages/inline-examples/editor-settings-polyline-pen.md]]
100
83
  */
101
84
  pens: {
102
85
  /**
103
86
  * Additional pen types that can be selected in a toolbar.
104
87
  */
105
- additionalPenTypes: readonly Readonly<PenTypeRecord>[];
88
+ additionalPenTypes?: readonly Readonly<PenTypeRecord>[];
89
+ /**
90
+ * Should return `true` if a pen type should be shown in the toolbar.
91
+ *
92
+ * @example
93
+ * ```ts,runnable
94
+ * import {Editor} from 'js-draw';
95
+ * const editor = new Editor(document.body, {
96
+ * // Only allow selecting the polyline pen from the toolbar.
97
+ * pens: { filterPenTypes: p => p.id === 'polyline-pen' },
98
+ * });
99
+ * editor.addToolbar();
100
+ * ```
101
+ * Notice that this setting only affects the toolbar GUI.
102
+ */
103
+ filterPenTypes?: (penType: PenTypeRecord) => boolean;
106
104
  } | null;
107
105
  }
108
106
  /**
@@ -123,7 +121,7 @@ export interface EditorSettings {
123
121
  * ```
124
122
  *
125
123
  * See also
126
- * [`docs/example/example.ts`](https://github.com/personalizedrefrigerator/js-draw/blob/main/docs/demo/example.ts).
124
+ * * [`examples.md`](https://github.com/personalizedrefrigerator/js-draw/blob/main/docs/examples.md).
127
125
  */
128
126
  export declare class Editor {
129
127
  private container;
@@ -76,7 +76,7 @@ const ClipboardHandler_1 = __importDefault(require("./util/ClipboardHandler"));
76
76
  * ```
77
77
  *
78
78
  * See also
79
- * [`docs/example/example.ts`](https://github.com/personalizedrefrigerator/js-draw/blob/main/docs/demo/example.ts).
79
+ * * [`examples.md`](https://github.com/personalizedrefrigerator/js-draw/blob/main/docs/examples.md).
80
80
  */
81
81
  class Editor {
82
82
  /**
@@ -138,7 +138,10 @@ class Editor {
138
138
  iconProvider: settings.iconProvider ?? new IconProvider_1.default(),
139
139
  notices: [],
140
140
  appInfo: settings.appInfo ? { ...settings.appInfo } : null,
141
- pens: { additionalPenTypes: settings.pens?.additionalPenTypes ?? [], },
141
+ pens: {
142
+ additionalPenTypes: settings.pens?.additionalPenTypes ?? [],
143
+ filterPenTypes: settings.pens?.filterPenTypes ?? (() => true)
144
+ },
142
145
  };
143
146
  // Validate settings
144
147
  if (this.settings.minZoom > this.settings.maxZoom) {
@@ -1,8 +1,9 @@
1
1
  import SerializableCommand from '../commands/SerializableCommand';
2
2
  import EditorImage from '../image/EditorImage';
3
- import { LineSegment2, Mat33, Rect2 } from '@js-draw/math';
3
+ import { LineSegment2, Mat33, Path, Rect2 } from '@js-draw/math';
4
4
  import AbstractRenderer from '../rendering/renderers/AbstractRenderer';
5
5
  import { ImageComponentLocalization } from './localization';
6
+ import Viewport from '../Viewport';
6
7
  export type LoadSaveData = (string[] | Record<symbol, string | number>);
7
8
  export type LoadSaveDataTable = Record<string, Array<LoadSaveData>>;
8
9
  export type DeserializeCallback = (data: string) => AbstractComponent;
@@ -112,7 +113,9 @@ export default abstract class AbstractComponent {
112
113
  * this function.
113
114
  */
114
115
  intersectsRect(rect: Rect2): boolean;
115
- protected abstract serializeToJSON(): any[] | Record<string, any> | number | string | null;
116
+ isSelectable(): boolean;
117
+ isBackground(): boolean;
118
+ getProportionalRenderingTime(): number;
116
119
  protected abstract applyTransformation(affineTransfm: Mat33): void;
117
120
  /**
118
121
  * Returns a command that, when applied, transforms this by [affineTransfm] and
@@ -131,9 +134,6 @@ export default abstract class AbstractComponent {
131
134
  * this command.
132
135
  */
133
136
  setZIndexAndTransformBy(affineTransfm: Mat33, newZIndex: number, originalZIndex?: number): SerializableCommand;
134
- isSelectable(): boolean;
135
- isBackground(): boolean;
136
- getProportionalRenderingTime(): number;
137
137
  private static transformElementCommandId;
138
138
  private static TransformElementCommand;
139
139
  /**
@@ -143,6 +143,18 @@ export default abstract class AbstractComponent {
143
143
  abstract description(localizationTable: ImageComponentLocalization): string;
144
144
  protected abstract createClone(): AbstractComponent;
145
145
  clone(): AbstractComponent;
146
+ /**
147
+ * **Optional method**: Divides this component into sections roughly along the given path,
148
+ * removing parts that are roughly within `shape`.
149
+ *
150
+ * **Notes**:
151
+ * - A default implementation may be provided for this method in the future. Until then,
152
+ * this method is `undefined` if unsupported.
153
+ *
154
+ * `viewport` should be provided to determine how newly-added points should be rounded.
155
+ */
156
+ withRegionErased?(shape: Path, viewport: Viewport): AbstractComponent[];
157
+ protected abstract serializeToJSON(): any[] | Record<string, any> | number | string | null;
146
158
  serialize(): {
147
159
  name: string;
148
160
  zIndex: number;
@@ -142,6 +142,21 @@ class AbstractComponent {
142
142
  const testLines = rect.getEdges();
143
143
  return testLines.some(edge => this.intersects(edge));
144
144
  }
145
+ // @returns true iff this component can be selected (e.g. by the selection tool.)
146
+ isSelectable() {
147
+ return true;
148
+ }
149
+ // @returns true iff this component should be added to the background, rather than the
150
+ // foreground of the image.
151
+ isBackground() {
152
+ return false;
153
+ }
154
+ // @returns an approximation of the proportional time it takes to render this component.
155
+ // This is intended to be a rough estimate, but, for example, a stroke with two points sould have
156
+ // a renderingWeight approximately twice that of a stroke with one point.
157
+ getProportionalRenderingTime() {
158
+ return 1;
159
+ }
145
160
  /**
146
161
  * Returns a command that, when applied, transforms this by [affineTransfm] and
147
162
  * updates the editor.
@@ -166,21 +181,6 @@ class AbstractComponent {
166
181
  setZIndexAndTransformBy(affineTransfm, newZIndex, originalZIndex) {
167
182
  return new AbstractComponent.TransformElementCommand(affineTransfm, this.getId(), this, newZIndex, originalZIndex);
168
183
  }
169
- // @returns true iff this component can be selected (e.g. by the selection tool.)
170
- isSelectable() {
171
- return true;
172
- }
173
- // @returns true iff this component should be added to the background, rather than the
174
- // foreground of the image.
175
- isBackground() {
176
- return false;
177
- }
178
- // @returns an approximation of the proportional time it takes to render this component.
179
- // This is intended to be a rough estimate, but, for example, a stroke with two points sould have
180
- // a renderingWeight approximately twice that of a stroke with one point.
181
- getProportionalRenderingTime() {
182
- return 1;
183
- }
184
184
  // Returns a copy of this component.
185
185
  clone() {
186
186
  const clone = this.createClone();
@@ -6,6 +6,7 @@ import AbstractComponent from './AbstractComponent';
6
6
  import { ImageComponentLocalization } from './localization';
7
7
  import RestyleableComponent, { ComponentStyle } from './RestylableComponent';
8
8
  import RenderablePathSpec, { RenderablePathSpecWithPath } from '../rendering/RenderablePathSpec';
9
+ import Viewport from '../Viewport';
9
10
  /**
10
11
  * Represents an {@link AbstractComponent} made up of one or more {@link Path}s.
11
12
  *
@@ -46,10 +47,12 @@ export default class Stroke extends AbstractComponent implements RestyleableComp
46
47
  * ]);
47
48
  * ```
48
49
  */
49
- constructor(parts: RenderablePathSpec[]);
50
+ constructor(parts: RenderablePathSpec[], initialZIndex?: number);
50
51
  getStyle(): ComponentStyle;
51
52
  updateStyle(style: ComponentStyle): SerializableCommand;
52
53
  forceStyle(style: ComponentStyle, editor: Editor | null): void;
54
+ /** @beta -- May fail for concave `path`s */
55
+ withRegionErased(eraserPath: Path, viewport: Viewport): Stroke[];
53
56
  intersects(line: LineSegment2): boolean;
54
57
  intersectsRect(rect: Rect2): boolean;
55
58
  private simplifiedPath;
@@ -44,8 +44,8 @@ class Stroke extends AbstractComponent_1.default {
44
44
  * ]);
45
45
  * ```
46
46
  */
47
- constructor(parts) {
48
- super('stroke');
47
+ constructor(parts, initialZIndex) {
48
+ super('stroke', initialZIndex);
49
49
  // @internal
50
50
  // eslint-disable-next-line @typescript-eslint/prefer-as-const
51
51
  this.isRestylableComponent = true;
@@ -123,6 +123,162 @@ class Stroke extends AbstractComponent_1.default {
123
123
  editor.queueRerender();
124
124
  }
125
125
  }
126
+ /** @beta -- May fail for concave `path`s */
127
+ withRegionErased(eraserPath, viewport) {
128
+ const polyline = eraserPath.polylineApproximation();
129
+ const isPointInsideEraser = (point) => {
130
+ return eraserPath.closedContainsPoint(point);
131
+ };
132
+ const newStrokes = [];
133
+ let failedAssertions = false;
134
+ for (const part of this.parts) {
135
+ const path = part.path;
136
+ const makeStroke = (path) => {
137
+ if (part.style.fill.a > 0) {
138
+ // Remove visually empty paths.
139
+ if (path.parts.length < 1 || (path.parts.length === 1 && path.parts[0].kind === math_1.PathCommandType.LineTo)) {
140
+ // TODO: If this isn't present, a very large number of strokes are created while erasing.
141
+ return null;
142
+ }
143
+ else {
144
+ // Filled paths must be closed (allows for optimizations elsewhere)
145
+ path = path.asClosed();
146
+ }
147
+ }
148
+ if (isNaN(path.getExactBBox().area)) {
149
+ console.warn('Prevented creating a stroke with NaN area');
150
+ failedAssertions = true;
151
+ return null;
152
+ }
153
+ return new Stroke([(0, RenderablePathSpec_1.pathToRenderable)(path, part.style)], this.getZIndex());
154
+ };
155
+ const intersectionPoints = [];
156
+ // If stroked, finds intersections with the middle of the stroke.
157
+ // If filled, finds intersections with the edge of the stroke.
158
+ for (const segment of polyline) {
159
+ intersectionPoints.push(...path.intersection(segment));
160
+ }
161
+ // When stroked, if the stroke width is significantly larger than the eraser,
162
+ // it can't intersect both the edge of the stroke and its middle at the same time
163
+ // (generally, erasing is triggered by the eraser touching the edge of this stroke).
164
+ //
165
+ // As such, we also look for intersections along the edge of this, if none with the
166
+ // center were found, but only within a certain range of sizes because:
167
+ // 1. Intersection testing with stroked paths is generally much slower than with
168
+ // non-stroked paths.
169
+ // 2. If zoomed in significantly, it's unlikely that the user wants to erase a large
170
+ // part of the stroke.
171
+ let isErasingFromEdge = false;
172
+ if (intersectionPoints.length === 0
173
+ && part.style.stroke
174
+ && part.style.stroke.width > eraserPath.bbox.minDimension * 0.3
175
+ && part.style.stroke.width < eraserPath.bbox.maxDimension * 30) {
176
+ for (const segment of polyline) {
177
+ intersectionPoints.push(...path.intersection(segment, part.style.stroke.width / 2));
178
+ }
179
+ isErasingFromEdge = true;
180
+ }
181
+ // Sort first by curve index, then by parameter value
182
+ intersectionPoints.sort(math_1.comparePathIndices);
183
+ const isInsideJustBeforeFirst = (() => {
184
+ if (intersectionPoints.length === 0) {
185
+ return false;
186
+ }
187
+ // The eraser may not be near the center of the curve -- approximate.
188
+ if (isErasingFromEdge) {
189
+ return intersectionPoints[0].curveIndex === 0 && intersectionPoints[0].parameterValue <= 0;
190
+ }
191
+ const justBeforeFirstIntersection = (0, math_1.stepPathIndexBy)(intersectionPoints[0], -1e-10);
192
+ return isPointInsideEraser(path.at(justBeforeFirstIntersection));
193
+ })();
194
+ let intersectionCount = isInsideJustBeforeFirst ? 1 : 0;
195
+ const addNewPath = (path, knownToBeInside) => {
196
+ const component = makeStroke(path);
197
+ let isInside = intersectionCount % 2 === 1;
198
+ intersectionCount++;
199
+ if (knownToBeInside !== undefined) {
200
+ isInside = knownToBeInside;
201
+ }
202
+ // Here, we work around bugs in the underlying Bezier curve library
203
+ // (including https://github.com/Pomax/bezierjs/issues/179).
204
+ // Even if not all intersections are returned correctly, we still want
205
+ // isInside to be roughly correct.
206
+ if (knownToBeInside === undefined && !isInside && eraserPath.closedContainsPoint(path.getExactBBox().center)) {
207
+ isInside = !isInside;
208
+ }
209
+ if (!component) {
210
+ return;
211
+ }
212
+ // Assertion: Avoid deleting sections that are much larger than the eraser.
213
+ failedAssertions ||= isInside && path.getExactBBox().maxDimension > eraserPath.getExactBBox().maxDimension * 2;
214
+ if (!isInside) {
215
+ newStrokes.push(component);
216
+ }
217
+ };
218
+ if (part.style.fill.a === 0) { // Not filled?
219
+ // An additional case where we erase completely -- without the padding of the stroke,
220
+ // the path is smaller than the eraser (allows us to erase dots completely).
221
+ const shouldEraseCompletely = eraserPath.getExactBBox().maxDimension / 10 > path.getExactBBox().maxDimension;
222
+ if (!shouldEraseCompletely) {
223
+ const split = path.splitAt(intersectionPoints, { mapNewPoint: p => viewport.roundPoint(p) });
224
+ for (const splitPart of split) {
225
+ addNewPath(splitPart);
226
+ }
227
+ }
228
+ }
229
+ else if (intersectionPoints.length >= 2 && intersectionPoints.length % 2 === 0) {
230
+ // TODO: Support subtractive erasing on small scales -- see https://github.com/personalizedrefrigerator/js-draw/pull/63/commits/568686e2384219ad0bb07617ea4efff1540aed00
231
+ // for a broken implementation.
232
+ //
233
+ // We currently assume that a 4-point intersection means that the intersection
234
+ // looks similar to this:
235
+ // -----------
236
+ // | STROKE |
237
+ // | |
238
+ //%%x-----------x%%%%%%%
239
+ //% %
240
+ //% ERASER %
241
+ //% %
242
+ //%%x-----------x%%%%%%%
243
+ // | STROKE |
244
+ // -----------
245
+ //
246
+ // Our goal is to separate STROKE into the contiguous parts outside
247
+ // of the eraser (as shown above).
248
+ //
249
+ // To do this, we split STROKE at each intersection:
250
+ // 3 3 3 3 3 3
251
+ // 3 STROKE 3
252
+ // 3 3
253
+ // x x
254
+ // 2 4
255
+ // 2 STROKE 4
256
+ // 2 4
257
+ // x x
258
+ // 1 STROKE 5
259
+ // . 5 5 5 5 5
260
+ // ^
261
+ // Start
262
+ //
263
+ // The difficulty here is correctly pairing edges to create the the output
264
+ // strokes, particularly because we don't know the order of intersection points.
265
+ const parts = path.splitAt(intersectionPoints, { mapNewPoint: p => viewport.roundPoint(p) });
266
+ for (let i = 0; i < Math.floor(parts.length / 2); i++) {
267
+ addNewPath(parts[i].union(parts[parts.length - i - 1]).asClosed());
268
+ }
269
+ if (parts.length % 2 !== 0) {
270
+ addNewPath(parts[Math.floor(parts.length / 2)].asClosed());
271
+ }
272
+ }
273
+ else {
274
+ addNewPath(path, false);
275
+ }
276
+ }
277
+ if (failedAssertions) {
278
+ return [this];
279
+ }
280
+ return newStrokes;
281
+ }
126
282
  intersects(line) {
127
283
  for (const part of this.parts) {
128
284
  const strokeWidth = part.style.stroke?.width;
@@ -9,7 +9,6 @@ import RenderingStyle from '../../rendering/RenderingStyle';
9
9
  /**
10
10
  * Creates strokes from line segments rather than Bézier curves.
11
11
  *
12
- * @beta Output behavior may change significantly between versions. For now, intended for debugging.
13
12
  */
14
13
  export declare const makePolylineBuilder: ComponentBuilderFactory;
15
14
  export default class PolylineBuilder implements ComponentBuilder {
@@ -21,6 +20,7 @@ export default class PolylineBuilder implements ComponentBuilder {
21
20
  private widthAverageNumSamples;
22
21
  private lastPoint;
23
22
  private startPoint;
23
+ private lastLineSegment;
24
24
  constructor(startPoint: StrokeDataPoint, minFitAllowed: number, viewport: Viewport);
25
25
  getBBox(): Rect2;
26
26
  protected getRenderingStyle(): RenderingStyle;
@@ -11,7 +11,6 @@ const makeShapeFitAutocorrect_1 = __importDefault(require("./autocorrect/makeSha
11
11
  /**
12
12
  * Creates strokes from line segments rather than Bézier curves.
13
13
  *
14
- * @beta Output behavior may change significantly between versions. For now, intended for debugging.
15
14
  */
16
15
  exports.makePolylineBuilder = (0, makeShapeFitAutocorrect_1.default)((initialPoint, viewport) => {
17
16
  const minFit = viewport.getSizeOfPixelOnCanvas();
@@ -23,6 +22,7 @@ class PolylineBuilder {
23
22
  this.viewport = viewport;
24
23
  this.parts = [];
25
24
  this.widthAverageNumSamples = 1;
25
+ this.lastLineSegment = null;
26
26
  this.averageWidth = startPoint.width;
27
27
  this.startPoint = {
28
28
  ...startPoint,
@@ -56,7 +56,7 @@ class PolylineBuilder {
56
56
  if (commands.length <= 1) {
57
57
  commands.push({
58
58
  kind: math_1.PathCommandType.LineTo,
59
- point: startPoint,
59
+ point: startPoint.plus(math_1.Vec2.of(this.averageWidth / 4, 0)),
60
60
  });
61
61
  }
62
62
  return {
@@ -104,11 +104,18 @@ class PolylineBuilder {
104
104
  + newPoint.width / this.widthAverageNumSamples;
105
105
  const roundedPoint = this.roundPoint(newPoint.pos);
106
106
  if (!roundedPoint.eq(this.lastPoint)) {
107
+ // If almost exactly in the same line as the previous
108
+ if (this.lastLineSegment && this.lastLineSegment.direction.dot(roundedPoint.minus(this.lastPoint).normalized()) > 0.997) {
109
+ this.parts.pop();
110
+ this.lastPoint = this.lastLineSegment.p1;
111
+ }
107
112
  this.parts.push({
108
113
  kind: math_1.PathCommandType.LineTo,
109
114
  point: this.roundPoint(newPoint.pos),
110
115
  });
111
116
  this.bbox = this.bbox.grownToPoint(roundedPoint);
117
+ this.lastLineSegment = new math_1.LineSegment2(this.lastPoint, roundedPoint);
118
+ this.lastPoint = roundedPoint;
112
119
  }
113
120
  }
114
121
  }
@@ -12,6 +12,7 @@ export default class PressureSensitiveFreehandLineBuilder implements ComponentBu
12
12
  private isFirstSegment;
13
13
  private pathStartConnector;
14
14
  private mostRecentConnector;
15
+ private nextCurveStartConnector;
15
16
  private upperSegments;
16
17
  private lowerSegments;
17
18
  private lastUpperBezier;
@@ -25,7 +26,6 @@ export default class PressureSensitiveFreehandLineBuilder implements ComponentBu
25
26
  private getRenderingStyle;
26
27
  private previewCurrentPath;
27
28
  private previewFullPath;
28
- private previewStroke;
29
29
  preview(renderer: AbstractRenderer): void;
30
30
  build(): Stroke;
31
31
  private roundPoint;
@@ -4,7 +4,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.makePressureSensitiveFreehandLineBuilder = void 0;
7
- const bezier_js_1 = require("bezier-js");
8
7
  const math_1 = require("@js-draw/math");
9
8
  const Stroke_1 = __importDefault(require("../Stroke"));
10
9
  const Viewport_1 = __importDefault(require("../../Viewport"));
@@ -31,6 +30,7 @@ class PressureSensitiveFreehandLineBuilder {
31
30
  this.isFirstSegment = true;
32
31
  this.pathStartConnector = null;
33
32
  this.mostRecentConnector = null;
33
+ this.nextCurveStartConnector = null;
34
34
  this.lastUpperBezier = null;
35
35
  this.lastLowerBezier = null;
36
36
  this.parts = [];
@@ -48,18 +48,18 @@ class PressureSensitiveFreehandLineBuilder {
48
48
  fill: this.startPoint.color ?? null,
49
49
  };
50
50
  }
51
- previewCurrentPath() {
51
+ previewCurrentPath(extendWithLatest = true) {
52
52
  const upperPath = this.upperSegments.slice();
53
53
  const lowerPath = this.lowerSegments.slice();
54
54
  let lowerToUpperCap;
55
55
  let pathStartConnector;
56
56
  const currentCurve = this.curveFitter.preview();
57
- if (currentCurve) {
57
+ if (currentCurve && extendWithLatest) {
58
58
  const { upperCurveCommand, lowerToUpperConnector, upperToLowerConnector, lowerCurveCommand } = this.segmentToPath(currentCurve);
59
59
  upperPath.push(upperCurveCommand);
60
60
  lowerPath.push(lowerCurveCommand);
61
61
  lowerToUpperCap = lowerToUpperConnector;
62
- pathStartConnector = this.pathStartConnector ?? upperToLowerConnector;
62
+ pathStartConnector = this.pathStartConnector ?? [upperToLowerConnector];
63
63
  }
64
64
  else {
65
65
  if (this.mostRecentConnector === null || this.pathStartConnector === null) {
@@ -100,7 +100,7 @@ class PressureSensitiveFreehandLineBuilder {
100
100
  // __/ __/
101
101
  // /___ /
102
102
  // •
103
- pathStartConnector,
103
+ ...pathStartConnector,
104
104
  // Move back to the start point:
105
105
  // •
106
106
  // __/ __/
@@ -117,13 +117,6 @@ class PressureSensitiveFreehandLineBuilder {
117
117
  }
118
118
  return null;
119
119
  }
120
- previewStroke() {
121
- const pathPreview = this.previewFullPath();
122
- if (pathPreview) {
123
- return new Stroke_1.default(pathPreview);
124
- }
125
- return null;
126
- }
127
120
  preview(renderer) {
128
121
  const paths = this.previewFullPath();
129
122
  if (paths) {
@@ -141,7 +134,7 @@ class PressureSensitiveFreehandLineBuilder {
141
134
  // Ensure we have something.
142
135
  this.addCurve(null);
143
136
  }
144
- return this.previewStroke();
137
+ return new Stroke_1.default(this.previewFullPath());
145
138
  }
146
139
  roundPoint(point) {
147
140
  let minFit = Math.min(this.minFitAllowed, this.curveStartWidth / 3);
@@ -156,25 +149,16 @@ class PressureSensitiveFreehandLineBuilder {
156
149
  return false;
157
150
  }
158
151
  const getIntersection = (curve1, curve2) => {
159
- const intersection = curve1.intersects(curve2);
160
- if (!intersection || intersection.length === 0) {
152
+ const intersections = curve1.intersectsBezier(curve2);
153
+ if (!intersections.length)
161
154
  return null;
162
- }
163
- // From http://pomax.github.io/bezierjs/#intersect-curve,
164
- // .intersects returns an array of 't1/t2' pairs, where curve1.at(t1) gives the point.
165
- const firstTPair = intersection[0];
166
- const match = /^([-0-9.eE]+)\/([-0-9.eE]+)$/.exec(firstTPair);
167
- if (!match) {
168
- throw new Error(`Incorrect format returned by .intersects: ${intersection} should be array of "number/number"!`);
169
- }
170
- const t = parseFloat(match[1]);
171
- return math_1.Vec2.ofXY(curve1.get(t));
155
+ return intersections[0].point;
172
156
  };
173
157
  const getExitDirection = (curve) => {
174
- return math_1.Vec2.ofXY(curve.points[2]).minus(math_1.Vec2.ofXY(curve.points[1])).normalized();
158
+ return curve.p2.minus(curve.p1).normalized();
175
159
  };
176
160
  const getEnterDirection = (curve) => {
177
- return math_1.Vec2.ofXY(curve.points[1]).minus(math_1.Vec2.ofXY(curve.points[0])).normalized();
161
+ return curve.p1.minus(curve.p0).normalized();
178
162
  };
179
163
  // Prevent
180
164
  // /
@@ -185,8 +169,8 @@ class PressureSensitiveFreehandLineBuilder {
185
169
  // where the next stroke and the previous stroke are in different directions.
186
170
  //
187
171
  // Are the exit/enter directions of the previous and current curves in different enough directions?
188
- if (getEnterDirection(upperCurve).dot(getExitDirection(this.lastUpperBezier)) < 0.3
189
- || getEnterDirection(lowerCurve).dot(getExitDirection(this.lastLowerBezier)) < 0.3
172
+ if (getEnterDirection(upperCurve).dot(getExitDirection(this.lastUpperBezier)) < 0.35
173
+ || getEnterDirection(lowerCurve).dot(getExitDirection(this.lastLowerBezier)) < 0.35
190
174
  // Also handle if the curves exit/enter directions differ
191
175
  || getEnterDirection(upperCurve).dot(getExitDirection(upperCurve)) < 0
192
176
  || getEnterDirection(lowerCurve).dot(getExitDirection(lowerCurve)) < 0) {
@@ -242,32 +226,37 @@ class PressureSensitiveFreehandLineBuilder {
242
226
  controlPoint: center.plus(math_1.Vec2.of(width, -width)),
243
227
  endPoint: center.plus(math_1.Vec2.of(width, 0)),
244
228
  });
245
- this.pathStartConnector = {
229
+ const connector = {
246
230
  kind: math_1.PathCommandType.LineTo,
247
231
  point: startPoint,
248
232
  };
249
- this.mostRecentConnector = this.pathStartConnector;
233
+ this.pathStartConnector = [connector];
234
+ this.mostRecentConnector = connector;
250
235
  return;
251
236
  }
252
- const { upperCurveCommand, lowerToUpperConnector, upperToLowerConnector, lowerCurveCommand, lowerCurve, upperCurve, } = this.segmentToPath(curve);
253
- const shouldStartNew = this.shouldStartNewSegment(lowerCurve, upperCurve);
237
+ const { upperCurveCommand, lowerToUpperConnector, upperToLowerConnector, lowerCurveCommand, lowerCurve, upperCurve, nextCurveStartConnector, } = this.segmentToPath(curve);
238
+ let shouldStartNew = this.shouldStartNewSegment(lowerCurve, upperCurve);
254
239
  if (shouldStartNew) {
255
- const part = this.previewCurrentPath();
240
+ const part = this.previewCurrentPath(false);
256
241
  if (part) {
257
242
  this.parts.push(part);
258
243
  this.upperSegments = [];
259
244
  this.lowerSegments = [];
260
245
  }
246
+ else {
247
+ shouldStartNew = false;
248
+ }
261
249
  }
262
250
  if (this.isFirstSegment || shouldStartNew) {
263
251
  // We draw the upper path (reversed), then the lower path, so we need the
264
252
  // upperToLowerConnector to join the two paths.
265
- this.pathStartConnector = upperToLowerConnector;
253
+ this.pathStartConnector = this.nextCurveStartConnector ?? [upperToLowerConnector];
266
254
  this.isFirstSegment = false;
267
255
  }
268
256
  // With the most recent connector, we're joining the end of the lowerPath to the most recent
269
257
  // upperPath:
270
258
  this.mostRecentConnector = lowerToUpperConnector;
259
+ this.nextCurveStartConnector = nextCurveStartConnector;
271
260
  this.lowerSegments.push(lowerCurveCommand);
272
261
  this.upperSegments.push(upperCurveCommand);
273
262
  this.lastLowerBezier = lowerCurve;
@@ -276,9 +265,9 @@ class PressureSensitiveFreehandLineBuilder {
276
265
  }
277
266
  // Returns [upper curve, connector, lower curve]
278
267
  segmentToPath(curve) {
279
- const bezier = new bezier_js_1.Bezier(curve.startPoint.xy, curve.controlPoint.xy, curve.endPoint.xy);
280
- let startVec = math_1.Vec2.ofXY(bezier.normal(0)).normalized();
281
- let endVec = math_1.Vec2.ofXY(bezier.normal(1)).normalized();
268
+ const bezier = new math_1.QuadraticBezier(curve.startPoint, curve.controlPoint, curve.endPoint);
269
+ let startVec = bezier.normal(0);
270
+ let endVec = bezier.normal(1);
282
271
  startVec = startVec.times(curve.startWidth / 2);
283
272
  endVec = endVec.times(curve.endWidth / 2);
284
273
  if (!isFinite(startVec.magnitude())) {
@@ -289,18 +278,9 @@ class PressureSensitiveFreehandLineBuilder {
289
278
  const endPt = curve.endPoint;
290
279
  const controlPoint = curve.controlPoint;
291
280
  // Approximate the normal at the location of the control point
292
- let projectionT = bezier.project(controlPoint.xy).t;
293
- if (!projectionT) {
294
- if (startPt.squareDistanceTo(controlPoint) < endPt.squareDistanceTo(controlPoint)) {
295
- projectionT = 0.1;
296
- }
297
- else {
298
- projectionT = 0.9;
299
- }
300
- }
281
+ const projectionT = bezier.nearestPointTo(controlPoint).parameterValue;
301
282
  const halfVecT = projectionT;
302
- const halfVec = math_1.Vec2.ofXY(bezier.normal(halfVecT))
303
- .normalized().times(curve.startWidth / 2 * halfVecT
283
+ const halfVec = bezier.normal(halfVecT).times(curve.startWidth / 2 * halfVecT
304
284
  + curve.endWidth / 2 * (1 - halfVecT));
305
285
  // Each starts at startPt ± startVec
306
286
  const lowerCurveStartPoint = this.roundPoint(startPt.plus(startVec));
@@ -324,16 +304,29 @@ class PressureSensitiveFreehandLineBuilder {
324
304
  kind: math_1.PathCommandType.LineTo,
325
305
  point: upperCurveStartPoint,
326
306
  };
307
+ // The segment to be used to start the next path (to insert to connect the start of its
308
+ // lower and the end of its upper).
309
+ const nextCurveStartConnector = [
310
+ {
311
+ kind: math_1.PathCommandType.LineTo,
312
+ point: upperCurveStartPoint,
313
+ },
314
+ {
315
+ kind: math_1.PathCommandType.LineTo,
316
+ point: lowerCurveEndPoint,
317
+ },
318
+ ];
327
319
  const upperCurveCommand = {
328
320
  kind: math_1.PathCommandType.QuadraticBezierTo,
329
321
  controlPoint: upperCurveControlPoint,
330
322
  endPoint: upperCurveEndPoint,
331
323
  };
332
- const upperCurve = new bezier_js_1.Bezier(upperCurveStartPoint, upperCurveControlPoint, upperCurveEndPoint);
333
- const lowerCurve = new bezier_js_1.Bezier(lowerCurveStartPoint, lowerCurveControlPoint, lowerCurveEndPoint);
324
+ const upperCurve = new math_1.QuadraticBezier(upperCurveStartPoint, upperCurveControlPoint, upperCurveEndPoint);
325
+ const lowerCurve = new math_1.QuadraticBezier(lowerCurveStartPoint, lowerCurveControlPoint, lowerCurveEndPoint);
334
326
  return {
335
327
  upperCurveCommand, upperToLowerConnector, lowerToUpperConnector, lowerCurveCommand,
336
328
  upperCurve, lowerCurve,
329
+ nextCurveStartConnector,
337
330
  };
338
331
  }
339
332
  addPoint(newPoint) {