@mocanvas/editor 4.13.0 → 4.15.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,40 @@ 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
+
215
+ **The zoom in your CSS.** `--mocanvas-zoom` and `--mocanvas-scale` are live on
216
+ the canvas container, but the overlay declares them as constants so that a zoom
217
+ does not restyle every node of every mounted shape — the single largest cost of
218
+ a zoom gesture on a board of DOM cards. A shape whose own CSS reads them says so:
219
+
220
+ ```ts
221
+ class CardUtil extends BaseBoxShapeUtil<CardShape> {
222
+ override usesCameraCssVars() {
223
+ return true // this card counter-scales its label
224
+ }
225
+ }
226
+ ```
227
+
228
+ That shape then gets the live values on its own wrapper, and its subtree is the
229
+ only one that restyles on a zoom. `options.overlayCameraCssVars: "inherit"`
230
+ turns the mechanism off altogether, for an app that would rather pay than name
231
+ its shapes. Everything outside the overlay — your own chrome, panels, menus —
232
+ reads the live values as before.
233
+
200
234
  ### `getIndicatorPath` — the selection outline
201
235
 
202
236
  ```ts
package/MIGRATION.md CHANGED
@@ -273,7 +273,7 @@ it. Two groups are worth knowing about:
273
273
 
274
274
  | tldraw token | What you get |
275
275
  | --- | --- |
276
- | `--tl-zoom`, `--tl-scale` | Real values. mocanvas now stamps `--mocanvas-zoom` and `--mocanvas-scale` on the canvas container and restamps them on zoom, so `calc()` rules that hold a constant on-screen size keep working. |
276
+ | `--tl-zoom`, `--tl-scale` | Real values. mocanvas stamps `--mocanvas-zoom` and `--mocanvas-scale` on the canvas container and restamps them on zoom, so `calc()` rules that hold a constant on-screen size keep working. Inside the DOM overlay they are constants unless the shape's util says `usesCameraCssVars()` — a tldraw shape whose CSS counter-scales with `--tl-scale` needs that line, or `options.overlayCameraCssVars: "inherit"`. |
277
277
  | `--tl-color-overlay`, `--tl-color-background-overlay`, `--tl-color-warn` | A static fallback. mocanvas has no equivalent, so these do not follow your theme — set them yourself if they matter. |
278
278
 
279
279
  Everything else maps onto a themed mocanvas variable and follows a theme swap
package/dist/index.d.ts CHANGED
@@ -3483,6 +3483,19 @@ declare abstract class ShapeUtil<T extends UnknownShape = UnknownShape, D extend
3483
3483
  getRenderStyle(_shape: T): StyleWords | null;
3484
3484
  /** Whether the shape should be drawn by the DOM overlay even if it has a render style (e.g. while editing). */
3485
3485
  needsOverlay(shape: T): boolean;
3486
+ /**
3487
+ * Whether this shape's own CSS reads `--mocanvas-zoom` / `--mocanvas-scale`.
3488
+ *
3489
+ * The overlay stops those variables inheriting into mounted shapes, because a
3490
+ * custom property that changes on every zoom frame restyles every node below
3491
+ * it — the cost of a zoom on a board of DOM cards. A shape that says `true`
3492
+ * here gets the live values on its own wrapper, and pays that restyle for
3493
+ * itself: the counter-scaled label, the hairline that must stay 1px.
3494
+ *
3495
+ * Default `false`. `options.overlayCameraCssVars: "inherit"` turns the whole
3496
+ * mechanism off for an app that would rather not name its shapes.
3497
+ */
3498
+ usesCameraCssVars(_shape: T): boolean;
3486
3499
  /**
3487
3500
  * Whether `component` should be rendered in the DOM overlay *in addition to*
3488
3501
  * the GPU geometry (e.g. a text label on a filled shape).
@@ -8313,6 +8326,41 @@ interface EditorConfig extends TldrawOptions {
8313
8326
  * enough to debounce at all, and how much movement is worth publishing.
8314
8327
  */
8315
8328
  debouncedZoomOctaves: number;
8329
+ /**
8330
+ * How many DOM-overlay shapes that left the viewport stay mounted, hidden, so
8331
+ * coming back into view costs a style change instead of a React remount.
8332
+ *
8333
+ * The engine culls the overlay to the viewport exactly, so without this every
8334
+ * card that crosses the viewport edge during a pan or a zoom is unmounted and
8335
+ * rebuilt from nothing when the gesture comes back — on a board of heavy React
8336
+ * cards, dozens of remounts per gesture. Retained shapes are the most recently
8337
+ * culled ones (least recently visible are evicted first), they are listed in
8338
+ * {@link Editor.getOverlayRetainedShapeIds}, and the canvas hides them with
8339
+ * `content-visibility: hidden`, which keeps their DOM and layout but skips
8340
+ * their rendering.
8341
+ *
8342
+ * The bound is the memory cost: up to this many shapes' DOM is kept alive off
8343
+ * screen. `0` turns retention off and unmounts at the edge, as every release before
8344
+ * this option did.
8345
+ */
8346
+ overlayRetainLimit: number;
8347
+ /**
8348
+ * Whether the DOM overlay lets `--mocanvas-zoom` / `--mocanvas-scale` inherit
8349
+ * into the shapes below it.
8350
+ *
8351
+ * `"cut"` (the default) has the overlay declare them as constants, so the
8352
+ * container's — rewritten on every frame of a zoom — stop there. A changed
8353
+ * inherited custom property restyles every descendant, and on a board of
8354
+ * mounted DOM cards that is every node of every shape: 41 ms of style
8355
+ * recalculation per zoom step over 1,000 live cards, against 0.1 ms with the
8356
+ * inheritance cut (`apps/bench`, `dom-overlay.mjs`).
8357
+ *
8358
+ * A shape that reads the live zoom in its own CSS says so with
8359
+ * `ShapeUtil.usesCameraCssVars()` and gets the real values on its wrapper.
8360
+ * `"inherit"` restores the old behaviour for an app whose shape CSS reads the
8361
+ * variables and that would rather pay than name the shapes.
8362
+ */
8363
+ overlayCameraCssVars: "cut" | "inherit";
8316
8364
  }
8317
8365
  declare const DEFAULT_EDITOR_CONFIG: EditorConfig;
8318
8366
  interface HitTestOptions {
@@ -8431,6 +8479,15 @@ declare class Editor extends EventEmitter<EditorEvents> {
8431
8479
  private readonly _frameEpoch;
8432
8480
  private readonly _overlayShapeIds;
8433
8481
  private readonly _overlayClips;
8482
+ private readonly _overlayRetained;
8483
+ /**
8484
+ * Overlay shapes the engine culled that are still mounted, oldest first: a
8485
+ * `Map` iterates in insertion order, which is the LRU order eviction wants.
8486
+ * Each carries the clip it had when it was last on screen.
8487
+ */
8488
+ private readonly overlayRetained;
8489
+ /** Last frame's on-screen overlay shapes (engine list plus kept ones), with their clips. */
8490
+ private overlayOnScreen;
8434
8491
  private readonly _isDisposed;
8435
8492
  private readonly _lastFrame;
8436
8493
  private readonly _cameraOptions;
@@ -8515,8 +8572,18 @@ declare class Editor extends EventEmitter<EditorEvents> {
8515
8572
  culled: number;
8516
8573
  ms: number;
8517
8574
  };
8518
- /** Shapes the DOM overlay must render this frame, in draw order. */
8575
+ /**
8576
+ * Shapes the DOM overlay must keep mounted this frame, in draw order: the ones
8577
+ * on screen, plus those listed by {@link getOverlayRetainedShapeIds}, which are
8578
+ * off screen and mounted only so that coming back does not rebuild them.
8579
+ */
8519
8580
  getOverlayShapeIds(): readonly ShapeId[];
8581
+ /**
8582
+ * The subset of {@link getOverlayShapeIds} that the engine culled and the
8583
+ * overlay keeps mounted but hidden — see `options.overlayRetainLimit`. Empty
8584
+ * when nothing is retained.
8585
+ */
8586
+ getOverlayRetainedShapeIds(): ReadonlySet<ShapeId>;
8520
8587
  /**
8521
8588
  * Page-space clip rects for the overlay shapes, parallel to
8522
8589
  * `getOverlayShapeIds()`; `undefined` where the shape is unclipped.
@@ -9088,6 +9155,27 @@ declare class Editor extends EventEmitter<EditorEvents> {
9088
9155
  * ancestors give it, on screen or off, so its DOM never changes shape at the viewport edge.
9089
9156
  */
9090
9157
  private keepUncullableOverlay;
9158
+ /**
9159
+ * Keep the overlay shapes that just left the viewport mounted, up to
9160
+ * `options.overlayRetainLimit`, and put the whole list in paint order.
9161
+ *
9162
+ * A shape that the engine stops reporting moves into a bounded LRU; one that
9163
+ * comes back moves out of it. Either way its entry in the list — and so its
9164
+ * React element and DOM — is untouched; only its retained flag flips. The list
9165
+ * is sorted by paint rank whenever anything is retained, so a shape crossing
9166
+ * the viewport edge never changes position among its siblings either: React
9167
+ * would move its DOM for that, and moving a node with an iframe in it reloads
9168
+ * the iframe.
9169
+ *
9170
+ * Returns the retained ids, which the canvas hides.
9171
+ */
9172
+ private retainCulledOverlay;
9173
+ /**
9174
+ * Every shape on the current page by paint order. Recomputed when a shape
9175
+ * record changes — not when the camera moves, which is when the overlay
9176
+ * needs it.
9177
+ */
9178
+ private readonly _paintRank;
9091
9179
  /**
9092
9180
  * Device-pixel scale at which rasterized textures (text labels) should be
9093
9181
  * 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,12 @@ 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,
7857
+ overlayCameraCssVars: "cut"
7853
7858
  };
7854
7859
  var RAD_PER_DEG = Math.PI / 180;
7855
7860
  var WHEEL_ZOOM_DELTA_CAP = 50;
@@ -7949,6 +7954,15 @@ var Editor = class extends EventEmitter {
7949
7954
  _frameEpoch;
7950
7955
  _overlayShapeIds;
7951
7956
  _overlayClips;
7957
+ _overlayRetained;
7958
+ /**
7959
+ * Overlay shapes the engine culled that are still mounted, oldest first: a
7960
+ * `Map` iterates in insertion order, which is the LRU order eviction wants.
7961
+ * Each carries the clip it had when it was last on screen.
7962
+ */
7963
+ overlayRetained = /* @__PURE__ */ new Map();
7964
+ /** Last frame's on-screen overlay shapes (engine list plus kept ones), with their clips. */
7965
+ overlayOnScreen = /* @__PURE__ */ new Map();
7952
7966
  _isDisposed;
7953
7967
  _lastFrame;
7954
7968
  _cameraOptions;
@@ -8015,6 +8029,7 @@ var Editor = class extends EventEmitter {
8015
8029
  });
8016
8030
  this._overlayShapeIds = atom("editor.overlayShapeIds", []);
8017
8031
  this._overlayClips = atom("editor.overlayClips", []);
8032
+ this._overlayRetained = atom("editor.overlayRetained", EMPTY_ID_SET);
8018
8033
  this._isDisposed = atom("editor.isDisposed", false);
8019
8034
  this._isMounted = atom("editor.isMounted", false);
8020
8035
  this._lastFrame = atom("editor.lastFrame", { drawn: 0, culled: 0, ms: 0 });
@@ -8209,10 +8224,22 @@ var Editor = class extends EventEmitter {
8209
8224
  getLastFrameStats() {
8210
8225
  return this._lastFrame.get();
8211
8226
  }
8212
- /** Shapes the DOM overlay must render this frame, in draw order. */
8227
+ /**
8228
+ * Shapes the DOM overlay must keep mounted this frame, in draw order: the ones
8229
+ * on screen, plus those listed by {@link getOverlayRetainedShapeIds}, which are
8230
+ * off screen and mounted only so that coming back does not rebuild them.
8231
+ */
8213
8232
  getOverlayShapeIds() {
8214
8233
  return this._overlayShapeIds.get();
8215
8234
  }
8235
+ /**
8236
+ * The subset of {@link getOverlayShapeIds} that the engine culled and the
8237
+ * overlay keeps mounted but hidden — see `options.overlayRetainLimit`. Empty
8238
+ * when nothing is retained.
8239
+ */
8240
+ getOverlayRetainedShapeIds() {
8241
+ return this._overlayRetained.get();
8242
+ }
8216
8243
  /**
8217
8244
  * Page-space clip rects for the overlay shapes, parallel to
8218
8245
  * `getOverlayShapeIds()`; `undefined` where the shape is unclipped.
@@ -10350,10 +10377,12 @@ var Editor = class extends EventEmitter {
10350
10377
  }
10351
10378
  }
10352
10379
  unsafe__withoutCapture(() => this.keepUncullableOverlay(ids, clips));
10380
+ const retained = unsafe__withoutCapture(() => this.retainCulledOverlay(ids, clips));
10353
10381
  unsafe__withoutCapture(() => {
10354
10382
  const prev = this._overlayShapeIds.get();
10355
10383
  if (prev.length !== ids.length || prev.some((id, i) => id !== ids[i])) this._overlayShapeIds.set(ids);
10356
10384
  if (!sameClips(this._overlayClips.get(), clips)) this._overlayClips.set(clips);
10385
+ if (!sameIdSet(this._overlayRetained.get(), retained)) this._overlayRetained.set(retained);
10357
10386
  const ms = performance.now() - t0;
10358
10387
  this._lastFrame.set({ drawn: frame.drawn, culled: frame.culled, ms });
10359
10388
  this.emit("frame", { drawn: frame.drawn, culled: frame.culled, ms });
@@ -10383,6 +10412,69 @@ var Editor = class extends EventEmitter {
10383
10412
  clips.push(entry.clip);
10384
10413
  }
10385
10414
  }
10415
+ /**
10416
+ * Keep the overlay shapes that just left the viewport mounted, up to
10417
+ * `options.overlayRetainLimit`, and put the whole list in paint order.
10418
+ *
10419
+ * A shape that the engine stops reporting moves into a bounded LRU; one that
10420
+ * comes back moves out of it. Either way its entry in the list — and so its
10421
+ * React element and DOM — is untouched; only its retained flag flips. The list
10422
+ * is sorted by paint rank whenever anything is retained, so a shape crossing
10423
+ * the viewport edge never changes position among its siblings either: React
10424
+ * would move its DOM for that, and moving a node with an iframe in it reloads
10425
+ * the iframe.
10426
+ *
10427
+ * Returns the retained ids, which the canvas hides.
10428
+ */
10429
+ retainCulledOverlay(ids, clips) {
10430
+ const limit = Math.max(0, Math.floor(this.options.overlayRetainLimit));
10431
+ const lastOnScreen = this.overlayOnScreen;
10432
+ const onScreen = /* @__PURE__ */ new Map();
10433
+ for (let i = 0; i < ids.length; i++) onScreen.set(ids[i], clips[i]);
10434
+ this.overlayOnScreen = onScreen;
10435
+ const retainedMap = this.overlayRetained;
10436
+ if (limit === 0) {
10437
+ retainedMap.clear();
10438
+ return EMPTY_ID_SET;
10439
+ }
10440
+ for (const id of onScreen.keys()) retainedMap.delete(id);
10441
+ for (const [id, clip] of lastOnScreen) {
10442
+ if (onScreen.has(id)) continue;
10443
+ retainedMap.delete(id);
10444
+ retainedMap.set(id, clip);
10445
+ }
10446
+ if (retainedMap.size === 0) return EMPTY_ID_SET;
10447
+ const pageId = this.getCurrentPageId();
10448
+ for (const id of [...retainedMap.keys()]) {
10449
+ const shape = this.getShape(id);
10450
+ if (!shape || !this.overlayFlaggedIds.has(id) || this.getAncestorPageId(shape) !== pageId || isShapeHidden(this, shape)) {
10451
+ retainedMap.delete(id);
10452
+ }
10453
+ }
10454
+ while (retainedMap.size > limit) retainedMap.delete(retainedMap.keys().next().value);
10455
+ if (retainedMap.size === 0) return EMPTY_ID_SET;
10456
+ const rank = this._paintRank.get();
10457
+ const entries = [];
10458
+ for (const [id, clip] of onScreen) entries.push({ id, clip });
10459
+ for (const [id, clip] of retainedMap) entries.push({ id, clip });
10460
+ const rankOf = (id) => rank.get(id) ?? Number.MAX_SAFE_INTEGER;
10461
+ entries.sort((a, b) => rankOf(a.id) - rankOf(b.id));
10462
+ ids.length = 0;
10463
+ clips.length = 0;
10464
+ for (const entry of entries) {
10465
+ ids.push(entry.id);
10466
+ clips.push(entry.clip);
10467
+ }
10468
+ return new Set(retainedMap.keys());
10469
+ }
10470
+ /**
10471
+ * Every shape on the current page by paint order. Recomputed when a shape
10472
+ * record changes — not when the camera moves, which is when the overlay
10473
+ * needs it.
10474
+ */
10475
+ _paintRank = computed("editor.paintRank", () => {
10476
+ return new Map(this.getCurrentPageShapesSorted().map((shape, index) => [shape.id, index]));
10477
+ });
10386
10478
  // ---- textures ----------------------------------------------------------
10387
10479
  /**
10388
10480
  * Device-pixel scale at which rasterized textures (text labels) should be
@@ -11622,6 +11714,13 @@ var Editor = class extends EventEmitter {
11622
11714
  /** Which menus are open right now. Backed by the `instance` session record. */
11623
11715
  menus = new MenuManager(this);
11624
11716
  };
11717
+ var EMPTY_ID_SET = /* @__PURE__ */ new Set();
11718
+ function sameIdSet(a, b) {
11719
+ if (a === b) return true;
11720
+ if (a.size !== b.size) return false;
11721
+ for (const id of a) if (!b.has(id)) return false;
11722
+ return true;
11723
+ }
11625
11724
  function sameClips(a, b) {
11626
11725
  if (a.length !== b.length) return false;
11627
11726
  for (let i = 0; i < a.length; i++) {
@@ -13813,6 +13912,21 @@ var ShapeUtil = class {
13813
13912
  needsOverlay(shape) {
13814
13913
  return this.editor.getEditingShapeId() === shape.id;
13815
13914
  }
13915
+ /**
13916
+ * Whether this shape's own CSS reads `--mocanvas-zoom` / `--mocanvas-scale`.
13917
+ *
13918
+ * The overlay stops those variables inheriting into mounted shapes, because a
13919
+ * custom property that changes on every zoom frame restyles every node below
13920
+ * it — the cost of a zoom on a board of DOM cards. A shape that says `true`
13921
+ * here gets the live values on its own wrapper, and pays that restyle for
13922
+ * itself: the counter-scaled label, the hairline that must stay 1px.
13923
+ *
13924
+ * Default `false`. `options.overlayCameraCssVars: "inherit"` turns the whole
13925
+ * mechanism off for an app that would rather not name its shapes.
13926
+ */
13927
+ usesCameraCssVars(_shape) {
13928
+ return false;
13929
+ }
13816
13930
  /**
13817
13931
  * Whether `component` should be rendered in the DOM overlay *in addition to*
13818
13932
  * the GPU geometry (e.g. a text label on a filled shape).
@@ -15174,10 +15288,8 @@ function CanvasBody({ editor, className, style, children, components, indicatorO
15174
15288
  const cursor = useValue("cursor", () => cssCursor(editor.getInstanceState().cursor.type), [editor]);
15175
15289
  const hasChromeContextMenu = useEditorComponents().ContextMenu != null;
15176
15290
  const themeVars = useThemeCssVars(editor);
15177
- const cameraVars = useCameraCssVars(editor);
15291
+ useCameraCssVarsOnElement(editor, containerRef, style);
15178
15292
  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
15293
  const Grid = components?.Grid === void 0 ? DefaultGrid : components.Grid;
15182
15294
  const Indicators = components?.Indicators ?? DefaultIndicators;
15183
15295
  const Brush = components?.Brush ?? (hasOverlay(editor, "brush") ? null : DefaultBrush);
@@ -15188,7 +15300,7 @@ function CanvasBody({ editor, className, style, children, components, indicatorO
15188
15300
  {
15189
15301
  ref: containerRef,
15190
15302
  className: className ? `mocanvas ${className}` : "mocanvas",
15191
- style: { ...containerStyle, background: backgroundFor(editor), ...themeVars, ...cameraVars, ...style, cursor },
15303
+ style: { ...containerStyle, background: backgroundFor(editor), ...themeVars, ...style, cursor },
15192
15304
  tabIndex: 0,
15193
15305
  onPointerDown: events.onPointerDown,
15194
15306
  onPointerMove: events.onPointerMove,
@@ -15198,7 +15310,7 @@ function CanvasBody({ editor, className, style, children, components, indicatorO
15198
15310
  "data-testid": "mocanvas-container",
15199
15311
  children: [
15200
15312
  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,
15313
+ Grid !== null && isGridMode ? /* @__PURE__ */ jsx(GridLayer, { editor, Grid }) : null,
15202
15314
  /* @__PURE__ */ jsx("canvas", { ref: canvasRef, style: { ...layerStyle, display: "block" } }),
15203
15315
  /* @__PURE__ */ jsx(OverlayLayer, { editor }),
15204
15316
  /* @__PURE__ */ jsx(IndicatorCanvas, { editor, util: indicatorOverlayUtil ?? ShapeIndicatorCompositor }),
@@ -15264,45 +15376,102 @@ function IndicatorCanvas({ editor, util }) {
15264
15376
  }, [editor, overlay]);
15265
15377
  return /* @__PURE__ */ jsx("canvas", { ref: canvasRef, style: { ...layerStyle, pointerEvents: "none", display: "block" } });
15266
15378
  }
15379
+ function useCameraCssVarsOnElement(editor, ref, style) {
15380
+ const own = style;
15381
+ const ownZoom = own?.["--mocanvas-zoom"] !== void 0;
15382
+ const ownScale = own?.["--mocanvas-scale"] !== void 0;
15383
+ useLayoutEffect(() => {
15384
+ const el = ref.current;
15385
+ if (!el) return;
15386
+ return react("canvas.cameraCssVars", () => {
15387
+ const vars = getCameraCssVars(editor.getZoomLevel());
15388
+ if (!ownZoom) el.style.setProperty("--mocanvas-zoom", vars["--mocanvas-zoom"]);
15389
+ if (!ownScale) el.style.setProperty("--mocanvas-scale", vars["--mocanvas-scale"]);
15390
+ });
15391
+ }, [editor, ref, ownZoom, ownScale]);
15392
+ }
15393
+ function useCameraCssVarsOnShape(editor, enabled) {
15394
+ const ref = useRef(null);
15395
+ useLayoutEffect(() => {
15396
+ const el = ref.current;
15397
+ if (!el) return;
15398
+ if (!enabled) {
15399
+ el.style.removeProperty("--mocanvas-zoom");
15400
+ el.style.removeProperty("--mocanvas-scale");
15401
+ return;
15402
+ }
15403
+ return react("canvas.shapeCameraCssVars", () => {
15404
+ const vars = getCameraCssVars(editor.getZoomLevel());
15405
+ el.style.setProperty("--mocanvas-zoom", vars["--mocanvas-zoom"]);
15406
+ el.style.setProperty("--mocanvas-scale", vars["--mocanvas-scale"]);
15407
+ });
15408
+ }, [editor, enabled]);
15409
+ return ref;
15410
+ }
15411
+ var GridLayer = track$1(function GridLayer2({ editor, Grid }) {
15412
+ const camera = editor.getCamera();
15413
+ const size = editor.getDocumentSettings().gridSize;
15414
+ return /* @__PURE__ */ jsx(Grid, { x: camera.x, y: camera.y, z: camera.z, size });
15415
+ });
15267
15416
  var OverlayLayer = track$1(function OverlayLayer2({ editor }) {
15268
15417
  const ids = editor.getOverlayShapeIds();
15269
15418
  const clips = editor.getOverlayClips();
15270
- const cam = editor.getCamera();
15419
+ const retained = editor.getOverlayRetainedShapeIds();
15271
15420
  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)`
15421
+ const ref = useRef(null);
15422
+ useLayoutEffect(() => {
15423
+ const el = ref.current;
15424
+ if (!el) return;
15425
+ return react("canvas.overlayTransform", () => {
15426
+ const cam = editor.getCamera();
15427
+ el.style.transform = `scale(${cam.z}) translate(${cam.x}px, ${cam.y}px)`;
15428
+ });
15429
+ }, [editor]);
15430
+ const cut = editor.options.overlayCameraCssVars !== "inherit";
15431
+ return /* @__PURE__ */ jsx("div", { ref, className: "mocanvas-overlay", style: cut ? overlayLayerStyleCut : overlayLayerStyle, children: ids.map((id, i) => {
15432
+ const shape = editor.getShape(id);
15433
+ if (!shape) return null;
15434
+ return /* @__PURE__ */ jsx(
15435
+ OverlayShape,
15436
+ {
15437
+ editor,
15438
+ shape,
15439
+ isEditing: editingId === id,
15440
+ clip: clips[i],
15441
+ isRetained: retained.has(id)
15281
15442
  },
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
- );
15443
+ id
15444
+ );
15445
+ }) });
15289
15446
  });
15447
+ var overlayLayerStyle = { ...layerStyle, pointerEvents: "none", transformOrigin: "0 0" };
15448
+ var overlayLayerStyleCut = {
15449
+ ...overlayLayerStyle,
15450
+ ["--mocanvas-zoom"]: "1",
15451
+ ["--mocanvas-scale"]: "1"
15452
+ };
15453
+ var RETAINED_STYLE = { contentVisibility: "hidden", visibility: "hidden" };
15290
15454
  var OverlayShape = track$1(function OverlayShape2({
15291
15455
  editor,
15292
15456
  shape,
15293
15457
  isEditing,
15294
- clip
15458
+ clip,
15459
+ isRetained
15295
15460
  }) {
15296
15461
  const util = editor.getShapeUtil(shape);
15297
15462
  const m = editor.getShapePageTransform(shape);
15298
15463
  const bounds = editor.getShapeGeometryBounds(shape);
15464
+ const asksForCamera = util.usesCameraCssVars?.(shape) === true;
15465
+ const cameraRef = useCameraCssVarsOnShape(editor, asksForCamera);
15299
15466
  const offset = clip ? `translate(${-clip[0]}px, ${-clip[1]}px) ` : "";
15300
15467
  const el = /* @__PURE__ */ jsx(
15301
15468
  "div",
15302
15469
  {
15470
+ ref: cameraRef,
15303
15471
  className: "mocanvas-shape",
15304
15472
  "data-shape-id": shape.id,
15305
15473
  "data-shape-type": shape.type,
15474
+ "data-retained": isRetained && !clip ? "true" : void 0,
15306
15475
  style: {
15307
15476
  position: "absolute",
15308
15477
  left: 0,
@@ -15312,7 +15481,8 @@ var OverlayShape = track$1(function OverlayShape2({
15312
15481
  transformOrigin: "0 0",
15313
15482
  transform: `${offset}matrix(${m.a}, ${m.b}, ${m.c}, ${m.d}, ${m.e}, ${m.f})`,
15314
15483
  opacity: shape.opacity,
15315
- pointerEvents: isEditing ? "auto" : "none"
15484
+ pointerEvents: isEditing ? "auto" : "none",
15485
+ ...isRetained && !clip ? RETAINED_STYLE : null
15316
15486
  },
15317
15487
  children: /* @__PURE__ */ jsxs(ShapeBoundary, { shape, children: [
15318
15488
  /* @__PURE__ */ jsx(ShapeBody, { editor, shape }),
@@ -15325,6 +15495,7 @@ var OverlayShape = track$1(function OverlayShape2({
15325
15495
  "div",
15326
15496
  {
15327
15497
  className: "mocanvas-shape-clip",
15498
+ "data-retained": isRetained ? "true" : void 0,
15328
15499
  style: {
15329
15500
  position: "absolute",
15330
15501
  left: clip[0],
@@ -15332,7 +15503,8 @@ var OverlayShape = track$1(function OverlayShape2({
15332
15503
  width: Math.max(0, clip[2] - clip[0]),
15333
15504
  height: Math.max(0, clip[3] - clip[1]),
15334
15505
  overflow: "hidden",
15335
- pointerEvents: "none"
15506
+ pointerEvents: "none",
15507
+ ...isRetained ? RETAINED_STYLE : null
15336
15508
  },
15337
15509
  children: el
15338
15510
  }
@@ -15374,9 +15546,10 @@ var DefaultIndicators = track$1(function DefaultIndicators2({ editor }) {
15374
15546
  const bounds = editor.getSelectionPageBounds();
15375
15547
  const hovered = editor.getHoveredShape();
15376
15548
  const selected = editor.getSelectedShapes();
15549
+ const tool = editor.getCurrentToolId();
15550
+ if (!bounds && selected.length === 0 && !(hovered && tool === "select")) return null;
15377
15551
  const cam = editor.getCamera();
15378
15552
  const z = cam.z;
15379
- const tool = editor.getCurrentToolId();
15380
15553
  const toScreen = (x, y) => [(x + cam.x) * z, (y + cam.y) * z];
15381
15554
  const items = [];
15382
15555
  const indicatorFor = (shape, key, stroke) => {
@@ -15502,9 +15675,19 @@ var DefaultBrush = track$1(function DefaultBrush2({ editor }) {
15502
15675
  }
15503
15676
  );
15504
15677
  });
15505
- var ShapeBody = track$1(function ShapeBody2({ editor, shape }) {
15506
- return /* @__PURE__ */ jsx(Fragment, { children: editor.getShapeUtil(shape).component(shape) });
15507
- });
15678
+ var ShapeBody = track$1(
15679
+ memo(
15680
+ function ShapeBody2({ editor, shape }) {
15681
+ return /* @__PURE__ */ jsx(Fragment, { children: editor.getShapeUtil(shape).component(shape) });
15682
+ },
15683
+ // A body is a function of its shape's content, not of where the shape is:
15684
+ // the wrapper above carries position, rotation and opacity as a transform.
15685
+ // Comparing the whole record re-rendered every card under a drag on every
15686
+ // frame, though nothing it draws had changed. Signals the body reads still
15687
+ // re-render it through `track`.
15688
+ (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
15689
+ )
15690
+ );
15508
15691
  function ShapeBoundary({ shape, children }) {
15509
15692
  const Fallback = useErrorSlot("ShapeErrorFallback", DefaultShapeErrorFallback);
15510
15693
  return /* @__PURE__ */ jsx(