@mocanvas/editor 4.13.0 → 4.14.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.
package/ARCHITECTURE.md CHANGED
@@ -233,9 +233,13 @@ engine.selection_bounds(ptr_handles, len) -> ptr to 4 f32
233
233
  level-of-detail quads until a later frame reaches them.
234
234
  4. Append transformed vertices into the frame vertex buffer, batching by
235
235
  texture. Shapes flagged `OVERLAY` skip the mesh and go into the overlay list.
236
- 5. TS uploads the buffers (unless the engine reports them unchanged) and draws. Overlay list is diffed against the React
237
- overlay layer, which positions DOM shapes with `transform` and passes
238
- pointer events through except when editing.
236
+ 5. TS uploads the buffers (unless the engine reports them unchanged) and draws. The overlay list
237
+ gains the shapes that left the viewport recently (a bounded LRU,
238
+ `options.overlayRetainLimit`) and is diffed against the React overlay layer,
239
+ which positions DOM shapes with `transform`, hides the retained ones with
240
+ `content-visibility: hidden`, and passes pointer events through except when
241
+ editing. The camera is a transform on the layer, written by a signal
242
+ reaction: a pan or zoom frame is no React render at all.
239
243
 
240
244
  Stroke widths are in page units and scale with zoom, so meshes never need
241
245
  retessellation while zooming. Dash patterns (`dash` style word: 0 solid,
package/COMPAT.md CHANGED
@@ -98,7 +98,10 @@ work is**, not in the symbol tables above.
98
98
 
99
99
  `canCull` is absent from the class but honoured where it matters: a util that
100
100
  implements it and returns `false` keeps its shape in the DOM overlay while the
101
- shape is off screen (4.12.4), and out of `getCulledShapes()`.
101
+ shape is off screen (4.12.4), and out of `getCulledShapes()`. Every other overlay
102
+ shape that leaves the viewport stays mounted but hidden until
103
+ `options.overlayRetainLimit` newer ones displace it; the option is mocanvas's
104
+ own.
102
105
 
103
106
  You can measure a real application against this surface rather than trusting the
104
107
  tables: `scripts/compat-parity.mjs` type-checks a consumer app with `tldraw`
package/CUSTOM_SHAPES.md CHANGED
@@ -197,6 +197,21 @@ geometry bounds, with `pointerEvents: "none"` unless the shape is being edited.
197
197
  Render in shape-local space starting at `(0, 0)`; do not apply the camera or
198
198
  the shape transform yourself.
199
199
 
200
+ **When it re-renders.** The body is a function of the shape's `props` and
201
+ `meta`: it re-renders when either changes, and when a signal it read during its
202
+ last render changes (`useValue`, `editor.get*`). Moving, rotating or fading the
203
+ shape changes only the wrapper's transform and opacity, so a body that reads
204
+ `shape.x`, `shape.y`, `shape.rotation` or `shape.opacity` sees the value from its
205
+ last render. Read the position through a signal if you need it.
206
+ A pan or zoom re-renders nothing: the camera is a transform on the overlay layer.
207
+
208
+ **Off screen.** A body is mounted while its shape is on screen. When the shape
209
+ leaves the viewport it stays mounted, hidden with `content-visibility: hidden`,
210
+ until `options.overlayRetainLimit` (default 256) more recently culled shapes
211
+ push it out; coming back on screen then costs a style change, not a remount.
212
+ `0` unmounts at the viewport edge. A util whose `canCull()` returns `false` is
213
+ never unmounted and never hidden for being off screen.
214
+
200
215
  ### `getIndicatorPath` — the selection outline
201
216
 
202
217
  ```ts
package/dist/index.d.ts CHANGED
@@ -8313,6 +8313,24 @@ interface EditorConfig extends TldrawOptions {
8313
8313
  * enough to debounce at all, and how much movement is worth publishing.
8314
8314
  */
8315
8315
  debouncedZoomOctaves: number;
8316
+ /**
8317
+ * How many DOM-overlay shapes that left the viewport stay mounted, hidden, so
8318
+ * coming back into view costs a style change instead of a React remount.
8319
+ *
8320
+ * The engine culls the overlay to the viewport exactly, so without this every
8321
+ * card that crosses the viewport edge during a pan or a zoom is unmounted and
8322
+ * rebuilt from nothing when the gesture comes back — on a board of heavy React
8323
+ * cards, dozens of remounts per gesture. Retained shapes are the most recently
8324
+ * culled ones (least recently visible are evicted first), they are listed in
8325
+ * {@link Editor.getOverlayRetainedShapeIds}, and the canvas hides them with
8326
+ * `content-visibility: hidden`, which keeps their DOM and layout but skips
8327
+ * their rendering.
8328
+ *
8329
+ * The bound is the memory cost: up to this many shapes' DOM is kept alive off
8330
+ * screen. `0` turns retention off and unmounts at the edge, as every release before
8331
+ * this option did.
8332
+ */
8333
+ overlayRetainLimit: number;
8316
8334
  }
8317
8335
  declare const DEFAULT_EDITOR_CONFIG: EditorConfig;
8318
8336
  interface HitTestOptions {
@@ -8431,6 +8449,15 @@ declare class Editor extends EventEmitter<EditorEvents> {
8431
8449
  private readonly _frameEpoch;
8432
8450
  private readonly _overlayShapeIds;
8433
8451
  private readonly _overlayClips;
8452
+ private readonly _overlayRetained;
8453
+ /**
8454
+ * Overlay shapes the engine culled that are still mounted, oldest first: a
8455
+ * `Map` iterates in insertion order, which is the LRU order eviction wants.
8456
+ * Each carries the clip it had when it was last on screen.
8457
+ */
8458
+ private readonly overlayRetained;
8459
+ /** Last frame's on-screen overlay shapes (engine list plus kept ones), with their clips. */
8460
+ private overlayOnScreen;
8434
8461
  private readonly _isDisposed;
8435
8462
  private readonly _lastFrame;
8436
8463
  private readonly _cameraOptions;
@@ -8515,8 +8542,18 @@ declare class Editor extends EventEmitter<EditorEvents> {
8515
8542
  culled: number;
8516
8543
  ms: number;
8517
8544
  };
8518
- /** Shapes the DOM overlay must render this frame, in draw order. */
8545
+ /**
8546
+ * Shapes the DOM overlay must keep mounted this frame, in draw order: the ones
8547
+ * on screen, plus those listed by {@link getOverlayRetainedShapeIds}, which are
8548
+ * off screen and mounted only so that coming back does not rebuild them.
8549
+ */
8519
8550
  getOverlayShapeIds(): readonly ShapeId[];
8551
+ /**
8552
+ * The subset of {@link getOverlayShapeIds} that the engine culled and the
8553
+ * overlay keeps mounted but hidden — see `options.overlayRetainLimit`. Empty
8554
+ * when nothing is retained.
8555
+ */
8556
+ getOverlayRetainedShapeIds(): ReadonlySet<ShapeId>;
8520
8557
  /**
8521
8558
  * Page-space clip rects for the overlay shapes, parallel to
8522
8559
  * `getOverlayShapeIds()`; `undefined` where the shape is unclipped.
@@ -9088,6 +9125,27 @@ declare class Editor extends EventEmitter<EditorEvents> {
9088
9125
  * ancestors give it, on screen or off, so its DOM never changes shape at the viewport edge.
9089
9126
  */
9090
9127
  private keepUncullableOverlay;
9128
+ /**
9129
+ * Keep the overlay shapes that just left the viewport mounted, up to
9130
+ * `options.overlayRetainLimit`, and put the whole list in paint order.
9131
+ *
9132
+ * A shape that the engine stops reporting moves into a bounded LRU; one that
9133
+ * comes back moves out of it. Either way its entry in the list — and so its
9134
+ * React element and DOM — is untouched; only its retained flag flips. The list
9135
+ * is sorted by paint rank whenever anything is retained, so a shape crossing
9136
+ * the viewport edge never changes position among its siblings either: React
9137
+ * would move its DOM for that, and moving a node with an iframe in it reloads
9138
+ * the iframe.
9139
+ *
9140
+ * Returns the retained ids, which the canvas hides.
9141
+ */
9142
+ private retainCulledOverlay;
9143
+ /**
9144
+ * Every shape on the current page by paint order. Recomputed when a shape
9145
+ * record changes — not when the camera moves, which is when the overlay
9146
+ * needs it.
9147
+ */
9148
+ private readonly _paintRank;
9091
9149
  /**
9092
9150
  * Device-pixel scale at which rasterized textures (text labels) should be
9093
9151
  * drawn: the device pixel ratio times the zoom, bucketed to powers of two.
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
- import { atom, computed, unsafe__withoutCapture, transact, react } from '@mocanvas/state';
1
+ import { atom, react, computed, unsafe__withoutCapture, transact } from '@mocanvas/state';
2
2
  export { AtomMap, atom, computed, react, reactor, transact, transaction } from '@mocanvas/state';
3
3
  import { createRecordType, isIndexKey, createMigrationIds, uniqueId, isRecordsDiffEmpty, squashRecordDiffs, reverseRecordsDiff, sortByIndex, getIndexAbove, ZERO_INDEX_KEY, getIndexBetween, getIndexBelow, getIndicesAbove, indexKeyToZKey, parseMigrationId, createMigrationSequence, StoreSchema, Store } from '@mocanvas/store';
4
4
  export { ZERO_INDEX_KEY, createComputedCache, getGraphemeLength, getGraphemes, getIndexAbove, getIndexBelow, getIndexBetween, getIndices, getIndicesAbove, getIndicesBelow, getIndicesBetween, iterateGraphemes, sortByIndex } from '@mocanvas/store';
5
5
  import { PATH_OP, FLAG, GEO_FLAG, EngineBridge, VERTEX_FLOATS, BATCH_WORDS, readClip } from '@mocanvas/wasm';
6
6
  export { EngineBridge, FLAG, GEO_FLAG, GEO_KIND, PATH_OP, getLoadedEngine, loadEngine, loadEngineSync } from '@mocanvas/wasm';
7
- import { createContext, useEffect, useRef, useLayoutEffect, useContext, Component, useMemo, useId, useCallback, useState } from 'react';
7
+ import { createContext, useEffect, useRef, useLayoutEffect, memo, useContext, Component, useMemo, useId, useCallback, useState } from 'react';
8
8
  import { track as track$1, useValue } from '@mocanvas/state/react';
9
9
  export { track, useAtom, useComputed, useQuickReactor, useReactor, useStateTracking, useValue } from '@mocanvas/state/react';
10
10
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
@@ -7849,7 +7849,11 @@ var DEFAULT_EDITOR_CONFIG = {
7849
7849
  backgroundColor: [0.976, 0.98, 0.984, 1],
7850
7850
  // SEMANTICS-ASSUMED: half an octave is the smallest zoom change a rasteriser
7851
7851
  // should care about.
7852
- debouncedZoomOctaves: 0.5
7852
+ debouncedZoomOctaves: 0.5,
7853
+ // SEMANTICS-ASSUMED: a few screens' worth of cards on a dense board. Measured
7854
+ // in apps/bench (`dom-overlay.mjs`): a pan or zoom there and back stops
7855
+ // remounting at this size; the cost is the DOM of this many off-screen shapes.
7856
+ overlayRetainLimit: 256
7853
7857
  };
7854
7858
  var RAD_PER_DEG = Math.PI / 180;
7855
7859
  var WHEEL_ZOOM_DELTA_CAP = 50;
@@ -7949,6 +7953,15 @@ var Editor = class extends EventEmitter {
7949
7953
  _frameEpoch;
7950
7954
  _overlayShapeIds;
7951
7955
  _overlayClips;
7956
+ _overlayRetained;
7957
+ /**
7958
+ * Overlay shapes the engine culled that are still mounted, oldest first: a
7959
+ * `Map` iterates in insertion order, which is the LRU order eviction wants.
7960
+ * Each carries the clip it had when it was last on screen.
7961
+ */
7962
+ overlayRetained = /* @__PURE__ */ new Map();
7963
+ /** Last frame's on-screen overlay shapes (engine list plus kept ones), with their clips. */
7964
+ overlayOnScreen = /* @__PURE__ */ new Map();
7952
7965
  _isDisposed;
7953
7966
  _lastFrame;
7954
7967
  _cameraOptions;
@@ -8015,6 +8028,7 @@ var Editor = class extends EventEmitter {
8015
8028
  });
8016
8029
  this._overlayShapeIds = atom("editor.overlayShapeIds", []);
8017
8030
  this._overlayClips = atom("editor.overlayClips", []);
8031
+ this._overlayRetained = atom("editor.overlayRetained", EMPTY_ID_SET);
8018
8032
  this._isDisposed = atom("editor.isDisposed", false);
8019
8033
  this._isMounted = atom("editor.isMounted", false);
8020
8034
  this._lastFrame = atom("editor.lastFrame", { drawn: 0, culled: 0, ms: 0 });
@@ -8209,10 +8223,22 @@ var Editor = class extends EventEmitter {
8209
8223
  getLastFrameStats() {
8210
8224
  return this._lastFrame.get();
8211
8225
  }
8212
- /** Shapes the DOM overlay must render this frame, in draw order. */
8226
+ /**
8227
+ * Shapes the DOM overlay must keep mounted this frame, in draw order: the ones
8228
+ * on screen, plus those listed by {@link getOverlayRetainedShapeIds}, which are
8229
+ * off screen and mounted only so that coming back does not rebuild them.
8230
+ */
8213
8231
  getOverlayShapeIds() {
8214
8232
  return this._overlayShapeIds.get();
8215
8233
  }
8234
+ /**
8235
+ * The subset of {@link getOverlayShapeIds} that the engine culled and the
8236
+ * overlay keeps mounted but hidden — see `options.overlayRetainLimit`. Empty
8237
+ * when nothing is retained.
8238
+ */
8239
+ getOverlayRetainedShapeIds() {
8240
+ return this._overlayRetained.get();
8241
+ }
8216
8242
  /**
8217
8243
  * Page-space clip rects for the overlay shapes, parallel to
8218
8244
  * `getOverlayShapeIds()`; `undefined` where the shape is unclipped.
@@ -10350,10 +10376,12 @@ var Editor = class extends EventEmitter {
10350
10376
  }
10351
10377
  }
10352
10378
  unsafe__withoutCapture(() => this.keepUncullableOverlay(ids, clips));
10379
+ const retained = unsafe__withoutCapture(() => this.retainCulledOverlay(ids, clips));
10353
10380
  unsafe__withoutCapture(() => {
10354
10381
  const prev = this._overlayShapeIds.get();
10355
10382
  if (prev.length !== ids.length || prev.some((id, i) => id !== ids[i])) this._overlayShapeIds.set(ids);
10356
10383
  if (!sameClips(this._overlayClips.get(), clips)) this._overlayClips.set(clips);
10384
+ if (!sameIdSet(this._overlayRetained.get(), retained)) this._overlayRetained.set(retained);
10357
10385
  const ms = performance.now() - t0;
10358
10386
  this._lastFrame.set({ drawn: frame.drawn, culled: frame.culled, ms });
10359
10387
  this.emit("frame", { drawn: frame.drawn, culled: frame.culled, ms });
@@ -10383,6 +10411,69 @@ var Editor = class extends EventEmitter {
10383
10411
  clips.push(entry.clip);
10384
10412
  }
10385
10413
  }
10414
+ /**
10415
+ * Keep the overlay shapes that just left the viewport mounted, up to
10416
+ * `options.overlayRetainLimit`, and put the whole list in paint order.
10417
+ *
10418
+ * A shape that the engine stops reporting moves into a bounded LRU; one that
10419
+ * comes back moves out of it. Either way its entry in the list — and so its
10420
+ * React element and DOM — is untouched; only its retained flag flips. The list
10421
+ * is sorted by paint rank whenever anything is retained, so a shape crossing
10422
+ * the viewport edge never changes position among its siblings either: React
10423
+ * would move its DOM for that, and moving a node with an iframe in it reloads
10424
+ * the iframe.
10425
+ *
10426
+ * Returns the retained ids, which the canvas hides.
10427
+ */
10428
+ retainCulledOverlay(ids, clips) {
10429
+ const limit = Math.max(0, Math.floor(this.options.overlayRetainLimit));
10430
+ const lastOnScreen = this.overlayOnScreen;
10431
+ const onScreen = /* @__PURE__ */ new Map();
10432
+ for (let i = 0; i < ids.length; i++) onScreen.set(ids[i], clips[i]);
10433
+ this.overlayOnScreen = onScreen;
10434
+ const retainedMap = this.overlayRetained;
10435
+ if (limit === 0) {
10436
+ retainedMap.clear();
10437
+ return EMPTY_ID_SET;
10438
+ }
10439
+ for (const id of onScreen.keys()) retainedMap.delete(id);
10440
+ for (const [id, clip] of lastOnScreen) {
10441
+ if (onScreen.has(id)) continue;
10442
+ retainedMap.delete(id);
10443
+ retainedMap.set(id, clip);
10444
+ }
10445
+ if (retainedMap.size === 0) return EMPTY_ID_SET;
10446
+ const pageId = this.getCurrentPageId();
10447
+ for (const id of [...retainedMap.keys()]) {
10448
+ const shape = this.getShape(id);
10449
+ if (!shape || !this.overlayFlaggedIds.has(id) || this.getAncestorPageId(shape) !== pageId || isShapeHidden(this, shape)) {
10450
+ retainedMap.delete(id);
10451
+ }
10452
+ }
10453
+ while (retainedMap.size > limit) retainedMap.delete(retainedMap.keys().next().value);
10454
+ if (retainedMap.size === 0) return EMPTY_ID_SET;
10455
+ const rank = this._paintRank.get();
10456
+ const entries = [];
10457
+ for (const [id, clip] of onScreen) entries.push({ id, clip });
10458
+ for (const [id, clip] of retainedMap) entries.push({ id, clip });
10459
+ const rankOf = (id) => rank.get(id) ?? Number.MAX_SAFE_INTEGER;
10460
+ entries.sort((a, b) => rankOf(a.id) - rankOf(b.id));
10461
+ ids.length = 0;
10462
+ clips.length = 0;
10463
+ for (const entry of entries) {
10464
+ ids.push(entry.id);
10465
+ clips.push(entry.clip);
10466
+ }
10467
+ return new Set(retainedMap.keys());
10468
+ }
10469
+ /**
10470
+ * Every shape on the current page by paint order. Recomputed when a shape
10471
+ * record changes — not when the camera moves, which is when the overlay
10472
+ * needs it.
10473
+ */
10474
+ _paintRank = computed("editor.paintRank", () => {
10475
+ return new Map(this.getCurrentPageShapesSorted().map((shape, index) => [shape.id, index]));
10476
+ });
10386
10477
  // ---- textures ----------------------------------------------------------
10387
10478
  /**
10388
10479
  * Device-pixel scale at which rasterized textures (text labels) should be
@@ -11622,6 +11713,13 @@ var Editor = class extends EventEmitter {
11622
11713
  /** Which menus are open right now. Backed by the `instance` session record. */
11623
11714
  menus = new MenuManager(this);
11624
11715
  };
11716
+ var EMPTY_ID_SET = /* @__PURE__ */ new Set();
11717
+ function sameIdSet(a, b) {
11718
+ if (a === b) return true;
11719
+ if (a.size !== b.size) return false;
11720
+ for (const id of a) if (!b.has(id)) return false;
11721
+ return true;
11722
+ }
11625
11723
  function sameClips(a, b) {
11626
11724
  if (a.length !== b.length) return false;
11627
11725
  for (let i = 0; i < a.length; i++) {
@@ -15174,10 +15272,8 @@ function CanvasBody({ editor, className, style, children, components, indicatorO
15174
15272
  const cursor = useValue("cursor", () => cssCursor(editor.getInstanceState().cursor.type), [editor]);
15175
15273
  const hasChromeContextMenu = useEditorComponents().ContextMenu != null;
15176
15274
  const themeVars = useThemeCssVars(editor);
15177
- const cameraVars = useCameraCssVars(editor);
15275
+ useCameraCssVarsOnElement(editor, containerRef, style);
15178
15276
  const isGridMode = useValue("isGridMode", () => editor.getInstanceState().isGridMode, [editor]);
15179
- const camera = useValue("camera", () => editor.getCamera(), [editor]);
15180
- const gridSize = useValue("gridSize", () => editor.getDocumentSettings().gridSize, [editor]);
15181
15277
  const Grid = components?.Grid === void 0 ? DefaultGrid : components.Grid;
15182
15278
  const Indicators = components?.Indicators ?? DefaultIndicators;
15183
15279
  const Brush = components?.Brush ?? (hasOverlay(editor, "brush") ? null : DefaultBrush);
@@ -15188,7 +15284,7 @@ function CanvasBody({ editor, className, style, children, components, indicatorO
15188
15284
  {
15189
15285
  ref: containerRef,
15190
15286
  className: className ? `mocanvas ${className}` : "mocanvas",
15191
- style: { ...containerStyle, background: backgroundFor(editor), ...themeVars, ...cameraVars, ...style, cursor },
15287
+ style: { ...containerStyle, background: backgroundFor(editor), ...themeVars, ...style, cursor },
15192
15288
  tabIndex: 0,
15193
15289
  onPointerDown: events.onPointerDown,
15194
15290
  onPointerMove: events.onPointerMove,
@@ -15198,7 +15294,7 @@ function CanvasBody({ editor, className, style, children, components, indicatorO
15198
15294
  "data-testid": "mocanvas-container",
15199
15295
  children: [
15200
15296
  Background ? /* @__PURE__ */ jsx(Background, { editor }) : null,
15201
- Grid !== null && isGridMode ? /* @__PURE__ */ jsx(Grid, { x: camera.x, y: camera.y, z: camera.z, size: gridSize }) : null,
15297
+ Grid !== null && isGridMode ? /* @__PURE__ */ jsx(GridLayer, { editor, Grid }) : null,
15202
15298
  /* @__PURE__ */ jsx("canvas", { ref: canvasRef, style: { ...layerStyle, display: "block" } }),
15203
15299
  /* @__PURE__ */ jsx(OverlayLayer, { editor }),
15204
15300
  /* @__PURE__ */ jsx(IndicatorCanvas, { editor, util: indicatorOverlayUtil ?? ShapeIndicatorCompositor }),
@@ -15264,34 +15360,63 @@ function IndicatorCanvas({ editor, util }) {
15264
15360
  }, [editor, overlay]);
15265
15361
  return /* @__PURE__ */ jsx("canvas", { ref: canvasRef, style: { ...layerStyle, pointerEvents: "none", display: "block" } });
15266
15362
  }
15363
+ function useCameraCssVarsOnElement(editor, ref, style) {
15364
+ const own = style;
15365
+ const ownZoom = own?.["--mocanvas-zoom"] !== void 0;
15366
+ const ownScale = own?.["--mocanvas-scale"] !== void 0;
15367
+ useLayoutEffect(() => {
15368
+ const el = ref.current;
15369
+ if (!el) return;
15370
+ return react("canvas.cameraCssVars", () => {
15371
+ const vars = getCameraCssVars(editor.getZoomLevel());
15372
+ if (!ownZoom) el.style.setProperty("--mocanvas-zoom", vars["--mocanvas-zoom"]);
15373
+ if (!ownScale) el.style.setProperty("--mocanvas-scale", vars["--mocanvas-scale"]);
15374
+ });
15375
+ }, [editor, ref, ownZoom, ownScale]);
15376
+ }
15377
+ var GridLayer = track$1(function GridLayer2({ editor, Grid }) {
15378
+ const camera = editor.getCamera();
15379
+ const size = editor.getDocumentSettings().gridSize;
15380
+ return /* @__PURE__ */ jsx(Grid, { x: camera.x, y: camera.y, z: camera.z, size });
15381
+ });
15267
15382
  var OverlayLayer = track$1(function OverlayLayer2({ editor }) {
15268
15383
  const ids = editor.getOverlayShapeIds();
15269
15384
  const clips = editor.getOverlayClips();
15270
- const cam = editor.getCamera();
15385
+ const retained = editor.getOverlayRetainedShapeIds();
15271
15386
  const editingId = editor.getEditingShapeId();
15272
- return /* @__PURE__ */ jsx(
15273
- "div",
15274
- {
15275
- className: "mocanvas-overlay",
15276
- style: {
15277
- ...layerStyle,
15278
- pointerEvents: "none",
15279
- transformOrigin: "0 0",
15280
- transform: `scale(${cam.z}) translate(${cam.x}px, ${cam.y}px)`
15387
+ const ref = useRef(null);
15388
+ useLayoutEffect(() => {
15389
+ const el = ref.current;
15390
+ if (!el) return;
15391
+ return react("canvas.overlayTransform", () => {
15392
+ const cam = editor.getCamera();
15393
+ el.style.transform = `scale(${cam.z}) translate(${cam.x}px, ${cam.y}px)`;
15394
+ });
15395
+ }, [editor]);
15396
+ return /* @__PURE__ */ jsx("div", { ref, className: "mocanvas-overlay", style: overlayLayerStyle, children: ids.map((id, i) => {
15397
+ const shape = editor.getShape(id);
15398
+ if (!shape) return null;
15399
+ return /* @__PURE__ */ jsx(
15400
+ OverlayShape,
15401
+ {
15402
+ editor,
15403
+ shape,
15404
+ isEditing: editingId === id,
15405
+ clip: clips[i],
15406
+ isRetained: retained.has(id)
15281
15407
  },
15282
- children: ids.map((id, i) => {
15283
- const shape = editor.getShape(id);
15284
- if (!shape) return null;
15285
- return /* @__PURE__ */ jsx(OverlayShape, { editor, shape, isEditing: editingId === id, clip: clips[i] }, id);
15286
- })
15287
- }
15288
- );
15408
+ id
15409
+ );
15410
+ }) });
15289
15411
  });
15412
+ var overlayLayerStyle = { ...layerStyle, pointerEvents: "none", transformOrigin: "0 0" };
15413
+ var RETAINED_STYLE = { contentVisibility: "hidden", visibility: "hidden" };
15290
15414
  var OverlayShape = track$1(function OverlayShape2({
15291
15415
  editor,
15292
15416
  shape,
15293
15417
  isEditing,
15294
- clip
15418
+ clip,
15419
+ isRetained
15295
15420
  }) {
15296
15421
  const util = editor.getShapeUtil(shape);
15297
15422
  const m = editor.getShapePageTransform(shape);
@@ -15303,6 +15428,7 @@ var OverlayShape = track$1(function OverlayShape2({
15303
15428
  className: "mocanvas-shape",
15304
15429
  "data-shape-id": shape.id,
15305
15430
  "data-shape-type": shape.type,
15431
+ "data-retained": isRetained && !clip ? "true" : void 0,
15306
15432
  style: {
15307
15433
  position: "absolute",
15308
15434
  left: 0,
@@ -15312,7 +15438,8 @@ var OverlayShape = track$1(function OverlayShape2({
15312
15438
  transformOrigin: "0 0",
15313
15439
  transform: `${offset}matrix(${m.a}, ${m.b}, ${m.c}, ${m.d}, ${m.e}, ${m.f})`,
15314
15440
  opacity: shape.opacity,
15315
- pointerEvents: isEditing ? "auto" : "none"
15441
+ pointerEvents: isEditing ? "auto" : "none",
15442
+ ...isRetained && !clip ? RETAINED_STYLE : null
15316
15443
  },
15317
15444
  children: /* @__PURE__ */ jsxs(ShapeBoundary, { shape, children: [
15318
15445
  /* @__PURE__ */ jsx(ShapeBody, { editor, shape }),
@@ -15325,6 +15452,7 @@ var OverlayShape = track$1(function OverlayShape2({
15325
15452
  "div",
15326
15453
  {
15327
15454
  className: "mocanvas-shape-clip",
15455
+ "data-retained": isRetained ? "true" : void 0,
15328
15456
  style: {
15329
15457
  position: "absolute",
15330
15458
  left: clip[0],
@@ -15332,7 +15460,8 @@ var OverlayShape = track$1(function OverlayShape2({
15332
15460
  width: Math.max(0, clip[2] - clip[0]),
15333
15461
  height: Math.max(0, clip[3] - clip[1]),
15334
15462
  overflow: "hidden",
15335
- pointerEvents: "none"
15463
+ pointerEvents: "none",
15464
+ ...isRetained ? RETAINED_STYLE : null
15336
15465
  },
15337
15466
  children: el
15338
15467
  }
@@ -15374,9 +15503,10 @@ var DefaultIndicators = track$1(function DefaultIndicators2({ editor }) {
15374
15503
  const bounds = editor.getSelectionPageBounds();
15375
15504
  const hovered = editor.getHoveredShape();
15376
15505
  const selected = editor.getSelectedShapes();
15506
+ const tool = editor.getCurrentToolId();
15507
+ if (!bounds && selected.length === 0 && !(hovered && tool === "select")) return null;
15377
15508
  const cam = editor.getCamera();
15378
15509
  const z = cam.z;
15379
- const tool = editor.getCurrentToolId();
15380
15510
  const toScreen = (x, y) => [(x + cam.x) * z, (y + cam.y) * z];
15381
15511
  const items = [];
15382
15512
  const indicatorFor = (shape, key, stroke) => {
@@ -15502,9 +15632,19 @@ var DefaultBrush = track$1(function DefaultBrush2({ editor }) {
15502
15632
  }
15503
15633
  );
15504
15634
  });
15505
- var ShapeBody = track$1(function ShapeBody2({ editor, shape }) {
15506
- return /* @__PURE__ */ jsx(Fragment, { children: editor.getShapeUtil(shape).component(shape) });
15507
- });
15635
+ var ShapeBody = track$1(
15636
+ memo(
15637
+ function ShapeBody2({ editor, shape }) {
15638
+ return /* @__PURE__ */ jsx(Fragment, { children: editor.getShapeUtil(shape).component(shape) });
15639
+ },
15640
+ // A body is a function of its shape's content, not of where the shape is:
15641
+ // the wrapper above carries position, rotation and opacity as a transform.
15642
+ // Comparing the whole record re-rendered every card under a drag on every
15643
+ // frame, though nothing it draws had changed. Signals the body reads still
15644
+ // re-render it through `track`.
15645
+ (prev, next) => prev.editor === next.editor && prev.shape.id === next.shape.id && prev.shape.type === next.shape.type && prev.shape.props === next.shape.props && prev.shape.meta === next.shape.meta
15646
+ )
15647
+ );
15508
15648
  function ShapeBoundary({ shape, children }) {
15509
15649
  const Fallback = useErrorSlot("ShapeErrorFallback", DefaultShapeErrorFallback);
15510
15650
  return /* @__PURE__ */ jsx(