js-draw 1.7.2 → 1.9.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 (68) hide show
  1. package/LICENSE +1 -1
  2. package/dist/Editor.css +1 -0
  3. package/dist/bundle.js +2 -2
  4. package/dist/bundledStyles.js +1 -1
  5. package/dist/cjs/EventDispatcher.js +2 -1
  6. package/dist/cjs/SVGLoader.d.ts +1 -1
  7. package/dist/cjs/SVGLoader.js +11 -2
  8. package/dist/cjs/Viewport.d.ts +2 -0
  9. package/dist/cjs/Viewport.js +2 -0
  10. package/dist/cjs/components/AbstractComponent.d.ts +9 -0
  11. package/dist/cjs/components/AbstractComponent.js +11 -0
  12. package/dist/cjs/components/Stroke.d.ts +3 -0
  13. package/dist/cjs/components/Stroke.js +55 -1
  14. package/dist/cjs/image/EditorImage.d.ts +20 -0
  15. package/dist/cjs/image/EditorImage.js +48 -3
  16. package/dist/cjs/rendering/Display.d.ts +9 -0
  17. package/dist/cjs/rendering/Display.js +39 -5
  18. package/dist/cjs/rendering/RenderablePathSpec.d.ts +20 -2
  19. package/dist/cjs/rendering/RenderablePathSpec.js +72 -9
  20. package/dist/cjs/rendering/caching/CacheRecord.js +5 -3
  21. package/dist/cjs/rendering/caching/CacheRecordManager.js +3 -4
  22. package/dist/cjs/rendering/caching/RenderingCache.d.ts +1 -0
  23. package/dist/cjs/rendering/caching/RenderingCache.js +4 -0
  24. package/dist/cjs/rendering/caching/RenderingCacheNode.js +19 -11
  25. package/dist/cjs/rendering/caching/types.d.ts +1 -0
  26. package/dist/cjs/rendering/renderers/AbstractRenderer.d.ts +10 -0
  27. package/dist/cjs/rendering/renderers/AbstractRenderer.js +12 -3
  28. package/dist/cjs/rendering/renderers/CanvasRenderer.js +5 -2
  29. package/dist/cjs/rendering/renderers/SVGRenderer.d.ts +0 -10
  30. package/dist/cjs/rendering/renderers/SVGRenderer.js +0 -14
  31. package/dist/cjs/testing/startPinchGesture.d.ts +14 -0
  32. package/dist/cjs/testing/startPinchGesture.js +41 -0
  33. package/dist/cjs/tools/PanZoom.d.ts +4 -0
  34. package/dist/cjs/tools/PanZoom.js +21 -2
  35. package/dist/cjs/version.js +1 -1
  36. package/dist/mjs/EventDispatcher.mjs +2 -1
  37. package/dist/mjs/SVGLoader.d.ts +1 -1
  38. package/dist/mjs/SVGLoader.mjs +11 -2
  39. package/dist/mjs/Viewport.d.ts +2 -0
  40. package/dist/mjs/Viewport.mjs +2 -0
  41. package/dist/mjs/components/AbstractComponent.d.ts +9 -0
  42. package/dist/mjs/components/AbstractComponent.mjs +11 -0
  43. package/dist/mjs/components/Stroke.d.ts +3 -0
  44. package/dist/mjs/components/Stroke.mjs +56 -2
  45. package/dist/mjs/image/EditorImage.d.ts +20 -0
  46. package/dist/mjs/image/EditorImage.mjs +46 -2
  47. package/dist/mjs/rendering/Display.d.ts +9 -0
  48. package/dist/mjs/rendering/Display.mjs +39 -5
  49. package/dist/mjs/rendering/RenderablePathSpec.d.ts +20 -2
  50. package/dist/mjs/rendering/RenderablePathSpec.mjs +71 -9
  51. package/dist/mjs/rendering/caching/CacheRecord.mjs +5 -3
  52. package/dist/mjs/rendering/caching/CacheRecordManager.mjs +3 -4
  53. package/dist/mjs/rendering/caching/RenderingCache.d.ts +1 -0
  54. package/dist/mjs/rendering/caching/RenderingCache.mjs +4 -0
  55. package/dist/mjs/rendering/caching/RenderingCacheNode.mjs +20 -12
  56. package/dist/mjs/rendering/caching/types.d.ts +1 -0
  57. package/dist/mjs/rendering/renderers/AbstractRenderer.d.ts +10 -0
  58. package/dist/mjs/rendering/renderers/AbstractRenderer.mjs +12 -3
  59. package/dist/mjs/rendering/renderers/CanvasRenderer.mjs +5 -2
  60. package/dist/mjs/rendering/renderers/SVGRenderer.d.ts +0 -10
  61. package/dist/mjs/rendering/renderers/SVGRenderer.mjs +0 -14
  62. package/dist/mjs/testing/startPinchGesture.d.ts +14 -0
  63. package/dist/mjs/testing/startPinchGesture.mjs +36 -0
  64. package/dist/mjs/tools/PanZoom.d.ts +4 -0
  65. package/dist/mjs/tools/PanZoom.mjs +21 -2
  66. package/dist/mjs/version.mjs +1 -1
  67. package/package.json +4 -4
  68. package/src/Editor.scss +2 -0
@@ -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.2',
4
+ number: '1.9.0',
5
5
  };
@@ -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
  *
@@ -18,7 +18,7 @@ export declare enum SVGLoaderLoadMethod {
18
18
  export interface SVGLoaderOptions {
19
19
  sanitize?: boolean;
20
20
  disableUnknownObjectWarnings?: boolean;
21
- loadMethod?: 'iframe' | 'domparser';
21
+ loadMethod?: SVGLoaderLoadMethod;
22
22
  }
23
23
  export default class SVGLoader implements ImageLoader {
24
24
  private source;
@@ -6,6 +6,7 @@ import SVGGlobalAttributesObject from './components/SVGGlobalAttributesObject.
6
6
  import TextComponent, { TextTransformMode } from './components/TextComponent.mjs';
7
7
  import UnknownSVGObject from './components/UnknownSVGObject.mjs';
8
8
  import { pathToRenderable } from './rendering/RenderablePathSpec.mjs';
9
+ import { renderedStylesheetId } from './rendering/renderers/SVGRenderer.mjs';
9
10
  // Size of a loaded image if no size is specified.
10
11
  export const defaultSVGViewRect = new Rect2(0, 0, 500, 500);
11
12
  // Key to retrieve unrecognised attributes from an AbstractComponent
@@ -475,7 +476,13 @@ export default class SVGLoader {
475
476
  this.updateSVGAttrs(node);
476
477
  break;
477
478
  case 'style':
478
- await this.addUnknownNode(node);
479
+ // Keeping unnecessary style sheets can cause the browser to keep all
480
+ // SVG elements *referenced* by the style sheet in some browsers.
481
+ //
482
+ // Only keep the style sheet if it won't be discarded on save.
483
+ if (node.getAttribute('id') !== renderedStylesheetId) {
484
+ await this.addUnknownNode(node);
485
+ }
479
486
  break;
480
487
  default:
481
488
  if (!this.disableUnknownObjectWarnings) {
@@ -518,6 +525,7 @@ export default class SVGLoader {
518
525
  this.onDetermineExportRect?.(defaultSVGViewRect);
519
526
  }
520
527
  this.onFinish?.();
528
+ this.onFinish = null;
521
529
  }
522
530
  /**
523
531
  * Create an `SVGLoader` from the content of an SVG image. SVGs are loaded within a sandboxed
@@ -529,7 +537,7 @@ export default class SVGLoader {
529
537
  * @param options - if `true` or `false`, treated as the `sanitize` option -- don't store unknown attributes.
530
538
  */
531
539
  static fromString(text, options = false) {
532
- const domParserLoad = typeof options !== 'boolean' && options?.loadMethod === 'domparser';
540
+ const domParserLoad = typeof options !== 'boolean' && options?.loadMethod === SVGLoaderLoadMethod.DOMParser;
533
541
  const { svgElem, cleanUp } = (() => {
534
542
  // If the user requested an iframe load (the default) try to load with an iframe.
535
543
  // There are some cases (e.g. in a sandboxed iframe) where this doesn't work.
@@ -577,6 +585,7 @@ export default class SVGLoader {
577
585
  const cleanUp = () => {
578
586
  svgElem.remove();
579
587
  sandbox.remove();
588
+ sandbox.src = '';
580
589
  };
581
590
  return { svgElem, cleanUp };
582
591
  }
@@ -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)) {
@@ -123,8 +123,28 @@ export default class EditorImage {
123
123
  */
124
124
  private setExportRectDirectly;
125
125
  private onExportViewportChanged;
126
+ /**
127
+ * @internal
128
+ *
129
+ * Enables debug mode for **all** `EditorImage`s.
130
+ *
131
+ * **Only use for debugging**.
132
+ *
133
+ * @internal
134
+ */
135
+ setDebugMode(newDebugMode: boolean): void;
126
136
  private static SetImportExportRectCommand;
127
137
  }
138
+ /**
139
+ * Determines the first index in `sortedLeaves` that needs to be rendered
140
+ * (based on occlusion -- everything before that index can be skipped and
141
+ * produce a visually-equivalent image).
142
+ *
143
+ * Does nothing if visibleRect is not provided
144
+ *
145
+ * @internal
146
+ */
147
+ export declare const computeFirstIndexToRender: (sortedLeaves: Array<ImageNode>, visibleRect?: Rect2) => number;
128
148
  type TooSmallToRenderCheck = (rect: Rect2) => boolean;
129
149
  /**
130
150
  * Part of the Editor's image. Does not handle fullscreen/invisible components.
@@ -19,7 +19,7 @@ export var EditorImageEventType;
19
19
  EditorImageEventType[EditorImageEventType["ExportViewportChanged"] = 0] = "ExportViewportChanged";
20
20
  EditorImageEventType[EditorImageEventType["AutoresizeModeChanged"] = 1] = "AutoresizeModeChanged";
21
21
  })(EditorImageEventType || (EditorImageEventType = {}));
22
- const debugMode = false;
22
+ let debugMode = false;
23
23
  // Handles lookup/storage of elements in the image
24
24
  class EditorImage {
25
25
  // @internal
@@ -271,6 +271,18 @@ class EditorImage {
271
271
  });
272
272
  }
273
273
  }
274
+ /**
275
+ * @internal
276
+ *
277
+ * Enables debug mode for **all** `EditorImage`s.
278
+ *
279
+ * **Only use for debugging**.
280
+ *
281
+ * @internal
282
+ */
283
+ setDebugMode(newDebugMode) {
284
+ debugMode = newDebugMode;
285
+ }
274
286
  }
275
287
  _a = EditorImage;
276
288
  // A Command that can access private [EditorImage] functionality
@@ -410,6 +422,30 @@ EditorImage.SetImportExportRectCommand = (_c = class extends SerializableCommand
410
422
  })(),
411
423
  _c);
412
424
  export default EditorImage;
425
+ /**
426
+ * Determines the first index in `sortedLeaves` that needs to be rendered
427
+ * (based on occlusion -- everything before that index can be skipped and
428
+ * produce a visually-equivalent image).
429
+ *
430
+ * Does nothing if visibleRect is not provided
431
+ *
432
+ * @internal
433
+ */
434
+ export const computeFirstIndexToRender = (sortedLeaves, visibleRect) => {
435
+ let startIndex = 0;
436
+ if (visibleRect) {
437
+ for (let i = sortedLeaves.length - 1; i >= 1; i--) {
438
+ if (
439
+ // Check for occlusion
440
+ sortedLeaves[i].getBBox().containsRect(visibleRect)
441
+ && sortedLeaves[i].getContent()?.occludesEverythingBelowWhenRenderedInRect(visibleRect)) {
442
+ startIndex = i;
443
+ break;
444
+ }
445
+ }
446
+ }
447
+ return startIndex;
448
+ };
413
449
  /**
414
450
  * Part of the Editor's image. Does not handle fullscreen/invisible components.
415
451
  * @internal
@@ -663,12 +699,20 @@ export class ImageNode {
663
699
  leaves = this.getLeaves();
664
700
  }
665
701
  sortLeavesByZIndex(leaves);
666
- for (const leaf of leaves) {
702
+ // If some components hide others (and we're permitted to simplify,
703
+ // which is true in the case of visibleRect being defined), then only
704
+ // draw the non-hidden components:
705
+ const startIndex = computeFirstIndexToRender(leaves);
706
+ for (let i = startIndex; i < leaves.length; i++) {
707
+ const leaf = leaves[i];
667
708
  // Leaves by definition have content
668
709
  leaf.getContent().render(renderer, visibleRect);
669
710
  }
670
711
  // Show debug information
671
712
  if (debugMode && visibleRect) {
713
+ if (startIndex !== 0) {
714
+ console.log('EditorImage: skipped ', startIndex, 'nodes due to occlusion');
715
+ }
672
716
  this.renderDebugBoundingBoxes(renderer, visibleRect);
673
717
  }
674
718
  }
@@ -26,6 +26,7 @@ export default class Display {
26
26
  private textRenderer;
27
27
  private textRerenderOutput;
28
28
  private cache;
29
+ private devicePixelRatio;
29
30
  private resizeSurfacesCallback?;
30
31
  private flattenCallback?;
31
32
  /** @internal */
@@ -46,6 +47,14 @@ export default class Display {
46
47
  getColorAt: (_screenPos: Point2) => Color4 | null;
47
48
  private initializeCanvasRendering;
48
49
  private initializeTextRendering;
50
+ /**
51
+ * Sets the device-pixel-ratio.
52
+ *
53
+ * Intended for debugging. Users do not need to call this manually.
54
+ *
55
+ * @internal
56
+ */
57
+ setDevicePixelRatio(dpr: number): Promise<void> | undefined;
49
58
  /**
50
59
  * Rerenders the text-based display.
51
60
  * The text-based display is intended for screen readers and can be navigated to by pressing `tab`.
@@ -28,6 +28,7 @@ export default class Display {
28
28
  this.editor = editor;
29
29
  this.parent = parent;
30
30
  this.textRerenderOutput = null;
31
+ this.devicePixelRatio = window.devicePixelRatio ?? 1;
31
32
  /**
32
33
  * @returns the color at the given point on the dry ink renderer, or `null` if `screenPos`
33
34
  * is not on the display.
@@ -112,16 +113,29 @@ export default class Display {
112
113
  this.parent.appendChild(wetInkCanvas);
113
114
  }
114
115
  this.resizeSurfacesCallback = () => {
116
+ const expectedWidth = (canvas) => {
117
+ return Math.ceil(canvas.clientWidth * this.devicePixelRatio);
118
+ };
119
+ const expectedHeight = (canvas) => {
120
+ return Math.ceil(canvas.clientHeight * this.devicePixelRatio);
121
+ };
115
122
  const hasSizeMismatch = (canvas) => {
116
- return canvas.clientHeight !== canvas.height || canvas.clientWidth !== canvas.width;
123
+ return expectedHeight(canvas) !== canvas.height || expectedWidth(canvas) !== canvas.width;
117
124
  };
118
125
  // Ensure that the drawing surfaces sizes match the
119
126
  // canvas' sizes to prevent stretching.
120
127
  if (hasSizeMismatch(dryInkCanvas) || hasSizeMismatch(wetInkCanvas)) {
121
- dryInkCanvas.width = dryInkCanvas.clientWidth;
122
- dryInkCanvas.height = dryInkCanvas.clientHeight;
123
- wetInkCanvas.width = wetInkCanvas.clientWidth;
124
- wetInkCanvas.height = wetInkCanvas.clientHeight;
128
+ dryInkCanvas.width = expectedWidth(dryInkCanvas);
129
+ dryInkCanvas.height = expectedHeight(dryInkCanvas);
130
+ wetInkCanvas.width = expectedWidth(wetInkCanvas);
131
+ wetInkCanvas.height = expectedHeight(wetInkCanvas);
132
+ // Ensure correct drawing operations on high-resolution screens.
133
+ // See
134
+ // https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas#scaling_for_high_resolution_displays
135
+ wetInkCtx.resetTransform();
136
+ dryInkCtx.resetTransform();
137
+ dryInkCtx.scale(this.devicePixelRatio, this.devicePixelRatio);
138
+ wetInkCtx.scale(this.devicePixelRatio, this.devicePixelRatio);
125
139
  this.editor.notifier.dispatch(EditorEventType.DisplayResized, {
126
140
  kind: EditorEventType.DisplayResized,
127
141
  newSize: Vec2.of(this.width, this.height),
@@ -130,7 +144,10 @@ export default class Display {
130
144
  };
131
145
  this.resizeSurfacesCallback();
132
146
  this.flattenCallback = () => {
147
+ dryInkCtx.save();
148
+ dryInkCtx.resetTransform();
133
149
  dryInkCtx.drawImage(wetInkCanvas, 0, 0);
150
+ dryInkCtx.restore();
134
151
  };
135
152
  this.getColorAt = (screenPos) => {
136
153
  const pixel = dryInkCtx.getImageData(screenPos.x, screenPos.y, 1, 1);
@@ -156,6 +173,23 @@ export default class Display {
156
173
  textRendererOutputContainer.replaceChildren(rerenderButton, this.textRerenderOutput);
157
174
  this.editor.createHTMLOverlay(textRendererOutputContainer);
158
175
  }
176
+ /**
177
+ * Sets the device-pixel-ratio.
178
+ *
179
+ * Intended for debugging. Users do not need to call this manually.
180
+ *
181
+ * @internal
182
+ */
183
+ setDevicePixelRatio(dpr) {
184
+ const minDpr = 0.001;
185
+ const maxDpr = 10;
186
+ if (isFinite(dpr) && dpr >= minDpr && dpr <= maxDpr && dpr !== this.devicePixelRatio) {
187
+ this.devicePixelRatio = dpr;
188
+ this.resizeSurfacesCallback?.();
189
+ return this.editor.queueRerender();
190
+ }
191
+ return undefined;
192
+ }
159
193
  /**
160
194
  * Rerenders the text-based display.
161
195
  * The text-based display is intended for screen readers and can be navigated to by pressing `tab`.
@@ -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
  }