js-draw 1.7.1 → 1.8.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 (49) hide show
  1. package/dist/Editor.css +1 -0
  2. package/dist/bundle.js +2 -2
  3. package/dist/bundledStyles.js +1 -1
  4. package/dist/cjs/Editor.js +4 -4
  5. package/dist/cjs/EventDispatcher.js +2 -1
  6. package/dist/cjs/Viewport.d.ts +2 -0
  7. package/dist/cjs/Viewport.js +2 -0
  8. package/dist/cjs/components/AbstractComponent.d.ts +9 -0
  9. package/dist/cjs/components/AbstractComponent.js +11 -0
  10. package/dist/cjs/components/Stroke.d.ts +3 -0
  11. package/dist/cjs/components/Stroke.js +55 -1
  12. package/dist/cjs/image/EditorImage.js +17 -1
  13. package/dist/cjs/rendering/RenderablePathSpec.d.ts +20 -2
  14. package/dist/cjs/rendering/RenderablePathSpec.js +72 -9
  15. package/dist/cjs/rendering/caching/CacheRecord.js +5 -3
  16. package/dist/cjs/rendering/renderers/AbstractRenderer.d.ts +10 -0
  17. package/dist/cjs/rendering/renderers/AbstractRenderer.js +12 -3
  18. package/dist/cjs/rendering/renderers/CanvasRenderer.js +2 -2
  19. package/dist/cjs/rendering/renderers/SVGRenderer.d.ts +0 -10
  20. package/dist/cjs/rendering/renderers/SVGRenderer.js +0 -14
  21. package/dist/cjs/testing/startPinchGesture.d.ts +14 -0
  22. package/dist/cjs/testing/startPinchGesture.js +41 -0
  23. package/dist/cjs/tools/PanZoom.d.ts +4 -0
  24. package/dist/cjs/tools/PanZoom.js +21 -2
  25. package/dist/cjs/version.js +1 -1
  26. package/dist/mjs/Editor.mjs +4 -4
  27. package/dist/mjs/EventDispatcher.mjs +2 -1
  28. package/dist/mjs/Viewport.d.ts +2 -0
  29. package/dist/mjs/Viewport.mjs +2 -0
  30. package/dist/mjs/components/AbstractComponent.d.ts +9 -0
  31. package/dist/mjs/components/AbstractComponent.mjs +11 -0
  32. package/dist/mjs/components/Stroke.d.ts +3 -0
  33. package/dist/mjs/components/Stroke.mjs +56 -2
  34. package/dist/mjs/image/EditorImage.mjs +17 -1
  35. package/dist/mjs/rendering/RenderablePathSpec.d.ts +20 -2
  36. package/dist/mjs/rendering/RenderablePathSpec.mjs +71 -9
  37. package/dist/mjs/rendering/caching/CacheRecord.mjs +5 -3
  38. package/dist/mjs/rendering/renderers/AbstractRenderer.d.ts +10 -0
  39. package/dist/mjs/rendering/renderers/AbstractRenderer.mjs +12 -3
  40. package/dist/mjs/rendering/renderers/CanvasRenderer.mjs +2 -2
  41. package/dist/mjs/rendering/renderers/SVGRenderer.d.ts +0 -10
  42. package/dist/mjs/rendering/renderers/SVGRenderer.mjs +0 -14
  43. package/dist/mjs/testing/startPinchGesture.d.ts +14 -0
  44. package/dist/mjs/testing/startPinchGesture.mjs +36 -0
  45. package/dist/mjs/tools/PanZoom.d.ts +4 -0
  46. package/dist/mjs/tools/PanZoom.mjs +21 -2
  47. package/dist/mjs/version.mjs +1 -1
  48. package/package.json +3 -3
  49. package/src/Editor.scss +2 -0
@@ -17,8 +17,11 @@ class AbstractRenderer {
17
17
  this.objectLevel = 0;
18
18
  this.currentPaths = null;
19
19
  }
20
- // this.canvasToScreen, etc. should be used instead of the corresponding
21
- // methods on Viewport.
20
+ /**
21
+ * this.canvasToScreen, etc. should be used instead of the corresponding
22
+ * methods on `Viewport`, because the viewport may not accurately reflect
23
+ * what is rendered.
24
+ */
22
25
  getViewport() { return this.viewport; }
23
26
  setDraftMode(_draftMode) { }
24
27
  flushPath() {
@@ -160,13 +163,19 @@ class AbstractRenderer {
160
163
  getSizeOfCanvasPixelOnScreen() {
161
164
  return this.getCanvasToScreenTransform().transformVec3(math_1.Vec2.unitX).length();
162
165
  }
166
+ /**
167
+ * @internal
168
+ */
169
+ overrideVisibleRect(rect) {
170
+ this.visibleRectOverride = rect;
171
+ }
163
172
  // Returns the region in canvas space that is visible within the viewport this
164
173
  // canvas is rendering to.
165
174
  //
166
175
  // Note that in some cases this might not be the same as the `visibleRect` given
167
176
  // to components in their `render` method.
168
177
  getVisibleRect() {
169
- return this.viewport.visibleRect;
178
+ return this.visibleRectOverride ?? this.viewport.visibleRect;
170
179
  }
171
180
  }
172
181
  exports.default = AbstractRenderer;
@@ -149,7 +149,7 @@ class CanvasRenderer extends AbstractRenderer_1.default {
149
149
  return;
150
150
  }
151
151
  // If part of a huge object, it might be worth trimming the path
152
- const visibleRect = this.getViewport().visibleRect;
152
+ const visibleRect = this.getVisibleRect();
153
153
  if (this.currentObjectBBox?.containsRect(visibleRect)) {
154
154
  // Try to trim/remove parts of the path outside of the bounding box.
155
155
  path = (0, RenderablePathSpec_1.visualEquivalent)(path, visibleRect);
@@ -189,7 +189,7 @@ class CanvasRenderer extends AbstractRenderer_1.default {
189
189
  if (!this.ignoringObject && clip) {
190
190
  // Don't clip if it would only remove content already trimmed by
191
191
  // the edge of the screen.
192
- const clippedIsOutsideScreen = boundingBox.containsRect(this.getViewport().visibleRect);
192
+ const clippedIsOutsideScreen = boundingBox.containsRect(this.getVisibleRect());
193
193
  if (!clippedIsOutsideScreen) {
194
194
  this.clipLevels.push(this.objectLevel);
195
195
  this.ctx.save();
@@ -59,16 +59,6 @@ export default class SVGRenderer extends AbstractRenderer {
59
59
  drawPoints(...points: Point2[]): void;
60
60
  drawSVGElem(elem: SVGElement): void;
61
61
  isTooSmallToRender(_rect: Rect2): boolean;
62
- private visibleRectOverride;
63
- /**
64
- * Overrides the visible region returned by `getVisibleRect`.
65
- *
66
- * This is useful when the `viewport`'s transform has been modified,
67
- * for example, to compensate for storing part of the image's
68
- * transformation in an SVG property.
69
- */
70
- private overrideVisibleRect;
71
- getVisibleRect(): Rect2;
72
62
  /**
73
63
  * Creates a new SVG element and `SVGRenerer` with `width`, `height`, `viewBox`,
74
64
  * and other metadata attributes set for the given `Viewport`.
@@ -42,7 +42,6 @@ class SVGRenderer extends AbstractRenderer_1.default {
42
42
  this.textContainer = null;
43
43
  this.textContainerTransform = null;
44
44
  this.textParentStyle = defaultTextStyle;
45
- this.visibleRectOverride = null;
46
45
  this.clear();
47
46
  this.addStyleSheet();
48
47
  }
@@ -345,19 +344,6 @@ class SVGRenderer extends AbstractRenderer_1.default {
345
344
  isTooSmallToRender(_rect) {
346
345
  return false;
347
346
  }
348
- /**
349
- * Overrides the visible region returned by `getVisibleRect`.
350
- *
351
- * This is useful when the `viewport`'s transform has been modified,
352
- * for example, to compensate for storing part of the image's
353
- * transformation in an SVG property.
354
- */
355
- overrideVisibleRect(newRect) {
356
- this.visibleRectOverride = newRect;
357
- }
358
- getVisibleRect() {
359
- return this.visibleRectOverride ?? super.getVisibleRect();
360
- }
361
347
  /**
362
348
  * Creates a new SVG element and `SVGRenerer` with `width`, `height`, `viewBox`,
363
349
  * and other metadata attributes set for the given `Viewport`.
@@ -0,0 +1,14 @@
1
+ import type Editor from '../Editor';
2
+ import { Point2 } from '@js-draw/math';
3
+ /**
4
+ * Creates two pointers and sends the touch {@link InputEvtType.PointerDownEvt}s for them.
5
+ *
6
+ * Returns an object that allows continuing or ending the gesture.
7
+ *
8
+ * `initialRotation` should be in radians.
9
+ */
10
+ declare const startPinchGesture: (editor: Editor, center: Point2, initialDistance: number, initialRotation: number) => {
11
+ update(center: Point2, distance: number, rotation: number): void;
12
+ end(): void;
13
+ };
14
+ export default startPinchGesture;
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const math_1 = require("@js-draw/math");
7
+ const sendTouchEvent_1 = __importDefault(require("./sendTouchEvent"));
8
+ const inputEvents_1 = require("../inputEvents");
9
+ /**
10
+ * Creates two pointers and sends the touch {@link InputEvtType.PointerDownEvt}s for them.
11
+ *
12
+ * Returns an object that allows continuing or ending the gesture.
13
+ *
14
+ * `initialRotation` should be in radians.
15
+ */
16
+ const startPinchGesture = (editor, center, initialDistance, initialRotation) => {
17
+ const computeTouchPoints = (center, distance, rotation) => {
18
+ const halfDisplacement = math_1.Mat33.zRotation(rotation).transformVec2(math_1.Vec2.of(0, distance / 2));
19
+ const point1 = center.plus(halfDisplacement);
20
+ const point2 = center.minus(halfDisplacement);
21
+ return [point1, point2];
22
+ };
23
+ let [touchPoint1, touchPoint2] = computeTouchPoints(center, initialDistance, initialRotation);
24
+ let firstPointer = (0, sendTouchEvent_1.default)(editor, inputEvents_1.InputEvtType.PointerDownEvt, touchPoint1);
25
+ let secondPointer = (0, sendTouchEvent_1.default)(editor, inputEvents_1.InputEvtType.PointerDownEvt, touchPoint2, [firstPointer]);
26
+ return {
27
+ update(center, distance, rotation) {
28
+ const eventType = inputEvents_1.InputEvtType.PointerMoveEvt;
29
+ const [newPoint1, newPoint2] = computeTouchPoints(center, distance, rotation);
30
+ touchPoint1 = newPoint1;
31
+ touchPoint2 = newPoint2;
32
+ firstPointer = (0, sendTouchEvent_1.default)(editor, eventType, newPoint1, [secondPointer]);
33
+ secondPointer = (0, sendTouchEvent_1.default)(editor, eventType, newPoint2, [firstPointer]);
34
+ },
35
+ end() {
36
+ (0, sendTouchEvent_1.default)(editor, inputEvents_1.InputEvtType.PointerUpEvt, touchPoint1, [secondPointer]);
37
+ (0, sendTouchEvent_1.default)(editor, inputEvents_1.InputEvtType.PointerUpEvt, touchPoint2);
38
+ },
39
+ };
40
+ };
41
+ exports.default = startPinchGesture;
@@ -21,6 +21,9 @@ export default class PanZoom extends BaseTool {
21
21
  private editor;
22
22
  private mode;
23
23
  private transform;
24
+ private readonly initialRotationSnapAngle;
25
+ private readonly afterRotationStartSnapAngle;
26
+ private readonly pinchZoomStartThreshold;
24
27
  private startDist;
25
28
  private lastDist;
26
29
  private lastScreenCenter;
@@ -29,6 +32,7 @@ export default class PanZoom extends BaseTool {
29
32
  private initialTouchAngle;
30
33
  private initialViewportRotation;
31
34
  private isScaling;
35
+ private isRotating;
32
36
  private inertialScroller;
33
37
  private velocity;
34
38
  constructor(editor: Editor, mode: PanZoomMode, description: string);
@@ -71,12 +71,19 @@ class PanZoom extends BaseTool_1.default {
71
71
  this.editor = editor;
72
72
  this.mode = mode;
73
73
  this.transform = null;
74
+ // Constants
75
+ // initialRotationSnapAngle is larger than afterRotationStartSnapAngle to
76
+ // make it more difficult to start rotating (and easier to continue rotating).
77
+ this.initialRotationSnapAngle = 0.22; // radians
78
+ this.afterRotationStartSnapAngle = 0.07; // radians
79
+ this.pinchZoomStartThreshold = 1.08; // scale factor
74
80
  this.lastPointerDownTimestamp = 0;
75
81
  this.initialTouchAngle = 0;
76
82
  this.initialViewportRotation = 0;
77
83
  // Set to `true` only when scaling has started (if two fingers are down and have moved
78
84
  // far enough).
79
85
  this.isScaling = false;
86
+ this.isRotating = false;
80
87
  this.inertialScroller = null;
81
88
  this.velocity = null;
82
89
  }
@@ -118,6 +125,9 @@ class PanZoom extends BaseTool_1.default {
118
125
  this.initialTouchAngle = angle;
119
126
  this.initialViewportRotation = this.editor.viewport.getRotationAngle();
120
127
  this.isScaling = false;
128
+ // We're initially rotated if `initialViewportRotation` isn't near a multiple of pi/2.
129
+ // In other words, if sin(2 initialViewportRotation) is near zero.
130
+ this.isRotating = Math.abs(Math.sin(this.initialViewportRotation * 2)) > 1e-3;
121
131
  handlingGesture = true;
122
132
  }
123
133
  else if (pointers.length === 1 && ((this.mode & PanZoomMode.OneFingerTouchGestures && allAreTouch)
@@ -174,7 +184,9 @@ class PanZoom extends BaseTool_1.default {
174
184
  const roundedFullRotation = Math.round(fullRotation / snapToMultipleOf) * snapToMultipleOf;
175
185
  // The maximum angle for which we snap the given angle to a multiple of
176
186
  // `snapToMultipleOf`.
177
- const maxSnapAngle = 0.07;
187
+ // Use a smaller snap angle if already rotated (to avoid pinch zoom gestures from
188
+ // starting rotation).
189
+ const maxSnapAngle = this.isRotating ? this.afterRotationStartSnapAngle : this.initialRotationSnapAngle;
178
190
  // Snap the rotation
179
191
  if (Math.abs(fullRotation - roundedFullRotation) < maxSnapAngle) {
180
192
  fullRotation = roundedFullRotation;
@@ -197,6 +209,11 @@ class PanZoom extends BaseTool_1.default {
197
209
  else {
198
210
  deltaRotation = this.toSnappedRotationDelta(angle);
199
211
  }
212
+ // If any rotation, make a note of this (affects rotation snap
213
+ // angles).
214
+ if (Math.abs(deltaRotation) > 1e-8) {
215
+ this.isRotating = true;
216
+ }
200
217
  this.updateVelocity(screenCenter);
201
218
  let scaleFactor = 1;
202
219
  if (this.isScaling) {
@@ -205,7 +222,9 @@ class PanZoom extends BaseTool_1.default {
205
222
  else {
206
223
  const initialScaleFactor = dist / this.startDist;
207
224
  // Only start scaling if scaling done so far exceeds some threshold.
208
- if (initialScaleFactor > 1.05 || initialScaleFactor < 0.95) {
225
+ const upperBound = this.pinchZoomStartThreshold;
226
+ const lowerBound = 1 / this.pinchZoomStartThreshold;
227
+ if (initialScaleFactor > upperBound || initialScaleFactor < lowerBound) {
209
228
  scaleFactor = initialScaleFactor;
210
229
  this.isScaling = true;
211
230
  }
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.default = {
4
- number: '1.7.1',
4
+ number: '1.8.0',
5
5
  };
@@ -1100,12 +1100,12 @@ export class Editor {
1100
1100
  if (this.settings.appInfo.description) {
1101
1101
  descriptionLines.push(this.settings.appInfo.description + '\n');
1102
1102
  }
1103
+ else {
1104
+ descriptionLines.push(`js-draw v${version.number}`);
1105
+ }
1103
1106
  notices.push({
1104
1107
  heading: `${this.settings.appInfo.name}`,
1105
- text: [
1106
- ...descriptionLines,
1107
- `(js-draw v${version.number})`,
1108
- ].join('\n'),
1108
+ text: descriptionLines.join('\n'),
1109
1109
  });
1110
1110
  }
1111
1111
  else {
@@ -1,4 +1,5 @@
1
- // Code shared with Joplin (js-draw was originally intended to be part of Joplin).
1
+ // Code shared with Joplin (from when it was MIT licensed)
2
+ // (js-draw was originally intended to be part of Joplin).
2
3
  /**
3
4
  * Handles notifying listeners of events.
4
5
  *
@@ -56,6 +56,8 @@ export declare class Viewport {
56
56
  /**
57
57
  * @returns the angle of the canvas in radians.
58
58
  * This is the angle by which the canvas is rotated relative to the screen.
59
+ *
60
+ * Returns an angle in the range $[-\pi, \pi]$ (the same range as {@link Vec3.angle}).
59
61
  */
60
62
  getRotationAngle(): number;
61
63
  /**
@@ -114,6 +114,8 @@ export class Viewport {
114
114
  /**
115
115
  * @returns the angle of the canvas in radians.
116
116
  * This is the angle by which the canvas is rotated relative to the screen.
117
+ *
118
+ * Returns an angle in the range $[-\pi, \pi]$ (the same range as {@link Vec3.angle}).
117
119
  */
118
120
  getRotationAngle() {
119
121
  return this.transform.transformVec3(Vec3.unitX).angle();
@@ -77,6 +77,15 @@ export default abstract class AbstractComponent {
77
77
  * {@link EditorImage}.
78
78
  */
79
79
  getSizingMode(): ComponentSizingMode;
80
+ /**
81
+ * **Optimization**
82
+ *
83
+ * Should return `true` if this component covers the entire `visibleRect`
84
+ * and would prevent anything below this component from being visible.
85
+ *
86
+ * Should return `false` otherwise.
87
+ */
88
+ occludesEverythingBelowWhenRenderedInRect(_visibleRect: Rect2): boolean;
80
89
  /** Called when this component is added to the given image. */
81
90
  onAddToImage(_image: EditorImage): void;
82
91
  onRemoveFromImage(): void;
@@ -102,6 +102,17 @@ class AbstractComponent {
102
102
  getSizingMode() {
103
103
  return ComponentSizingMode.BoundingBox;
104
104
  }
105
+ /**
106
+ * **Optimization**
107
+ *
108
+ * Should return `true` if this component covers the entire `visibleRect`
109
+ * and would prevent anything below this component from being visible.
110
+ *
111
+ * Should return `false` otherwise.
112
+ */
113
+ occludesEverythingBelowWhenRenderedInRect(_visibleRect) {
114
+ return false;
115
+ }
105
116
  /** Called when this component is added to the given image. */
106
117
  onAddToImage(_image) { }
107
118
  onRemoveFromImage() { }
@@ -49,6 +49,9 @@ export default class Stroke extends AbstractComponent implements RestyleableComp
49
49
  forceStyle(style: ComponentStyle, editor: Editor | null): void;
50
50
  intersects(line: LineSegment2): boolean;
51
51
  intersectsRect(rect: Rect2): boolean;
52
+ private simplifiedPath;
53
+ private computeSimplifiedPathFor;
54
+ occludesEverythingBelowWhenRenderedInRect(rect: Rect2): boolean;
52
55
  render(canvas: AbstractRenderer, visibleRect?: Rect2): void;
53
56
  getProportionalRenderingTime(): number;
54
57
  private bboxForPart;
@@ -2,7 +2,7 @@ import { Path, Rect2 } from '@js-draw/math';
2
2
  import { styleFromJSON, styleToJSON } from '../rendering/RenderingStyle.mjs';
3
3
  import AbstractComponent from './AbstractComponent.mjs';
4
4
  import { createRestyleComponentCommand } from './RestylableComponent.mjs';
5
- import { pathFromRenderable, pathToRenderable } from '../rendering/RenderablePathSpec.mjs';
5
+ import { pathFromRenderable, pathToRenderable, simplifyPathToFullScreenOrEmpty } from '../rendering/RenderablePathSpec.mjs';
6
6
  /**
7
7
  * Represents an {@link AbstractComponent} made up of one or more {@link Path}s.
8
8
  *
@@ -41,6 +41,9 @@ export default class Stroke extends AbstractComponent {
41
41
  // @internal
42
42
  // eslint-disable-next-line @typescript-eslint/prefer-as-const
43
43
  this.isRestylableComponent = true;
44
+ // A simplification of the path for a given visibleRect. Intended
45
+ // to help check for occlusion.
46
+ this.simplifiedPath = null;
44
47
  this.approximateRenderingTime = 0;
45
48
  this.parts = [];
46
49
  for (const section of parts) {
@@ -166,9 +169,60 @@ export default class Stroke extends AbstractComponent {
166
169
  }
167
170
  return super.intersectsRect(rect);
168
171
  }
172
+ computeSimplifiedPathFor(visibleRect) {
173
+ const simplifiedParts = [];
174
+ let occludes = false;
175
+ let skipSimplification = false;
176
+ for (const part of this.parts) {
177
+ if (skipSimplification
178
+ // Simplification currently only works for stroked paths
179
+ || !part.style.stroke
180
+ // One of the main purposes of this is to check for occlusion.
181
+ // We can't occlude things if the stroke is partially transparent.
182
+ || part.style.stroke.color.a < 0.99) {
183
+ simplifiedParts.push(part);
184
+ continue;
185
+ }
186
+ const mapping = simplifyPathToFullScreenOrEmpty(part, visibleRect);
187
+ if (mapping) {
188
+ simplifiedParts.push(mapping.path);
189
+ if (mapping.fullScreen) {
190
+ occludes = true;
191
+ skipSimplification = true;
192
+ }
193
+ }
194
+ else {
195
+ simplifiedParts.push(part);
196
+ }
197
+ }
198
+ return {
199
+ forVisibleRect: visibleRect,
200
+ parts: simplifiedParts,
201
+ occludes,
202
+ };
203
+ }
204
+ occludesEverythingBelowWhenRenderedInRect(rect) {
205
+ // Can't occlude if doesn't contain.
206
+ if (!this.getBBox().containsRect(rect)) {
207
+ return false;
208
+ }
209
+ if (!this.simplifiedPath || !this.simplifiedPath.forVisibleRect.eq(rect)) {
210
+ this.simplifiedPath = this.computeSimplifiedPathFor(rect);
211
+ }
212
+ return this.simplifiedPath.occludes;
213
+ }
169
214
  render(canvas, visibleRect) {
170
215
  canvas.startObject(this.getBBox());
171
- for (const part of this.parts) {
216
+ // Can we use a cached simplified path for faster rendering?
217
+ let parts = this.parts;
218
+ if (visibleRect && this.simplifiedPath?.forVisibleRect?.containsRect(visibleRect)) {
219
+ parts = this.simplifiedPath.parts;
220
+ }
221
+ else {
222
+ // Save memory
223
+ this.simplifiedPath = null;
224
+ }
225
+ for (const part of parts) {
172
226
  const bbox = this.bboxForPart(part.path.bbox, part.style);
173
227
  if (visibleRect) {
174
228
  if (!bbox.intersects(visibleRect)) {
@@ -663,12 +663,28 @@ export class ImageNode {
663
663
  leaves = this.getLeaves();
664
664
  }
665
665
  sortLeavesByZIndex(leaves);
666
- for (const leaf of leaves) {
666
+ // If some components hide others (and we're permitted to simplify,
667
+ // which is true in the case of visibleRect being defined), then only
668
+ // draw the non-hidden components:
669
+ let startIndex = 0;
670
+ if (visibleRect) {
671
+ for (let i = leaves.length - 1; i >= 1; i--) {
672
+ if (leaves[i].getContent()?.occludesEverythingBelowWhenRenderedInRect(visibleRect)) {
673
+ startIndex = i;
674
+ break;
675
+ }
676
+ }
677
+ }
678
+ for (let i = startIndex; i < leaves.length; i++) {
679
+ const leaf = leaves[i];
667
680
  // Leaves by definition have content
668
681
  leaf.getContent().render(renderer, visibleRect);
669
682
  }
670
683
  // Show debug information
671
684
  if (debugMode && visibleRect) {
685
+ if (startIndex !== 0) {
686
+ console.log('EditorImage: skipped ', startIndex, 'nodes due to occlusion');
687
+ }
672
688
  this.renderDebugBoundingBoxes(renderer, visibleRect);
673
689
  }
674
690
  }
@@ -6,11 +6,29 @@ interface RenderablePathSpec {
6
6
  style: RenderingStyle;
7
7
  path?: Path;
8
8
  }
9
+ interface RenderablePathSpecWithPath extends RenderablePathSpec {
10
+ path: Path;
11
+ }
9
12
  /** Converts a renderable path (a path with a `startPoint`, `commands`, and `style`). */
10
13
  export declare const pathFromRenderable: (renderable: RenderablePathSpec) => Path;
11
- export declare const pathToRenderable: (path: Path, style: RenderingStyle) => RenderablePathSpec;
14
+ export declare const pathToRenderable: (path: Path, style: RenderingStyle) => RenderablePathSpecWithPath;
15
+ interface RectangleSimplificationResult {
16
+ rectangle: Rect2;
17
+ path: RenderablePathSpecWithPath;
18
+ fullScreen: boolean;
19
+ }
20
+ /**
21
+ * Tries to simplify the given path to a fullscreen rectangle.
22
+ * Returns `null` on failure.
23
+ *
24
+ * @internal
25
+ */
26
+ export declare const simplifyPathToFullScreenOrEmpty: (renderablePath: RenderablePathSpec, visibleRect: Rect2, options?: {
27
+ fastCheck: boolean;
28
+ expensiveCheck: boolean;
29
+ }) => RectangleSimplificationResult | null;
12
30
  /**
13
31
  * @returns a Path that, when rendered, looks roughly equivalent to the given path.
14
32
  */
15
- export declare const visualEquivalent: (renderablePath: RenderablePathSpec, visibleRect: Rect2) => RenderablePathSpec;
33
+ export declare const visualEquivalent: (renderablePath: RenderablePathSpec, visibleRect: Rect2) => RenderablePathSpecWithPath;
16
34
  export default RenderablePathSpec;
@@ -1,4 +1,4 @@
1
- import { Mat33, Path, PathCommandType } from '@js-draw/math';
1
+ import { Color4, Mat33, Path, PathCommandType, Rect2 } from '@js-draw/math';
2
2
  /** Converts a renderable path (a path with a `startPoint`, `commands`, and `style`). */
3
3
  export const pathFromRenderable = (renderable) => {
4
4
  if (renderable.path) {
@@ -15,33 +15,89 @@ export const pathToRenderable = (path, style) => {
15
15
  };
16
16
  };
17
17
  /**
18
- * @returns a Path that, when rendered, looks roughly equivalent to the given path.
18
+ * Fills the optional `path` field in `RenderablePathSpec`
19
+ * with `path` if not already filled
19
20
  */
20
- export const visualEquivalent = (renderablePath, visibleRect) => {
21
+ const pathIncluded = (renderablePath, path) => {
22
+ if (renderablePath.path) {
23
+ return renderablePath;
24
+ }
25
+ return {
26
+ ...renderablePath,
27
+ path,
28
+ };
29
+ };
30
+ /**
31
+ * Tries to simplify the given path to a fullscreen rectangle.
32
+ * Returns `null` on failure.
33
+ *
34
+ * @internal
35
+ */
36
+ export const simplifyPathToFullScreenOrEmpty = (renderablePath, visibleRect, options = { fastCheck: true, expensiveCheck: true }) => {
21
37
  const path = pathFromRenderable(renderablePath);
22
38
  const strokeWidth = renderablePath.style.stroke?.width ?? 0;
23
39
  const onlyStroked = strokeWidth > 0 && renderablePath.style.fill.a === 0;
24
40
  const styledPathBBox = path.bbox.grownBy(strokeWidth);
25
41
  // Are we close enough to the path that it fills the entire screen?
26
- if (onlyStroked
27
- && renderablePath.style.stroke
42
+ const isOnlyStrokedAndCouldFillScreen = (onlyStroked
28
43
  && strokeWidth > visibleRect.maxDimension
29
- && styledPathBBox.containsRect(visibleRect)) {
44
+ && styledPathBBox.containsRect(visibleRect));
45
+ if (options.fastCheck && isOnlyStrokedAndCouldFillScreen && renderablePath.style.stroke) {
30
46
  const strokeRadius = strokeWidth / 2;
47
+ // Are we completely within the stroke?
31
48
  // Do a fast, but with many false negatives, check.
32
49
  for (const point of path.startEndPoints()) {
33
50
  // If within the strokeRadius of any point
34
51
  if (visibleRect.isWithinRadiusOf(strokeRadius, point)) {
35
- return pathToRenderable(Path.fromRect(visibleRect), { fill: renderablePath.style.stroke.color });
52
+ return {
53
+ rectangle: visibleRect,
54
+ path: pathToRenderable(Path.fromRect(visibleRect), { fill: renderablePath.style.stroke.color }),
55
+ fullScreen: true,
56
+ };
36
57
  }
37
58
  }
38
59
  }
60
+ // Try filtering again, but with slightly more expensive checks
61
+ if (options.expensiveCheck &&
62
+ isOnlyStrokedAndCouldFillScreen && renderablePath.style.stroke
63
+ && strokeWidth > visibleRect.maxDimension * 3) {
64
+ const signedDist = path.signedDistance(visibleRect.center, strokeWidth / 2);
65
+ const margin = strokeWidth / 6;
66
+ if (signedDist < -visibleRect.maxDimension / 2 - margin) {
67
+ return {
68
+ path: pathToRenderable(Path.fromRect(visibleRect), { fill: renderablePath.style.stroke.color }),
69
+ rectangle: visibleRect,
70
+ fullScreen: true,
71
+ };
72
+ }
73
+ else if (signedDist > visibleRect.maxDimension / 2 + margin) {
74
+ return {
75
+ path: pathToRenderable(Path.empty, { fill: Color4.transparent }),
76
+ rectangle: Rect2.empty,
77
+ fullScreen: false,
78
+ };
79
+ }
80
+ }
81
+ return null;
82
+ };
83
+ /**
84
+ * @returns a Path that, when rendered, looks roughly equivalent to the given path.
85
+ */
86
+ export const visualEquivalent = (renderablePath, visibleRect) => {
87
+ const path = pathFromRenderable(renderablePath);
88
+ const strokeWidth = renderablePath.style.stroke?.width ?? 0;
89
+ const onlyStroked = strokeWidth > 0 && renderablePath.style.fill.a === 0;
90
+ const styledPathBBox = path.bbox.grownBy(strokeWidth);
91
+ let rectangleSimplification = simplifyPathToFullScreenOrEmpty(renderablePath, visibleRect, { fastCheck: true, expensiveCheck: false, });
92
+ if (rectangleSimplification) {
93
+ return rectangleSimplification.path;
94
+ }
39
95
  // Scale the expanded rect --- the visual equivalent is only close for huge strokes.
40
96
  const expandedRect = visibleRect.grownBy(strokeWidth)
41
97
  .transformedBoundingBox(Mat33.scaling2D(4, visibleRect.center));
42
98
  // TODO: Handle simplifying very small paths.
43
99
  if (expandedRect.containsRect(styledPathBBox)) {
44
- return renderablePath;
100
+ return pathIncluded(renderablePath, path);
45
101
  }
46
102
  const parts = [];
47
103
  let startPoint = path.startPoint;
@@ -76,5 +132,11 @@ export const visualEquivalent = (renderablePath, visibleRect) => {
76
132
  }
77
133
  startPoint = endPoint;
78
134
  }
79
- return pathToRenderable(new Path(path.startPoint, parts), renderablePath.style);
135
+ const newPath = new Path(path.startPoint, parts);
136
+ const newStyle = renderablePath.style;
137
+ rectangleSimplification = simplifyPathToFullScreenOrEmpty(renderablePath, visibleRect, { fastCheck: false, expensiveCheck: true, });
138
+ if (rectangleSimplification) {
139
+ return rectangleSimplification.path;
140
+ }
141
+ return pathToRenderable(newPath, newStyle);
80
142
  };
@@ -47,8 +47,10 @@ export default class CacheRecord {
47
47
  return transform;
48
48
  }
49
49
  setRenderingRegion(drawTo) {
50
- this.renderer.setTransform(
51
- // Invert to map objects instead of the viewport
52
- this.getTransform(drawTo));
50
+ const transform = this.getTransform(drawTo);
51
+ this.renderer.setTransform(transform);
52
+ // The visible region may be slightly larger than where we're actually drawing
53
+ // to (because of rounding).
54
+ this.renderer.overrideVisibleRect(drawTo.grownBy(1 / transform.getScaleFactor()));
53
55
  }
54
56
  }
@@ -20,6 +20,11 @@ export default abstract class AbstractRenderer {
20
20
  private selfTransform;
21
21
  private transformStack;
22
22
  protected constructor(viewport: Viewport);
23
+ /**
24
+ * this.canvasToScreen, etc. should be used instead of the corresponding
25
+ * methods on `Viewport`, because the viewport may not accurately reflect
26
+ * what is rendered.
27
+ */
23
28
  protected getViewport(): Viewport;
24
29
  abstract displaySize(): Vec2;
25
30
  abstract clear(): void;
@@ -73,5 +78,10 @@ export default abstract class AbstractRenderer {
73
78
  getCanvasToScreenTransform(): Mat33;
74
79
  canvasToScreen(vec: Vec2): Vec2;
75
80
  getSizeOfCanvasPixelOnScreen(): number;
81
+ private visibleRectOverride;
82
+ /**
83
+ * @internal
84
+ */
85
+ overrideVisibleRect(rect: Rect2 | null): void;
76
86
  getVisibleRect(): Rect2;
77
87
  }