@mocanvas/editor 4.3.0 → 4.4.1

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.
package/dist/index.d.ts CHANGED
@@ -943,6 +943,24 @@ declare abstract class Geometry2d {
943
943
  ignoreHit(_point: VecLike): boolean;
944
944
  nearestPoint(point: VecLike, _filters?: Geometry2dFilters): Vec;
945
945
  distanceToPoint(point: VecLike, hitInside?: boolean, filters?: Geometry2dFilters): number;
946
+ /**
947
+ * Whether `point` hits this geometry, within `margin`.
948
+ *
949
+ * The interior counts when the shape is **filled**, or when the caller asks
950
+ * for `hitInside` — the two are alternatives, not a requirement and a
951
+ * refinement. `hitInside` means "count the interior of a HOLLOW shape too",
952
+ * which is what a marquee or a drop target wants; it is not the only way the
953
+ * interior is ever reachable.
954
+ *
955
+ * This used to test `hitInside` alone, so a solid rectangle could not be
956
+ * selected by clicking its middle — only its outline answered. Nothing failed
957
+ * and nothing warned: every feature built on hit testing simply returned
958
+ * nothing, which reads as several unrelated bugs rather than one.
959
+ *
960
+ * An unfilled shape still misses in the middle. A hollow rectangle is a frame
961
+ * around empty space, and clicking the space inside it should reach whatever
962
+ * is behind.
963
+ */
946
964
  hitTestPoint(point: VecLike, margin?: number, hitInside?: boolean, _filters?: Geometry2dFilters): boolean;
947
965
  /** Whether `point` lies within the bounding box, which is the cheap pre-test. */
948
966
  isPointInBounds(point: VecLike, margin?: number): boolean;
@@ -8273,10 +8291,6 @@ declare const COLLABORATOR_INACTIVE_TIMEOUT = 60000;
8273
8291
  * all in a raw-ESM browser page, hence the guard rather than a bare read.
8274
8292
  */
8275
8293
  declare function warnOnce(key: string, message: string): void;
8276
- /**
8277
- * The editor: document access, selection, camera, tool dispatch, and the
8278
- * bridge that mirrors the current page into the WASM engine.
8279
- */
8280
8294
  declare class Editor extends EventEmitter<EditorEvents> {
8281
8295
  readonly store: EditorStore;
8282
8296
  readonly engine: EngineBridge;
@@ -8725,7 +8739,16 @@ declare class Editor extends EventEmitter<EditorEvents> {
8725
8739
  getSharedStyles(): SharedStyleMap;
8726
8740
  /** Move shapes by a page-space offset. */
8727
8741
  nudgeShapes(ids: readonly ShapeId[], offset: VecLike): this;
8728
- /** Rotate shapes by `delta` radians around the center of their common page bounds. */
8742
+ /**
8743
+ * Rotate shapes by `delta` radians around the center of their common page
8744
+ * bounds.
8745
+ *
8746
+ * The resulting rotation is canonicalized into `[0, 2π)`. Without that, an
8747
+ * angle that only ever accumulates leaves two documents that look identical
8748
+ * carrying different numbers — and `rotation` is persisted and sent over the
8749
+ * wire, so a diff, a parity check and a sync merge all see a difference that
8750
+ * is not on screen.
8751
+ */
8729
8752
  rotateShapesBy(ids: readonly ShapeId[], delta: number, center?: VecLike): this;
8730
8753
  /** Mirror shapes across the center of their common bounds. Positions flip; geometry is not mirrored. */
8731
8754
  flipShapes(ids: readonly ShapeId[], operation: "horizontal" | "vertical"): this;
package/dist/index.js CHANGED
@@ -1696,8 +1696,26 @@ var Geometry2d = class {
1696
1696
  if (hitInside && this.isClosed && this.isFilled && pointInPolygon(point, this.vertices)) return -d;
1697
1697
  return d;
1698
1698
  }
1699
+ /**
1700
+ * Whether `point` hits this geometry, within `margin`.
1701
+ *
1702
+ * The interior counts when the shape is **filled**, or when the caller asks
1703
+ * for `hitInside` — the two are alternatives, not a requirement and a
1704
+ * refinement. `hitInside` means "count the interior of a HOLLOW shape too",
1705
+ * which is what a marquee or a drop target wants; it is not the only way the
1706
+ * interior is ever reachable.
1707
+ *
1708
+ * This used to test `hitInside` alone, so a solid rectangle could not be
1709
+ * selected by clicking its middle — only its outline answered. Nothing failed
1710
+ * and nothing warned: every feature built on hit testing simply returned
1711
+ * nothing, which reads as several unrelated bugs rather than one.
1712
+ *
1713
+ * An unfilled shape still misses in the middle. A hollow rectangle is a frame
1714
+ * around empty space, and clicking the space inside it should reach whatever
1715
+ * is behind.
1716
+ */
1699
1717
  hitTestPoint(point, margin = 0, hitInside = false, _filters) {
1700
- if (hitInside && this.isClosed && pointInPolygon(point, this.vertices)) return true;
1718
+ if ((hitInside || this.isFilled) && this.isClosed && pointInPolygon(point, this.vertices)) return true;
1701
1719
  return Vec.Dist(point, this.nearestPoint(point)) <= margin;
1702
1720
  }
1703
1721
  /** Whether `point` lies within the bounding box, which is the cheap pre-test. */
@@ -1886,7 +1904,7 @@ var TransformedGeometry2d = class extends Geometry2d {
1886
1904
  return d;
1887
1905
  }
1888
1906
  hitTestPoint(point, margin = 0, hitInside = false, filters) {
1889
- if (hitInside && this.isClosed && pointInPolygon(point, this.vertices)) return true;
1907
+ if ((hitInside || this.isFilled) && this.isClosed && pointInPolygon(point, this.vertices)) return true;
1890
1908
  return Vec.Dist(point, this.nearestPoint(point, filters)) <= margin;
1891
1909
  }
1892
1910
  ignoreHit(point) {
@@ -7834,6 +7852,12 @@ function warnOnce(key, message) {
7834
7852
  warnedKeys.add(key);
7835
7853
  console.warn(message);
7836
7854
  }
7855
+ function assertUtilMapPresent(map, name) {
7856
+ if (map) return;
7857
+ throw new Error(
7858
+ `editor.${name} is undefined, which the constructor never leaves it. The usual cause is a subclass redeclaring \`${name}\` without an initializer: at ES2022 that DEFINES the field as undefined after the base constructor ran. Write \`declare readonly ${name}: ...\` instead, or drop the redeclaration.`
7859
+ );
7860
+ }
7837
7861
  var Editor = class extends EventEmitter {
7838
7862
  store;
7839
7863
  engine;
@@ -8415,6 +8439,7 @@ var Editor = class extends EventEmitter {
8415
8439
  */
8416
8440
  getShapeUtil(shape) {
8417
8441
  const type = typeof shape === "string" ? shape : shape.type;
8442
+ assertUtilMapPresent(this.shapeUtils, "shapeUtils");
8418
8443
  const util = this.shapeUtils[type];
8419
8444
  if (!util) throw new Error(`No ShapeUtil registered for type "${type}"`);
8420
8445
  return util;
@@ -8677,29 +8702,34 @@ var Editor = class extends EventEmitter {
8677
8702
  getShapeAtPoint(point, opts = {}) {
8678
8703
  this.flushEngine();
8679
8704
  const margin = (opts.margin ?? this.getHitTestMargin()) / this.getZoomLevel();
8680
- const bits = this.hitFilterBits(opts) | (opts.hitInside ? 0 : 4);
8681
8705
  const filter = this.hitFilter(opts);
8682
- if (!filter) {
8683
- const h = this.engine.hitTest(point.x, point.y, margin, bits);
8706
+ if (!filter && !opts.hitInside) {
8707
+ const h = this.engine.hitTest(point.x, point.y, margin, this.hitFilterBits(opts));
8684
8708
  const id = this.handles.id(h);
8685
8709
  return id ? this.getShape(id) : void 0;
8686
8710
  }
8687
- for (const shape of this.getShapesAtPoint(point, opts)) {
8688
- if (filter(shape)) return shape;
8689
- }
8690
- return void 0;
8711
+ return this.getShapesAtPoint(point, opts)[0];
8691
8712
  }
8692
8713
  /** Shapes under a point, topmost first. */
8693
8714
  getShapesAtPoint(point, opts = {}) {
8694
8715
  this.flushEngine();
8695
8716
  const margin = (opts.margin ?? this.getHitTestMargin()) / this.getZoomLevel();
8696
- const handles = this.engine.queryBox(point.x - margin, point.y - margin, point.x + margin, point.y + margin, 0, this.hitFilterBits(opts));
8697
8717
  const filter = this.hitFilter(opts);
8698
8718
  const out = [];
8699
- for (let i = handles.length - 1; i >= 0; i--) {
8700
- const id = this.handles.id(handles[i]);
8701
- const shape = id ? this.getShape(id) : void 0;
8702
- if (!shape) continue;
8719
+ let candidates;
8720
+ if (opts.hitInside) {
8721
+ candidates = this.getCurrentPageShapesSorted().filter((s) => this.getShapePageBounds(s)?.containsPoint(point, margin));
8722
+ candidates.reverse();
8723
+ } else {
8724
+ const handles = this.engine.queryBox(point.x - margin, point.y - margin, point.x + margin, point.y + margin, 0, this.hitFilterBits(opts));
8725
+ candidates = [];
8726
+ for (let i = handles.length - 1; i >= 0; i--) {
8727
+ const id = this.handles.id(handles[i]);
8728
+ const shape = id ? this.getShape(id) : void 0;
8729
+ if (shape) candidates.push(shape);
8730
+ }
8731
+ }
8732
+ for (const shape of candidates) {
8703
8733
  if (filter && !filter(shape)) continue;
8704
8734
  const local = this.getPointInShapeSpace(shape, point);
8705
8735
  const geo = this.getShapeGeometry(shape);
@@ -8886,6 +8916,7 @@ var Editor = class extends EventEmitter {
8886
8916
  );
8887
8917
  getBindingUtil(binding) {
8888
8918
  const type = typeof binding === "string" ? binding : binding.type;
8919
+ assertUtilMapPresent(this.bindingUtils, "bindingUtils");
8889
8920
  const util = this.bindingUtils[type];
8890
8921
  if (!util) throw new Error(`No BindingUtil registered for type "${type}"`);
8891
8922
  return util;
@@ -9203,7 +9234,16 @@ var Editor = class extends EventEmitter {
9203
9234
  }
9204
9235
  return this.updateShapes(updates);
9205
9236
  }
9206
- /** Rotate shapes by `delta` radians around the center of their common page bounds. */
9237
+ /**
9238
+ * Rotate shapes by `delta` radians around the center of their common page
9239
+ * bounds.
9240
+ *
9241
+ * The resulting rotation is canonicalized into `[0, 2π)`. Without that, an
9242
+ * angle that only ever accumulates leaves two documents that look identical
9243
+ * carrying different numbers — and `rotation` is persisted and sent over the
9244
+ * wire, so a diff, a parity check and a sync merge all see a difference that
9245
+ * is not on screen.
9246
+ */
9207
9247
  rotateShapesBy(ids, delta, center) {
9208
9248
  const shapes = ids.map((id) => this.getShape(id)).filter((s) => !!s && !s.isLocked);
9209
9249
  if (shapes.length === 0) return this;
@@ -9214,7 +9254,7 @@ var Editor = class extends EventEmitter {
9214
9254
  const m = this.getShapePageTransform(shape);
9215
9255
  const newPagePos = Vec.RotWith(new Vec(m.e, m.f), c, delta);
9216
9256
  const parentPoint = this.getPointInParentSpace(shape, newPagePos);
9217
- updates.push({ id: shape.id, type: shape.type, x: parentPoint.x, y: parentPoint.y, rotation: shape.rotation + delta });
9257
+ updates.push({ id: shape.id, type: shape.type, x: parentPoint.x, y: parentPoint.y, rotation: canonicalizeRotation(shape.rotation + delta) });
9218
9258
  }
9219
9259
  return this.updateShapes(updates);
9220
9260
  }