@tldraw/commenting 5.3.0-next.dec36c5c2930 → 5.3.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.
@@ -19,6 +19,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
19
19
  var cluster_model_exports = {};
20
20
  __export(cluster_model_exports, {
21
21
  CLUSTER_EXPAND_ZOOM_MS: () => CLUSTER_EXPAND_ZOOM_MS,
22
+ anyFoldedLeafMoved: () => anyFoldedLeafMoved,
22
23
  revealThreadPin: () => revealThreadPin,
23
24
  useClusterModel: () => useClusterModel,
24
25
  zoomToClusterSplit: () => zoomToClusterSplit
@@ -50,6 +51,7 @@ function useClusterModel(editor, threads, openId) {
50
51
  const [heldThreadIds, setHeldThreadIds] = (0, import_react.useState)(EMPTY_SET);
51
52
  const adoptOnRebuild = (0, import_react.useRef)(false);
52
53
  const clusterInputRef = (0, import_react.useRef)({ leaves: [], screenOffsets: void 0 });
54
+ const renderedModelRef = (0, import_react.useRef)(null);
53
55
  const clusterInput = (0, import_tldraw.useValue)(
54
56
  "comment cluster leaves",
55
57
  () => {
@@ -59,7 +61,9 @@ function useClusterModel(editor, threads, openId) {
59
61
  import_state.openThreadId.get(editor)
60
62
  );
61
63
  const prev = clusterInputRef.current;
62
- if (isShapeDragInProgress(editor) && (0, import_cluster_input.clusterInputIdsEqual)(prev, next)) return prev;
64
+ if (isShapeDragInProgress(editor) && (0, import_cluster_input.clusterInputIdsEqual)(prev, next) && !anyFoldedLeafMoved(prev, next, renderedModelRef.current)) {
65
+ return prev;
66
+ }
63
67
  if ((0, import_cluster_input.clusterInputEqual)(prev, next)) return prev;
64
68
  clusterInputRef.current = next;
65
69
  return next;
@@ -102,6 +106,7 @@ function useClusterModel(editor, threads, openId) {
102
106
  } else if (heldThreadIds.size === 0 && renderedModel === latestModel) {
103
107
  adoptOnRebuild.current = false;
104
108
  }
109
+ renderedModelRef.current = clusterModel;
105
110
  const newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel);
106
111
  if (newlyMovedIds.length > 0) {
107
112
  const next = new Set(heldThreadIds);
@@ -176,6 +181,25 @@ function useClusterModel(editor, threads, openId) {
176
181
  heldThreads
177
182
  };
178
183
  }
184
+ function anyFoldedLeafMoved(prev, next, rendered) {
185
+ if (!rendered) return false;
186
+ if (prev.leaves.length !== next.leaves.length) return false;
187
+ const folded = /* @__PURE__ */ new Set();
188
+ for (const node of rendered.runtime.getVisible().values()) {
189
+ if (node.count < 2) continue;
190
+ for (const member of node.members) folded.add(member);
191
+ }
192
+ if (folded.size === 0) return false;
193
+ for (let i = 0; i < next.leaves.length; i++) {
194
+ if (!folded.has(next.leaves[i].id)) continue;
195
+ const a = prev.leaves[i].point;
196
+ const b = next.leaves[i].point;
197
+ if (Math.abs(a.x - b.x) > MOVED_LEAF_EPSILON || Math.abs(a.y - b.y) > MOVED_LEAF_EPSILON) {
198
+ return true;
199
+ }
200
+ }
201
+ return false;
202
+ }
179
203
  function findMovedClusteredLeafIds(rendered, latest) {
180
204
  if (rendered.table === latest.table) return [];
181
205
  const visible = rendered.runtime.getVisible();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/canvas/cluster-model.ts"],
4
- "sourcesContent": ["import { useEffect, useMemo, useRef, useState } from 'react'\nimport { Editor, react, TLCommentThread, useValue } from 'tldraw'\nimport { computeClusterTable } from '../clustering/computeClusterTable'\nimport { type ClusterRuntime, createClusterRuntime } from '../clustering/runtime'\nimport type { ClusterNode, ClusterTable, MergeEvent } from '../clustering/types'\nimport { type ClusterFadeNode, useFadeVisibleNodes } from './cluster-fade'\nimport {\n\ttype ClusterInput,\n\tclusterInputEqual,\n\tclusterInputIdsEqual,\n\tcollectClusterLeaves,\n} from './cluster-input'\nimport { type CommentingOptions } from './options'\nimport { openThreadId } from './state'\nimport { anchorPagePoint, commentCenterScreenOffset } from './thread-state'\n\n/** Duration of the click-a-badge zoom-to-split animation. */\nexport const CLUSTER_EXPAND_ZOOM_MS = 450\n/** How far past a cluster's split zoom to land when expanding it \u2014 a 5% overshoot, so the badge\n * lands clear of the threshold it just crossed rather than flickering on it. */\nconst CLUSTER_SPLIT_ZOOM_FACTOR = 1.05\n\nconst EMPTY_SET: ReadonlySet<string> = new Set()\nconst MOVED_LEAF_EPSILON = 1e-6\n\n/** Default select-tool states that move shapes continuously, one store write per pointermove. A\n * custom tool in their place just never matches, falling back to a rebuild per frame. */\nconst SHAPE_DRAG_STATE_PATHS = [\n\t'select.translating',\n\t'select.resizing',\n\t'select.rotating',\n\t'select.dragging_handle',\n\t'select.crop.cropping',\n] as const\n\n/** Reactive: `isInAny` reads the tool state path, so a computed reading this re-evaluates when the\n * gesture starts or settles. */\nfunction isShapeDragInProgress(editor: Editor): boolean {\n\treturn editor.isInAny(...SHAPE_DRAG_STATE_PATHS)\n}\n\n/** The clustering table for the current scene, plus the runtime walking its merge events. */\nexport interface ClusterModel {\n\truntime: ClusterRuntime\n\ttable: ClusterTable\n}\n\nexport interface ClusterZoomBounds {\n\tminZoom: number\n\tmaxZoom: number\n}\n\nexport interface ClusterModelState {\n\t/** The partition actually on screen. See {@link useClusterModel} for why it can lag the input. */\n\tmodel: ClusterModel\n\tzoomBounds: ClusterZoomBounds\n\t/** The displayed nodes, each tagged with its cross-fade phase. */\n\tfadeNodes: ClusterFadeNode[]\n\t/** Threads in the input that the displayed partition doesn't show \u2014 render them as plain pins. */\n\torphanThreads: TLCommentThread[]\n\t/** Threads held out of clustering because their anchor moved while folded into a badge. */\n\theldThreads: TLCommentThread[]\n}\n\n/**\n * The clustering state machine behind the comments layer.\n *\n * The core invariant: the only thing that re-flows clustering doc-wide is zoom. Every rebuild is\n * computed immediately as `latestModel`, but the on-screen partition is `renderedModel`, which only\n * changes via (a) the cursor walking on zoom, (b) adoption of the pending rebuild on zoom-out, or\n * (c) LOCAL detach patches, where a leaf that left the input is detached from its own badge in\n * place and nothing else on the canvas moves.\n *\n * Threads the displayed partition can't represent are returned separately (`orphanThreads`,\n * `heldThreads`) for the layer to draw as ordinary pins.\n */\nexport function useClusterModel(\n\teditor: Editor,\n\tthreads: readonly TLCommentThread[],\n\topenId: string | null\n): ClusterModelState {\n\t// Threads held out of clustering because their anchor moved while folded inside a badge\n\t// (drag, nudge, align, undo, a collaborator \u2014 detected by position, not gesture). They render\n\t// as live pins riding their anchor and rejoin clustering on the next zoom-out.\n\tconst [heldThreadIds, setHeldThreadIds] = useState<ReadonlySet<string>>(EMPTY_SET)\n\tconst adoptOnRebuild = useRef(false)\n\t// This input's identity keys the O(N\u00B2) table rebuild below, so it's gated on value equality: a\n\t// reply, a reaction, a resolve \u2014 anything that touches comment records without moving a pin \u2014\n\t// returns the previous input and rebuilds nothing.\n\t//\n\t// Mid-drag the gate widens to ignore positions too, and that costs something real: it freezes\n\t// `latestModel`, which is where `findMovedClusteredLeafIds` reads live positions from, so a pin\n\t// folded into a badge stops popping out to ride its anchor and corrects on release instead. The\n\t// per-frame rebuild it buys back was paid by every drag on the board, comment-anchored or not.\n\t// An added or deleted thread changes the id set, so it still rebuilds promptly.\n\tconst clusterInputRef = useRef<ClusterInput>({ leaves: [], screenOffsets: undefined })\n\tconst clusterInput = useValue(\n\t\t'comment cluster leaves',\n\t\t() => {\n\t\t\tconst next = collectClusterLeaves(\n\t\t\t\teditor,\n\t\t\t\tthreads.filter((thread) => !heldThreadIds.has(thread.id)),\n\t\t\t\topenThreadId.get(editor)\n\t\t\t)\n\t\t\tconst prev = clusterInputRef.current\n\t\t\tif (isShapeDragInProgress(editor) && clusterInputIdsEqual(prev, next)) return prev\n\t\t\tif (clusterInputEqual(prev, next)) return prev\n\t\t\tclusterInputRef.current = next\n\t\t\treturn next\n\t\t},\n\t\t[editor, threads, heldThreadIds]\n\t)\n\tconst clusterZoomBounds = useValue(\n\t\t'comment cluster zoom bounds',\n\t\t() => getClusterZoomBounds(editor),\n\t\t[editor]\n\t)\n\tconst latestModel = useMemo(() => {\n\t\tconst table = computeClusterTable(\n\t\t\tclusterInput.leaves,\n\t\t\tclusterZoomBounds,\n\t\t\tclusterInput.screenOffsets\n\t\t)\n\t\tconst runtime = createClusterRuntime(table)\n\t\truntime.seed(editor.getZoomLevel())\n\t\treturn { runtime, table }\n\t}, [clusterInput, clusterZoomBounds, editor])\n\tconst [renderedModel, setRenderedModel] = useState(latestModel)\n\tlet clusterModel = renderedModel\n\t// A page switch replaces the whole scene: hard-reset rather than detach the world.\n\tconst pageId = useValue('comment cluster page', () => editor.getCurrentPageId(), [editor])\n\tconst pageRef = useRef(pageId)\n\tif (pageRef.current !== pageId) {\n\t\tpageRef.current = pageId\n\t\tadoptOnRebuild.current = false\n\t\tlatestModel.runtime.seed(editor.getZoomLevel())\n\t\tif (heldThreadIds.size > 0) setHeldThreadIds(EMPTY_SET)\n\t\tsetRenderedModel(latestModel)\n\t\tclusterModel = latestModel\n\t}\n\t// adoptOnRebuild is set outside React's render cycle, paired with clearing heldThreadIds. Only trust\n\t// it once that pairing is visible here, or an unrelated re-render can land in the gap.\n\tconst rejoinPending = heldThreadIds.size === 0 && adoptOnRebuild.current\n\tif (renderedModel !== latestModel && rejoinPending) {\n\t\tadoptOnRebuild.current = false\n\t\t// Carryover seed: band events inherit the outgoing partition's merged/unmerged state, so\n\t\t// nothing changes state because of the swap alone. Idempotent, so safe during render.\n\t\tlatestModel.runtime.seedFrom(editor.getZoomLevel(), renderedModel.runtime.getVisible())\n\t\tsetRenderedModel(latestModel)\n\t\tclusterModel = latestModel\n\t} else if (heldThreadIds.size === 0 && renderedModel === latestModel) {\n\t\t// Nothing pending and nothing to adopt: clear any leftover force-adopt intent so it can't\n\t\t// survive to force-adopt a later, unrelated rebuild.\n\t\tadoptOnRebuild.current = false\n\t}\n\t// Pop-out detection: a leaf folded inside a badge can't follow its anchor (the badge position\n\t// is baked into the model), so when its live position drifts from the baked one, hold it out.\n\t// It renders as a live pin riding the anchor; the detach loop below shrinks its badge locally.\n\tconst newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel)\n\tif (newlyMovedIds.length > 0) {\n\t\tconst next = new Set(heldThreadIds)\n\t\tfor (const id of newlyMovedIds) next.add(id)\n\t\tsetHeldThreadIds(next)\n\t}\n\t// Local partition maintenance \u2014 the only non-zoom visual change. Any displayed leaf that has left the\n\t// cluster input is detached from its badge in place; the corrected rebuild already sits in latestModel\n\t// awaiting the next zoom-out. When the two models match, the leaf sets are identical, so skip the scan.\n\tif (clusterModel !== latestModel) {\n\t\tconst latestLeafIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))\n\t\tconst removedLeafIds: string[] = []\n\t\tfor (const leaf of clusterModel.table.leaves) {\n\t\t\tif (!latestLeafIds.has(leaf.id)) {\n\t\t\t\tremovedLeafIds.push(leaf.id)\n\t\t\t}\n\t\t}\n\t\t// Batched: one patch rebuild and one version bump for the whole set.\n\t\tif (removedLeafIds.length > 0) clusterModel.runtime.detachLeaves(removedLeafIds)\n\t}\n\t// Moved pins rejoin clustering on the next zoom-out motion: clear the set (so the rebuild\n\t// includes them again) and adopt that rebuild immediately instead of deferring it. Zooming in\n\t// never folds pins into clusters \u2014 merging is a zoom-out-only move, matching the runtime.\n\tuseEffect(() => {\n\t\tif (heldThreadIds.size === 0) return\n\t\tlet lastZoom = editor.getZoomLevel()\n\t\treturn react('rejoin moved comment pins on zoom out', () => {\n\t\t\tconst zoom = editor.getZoomLevel()\n\t\t\tconst prevZoom = lastZoom\n\t\t\tlastZoom = zoom\n\t\t\tif (zoom >= prevZoom) return\n\t\t\tadoptOnRebuild.current = true\n\t\t\tsetHeldThreadIds(EMPTY_SET)\n\t\t})\n\t}, [heldThreadIds, editor])\n\t// Adopt a pending rebuild only on zoom-out motion: folding deferred additions into clusters is\n\t// a merge, and merging only happens while zooming out. While zooming in, the stale table still\n\t// splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).\n\tuseEffect(() => {\n\t\tif (clusterModel === latestModel) return\n\t\tlet lastZoom = editor.getZoomLevel()\n\t\treturn react('adopt pending cluster model on zoom out', () => {\n\t\t\tconst zoom = editor.getZoomLevel()\n\t\t\tconst prevZoom = lastZoom\n\t\t\tlastZoom = zoom\n\t\t\tif (zoom >= prevZoom) return\n\t\t\tlatestModel.runtime.seedFrom(zoom, clusterModel.runtime.getVisible())\n\t\t\tsetRenderedModel(latestModel)\n\t\t})\n\t}, [clusterModel, latestModel, editor])\n\t// Threads the displayed partition doesn't show anywhere (new comments, reopened threads, undone\n\t// deletions) render as plain pins until the next zoom-out folds them in. Judged against the\n\t// displayed partition, so a detached-then-restored leaf reappears.\n\tconst partitionVersion = clusterModel.runtime.version\n\tconst orphanThreads = useMemo(() => {\n\t\tif (clusterModel === latestModel) return []\n\t\tconst displayed = new Set<string>()\n\t\tfor (const node of clusterModel.runtime.getVisible().values()) {\n\t\t\tfor (const member of node.members) displayed.add(member)\n\t\t}\n\t\tconst latestIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))\n\t\treturn threads.filter((thread) => latestIds.has(thread.id) && !displayed.has(thread.id))\n\t\t// The runtime mutates its partition in place; partitionVersion is its change stamp.\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [clusterModel, latestModel, threads, partitionVersion])\n\tconst heldThreads = useMemo(\n\t\t() => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),\n\t\t[threads, heldThreadIds, openId]\n\t)\n\t// Subscribe to the runtime's partition version, not the raw zoom, so this only re-renders on cluster\n\t// changes rather than every camera frame. The memo below re-reads the version inline because\n\t// render-time detaches bump it after the subscription's computed already evaluated.\n\tuseValue(\n\t\t'comment cluster version',\n\t\t() => {\n\t\t\tclusterModel.runtime.onCamera(editor.getZoomLevel())\n\t\t\treturn clusterModel.runtime.version\n\t\t},\n\t\t[clusterModel, editor]\n\t)\n\tconst visibleNodes = useMemo(() => {\n\t\treturn Array.from(clusterModel.runtime.getVisible().values())\n\t\t// The runtime mutates its partition in place; partitionVersion is its change stamp.\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [clusterModel, partitionVersion])\n\tconst fadeNodes = useFadeVisibleNodes(visibleNodes, clusterModel)\n\n\treturn {\n\t\tmodel: clusterModel,\n\t\tzoomBounds: clusterZoomBounds,\n\t\tfadeNodes,\n\t\torphanThreads,\n\t\theldThreads,\n\t}\n}\n\n/**\n * Leaves folded inside a badge whose live anchor no longer matches the position the rendered\n * model was built with. Visible (unclustered) leaf pins track their anchor live, so they can\n * stay deferred; a badge can't follow a member, so these must pop out of clustering.\n */\nfunction findMovedClusteredLeafIds(rendered: ClusterModel, latest: { table: ClusterTable }) {\n\tif (rendered.table === latest.table) return []\n\tconst visible = rendered.runtime.getVisible()\n\tconst latestById = new Map(latest.table.leaves.map((leaf) => [leaf.id, leaf]))\n\tconst moved: string[] = []\n\tfor (const leaf of rendered.table.leaves) {\n\t\tif (visible.has(leaf.id)) continue\n\t\tconst current = latestById.get(leaf.id)\n\t\tif (!current) continue\n\t\tif (\n\t\t\tMath.abs(current.centroid.x - leaf.centroid.x) > MOVED_LEAF_EPSILON ||\n\t\t\tMath.abs(current.centroid.y - leaf.centroid.y) > MOVED_LEAF_EPSILON\n\t\t) {\n\t\t\tmoved.push(leaf.id)\n\t\t}\n\t}\n\treturn moved\n}\n\nfunction getClusterZoomBounds(editor: Editor): ClusterZoomBounds {\n\tconst cameraOptions = editor.getCameraOptions()\n\tconst baseZoom = cameraOptions.constraints ? editor.getBaseZoom() : 1\n\tconst zoomSteps = cameraOptions.zoomSteps\n\treturn {\n\t\tminZoom: zoomSteps[0] * baseZoom,\n\t\tmaxZoom: zoomSteps[zoomSteps.length - 1] * baseZoom,\n\t}\n}\n\n/**\n * Bring a thread's pin into view: switch pages if needed, then zoom to the first cluster split\n * that unfolds it from its badge (or just centre on it when it isn't clustered).\n */\nexport function revealThreadPin(\n\teditor: Editor,\n\tthread: TLCommentThread,\n\ttable: ClusterTable,\n\tzoomBounds: ClusterZoomBounds,\n\toptions: CommentingOptions,\n\tduration = 200\n) {\n\tif (thread.pageId !== editor.getCurrentPageId()) {\n\t\teditor.setCurrentPage(thread.pageId as any)\n\t}\n\n\tconst point = anchorPagePoint(editor, thread.anchor)\n\tif (!point) return\n\n\t// With clustering off the pin always renders individually, so skip the zoom-to-split (its cluster\n\t// badge never exists) and just center on the pin.\n\tif (options.enableClustering) {\n\t\tconst parentEvent = findDirectParentEvent(table, thread.id)\n\t\tif (\n\t\t\tparentEvent &&\n\t\t\tNumber.isFinite(parentEvent.zSplit) &&\n\t\t\tparentEvent.zSplit <= zoomBounds.maxZoom\n\t\t) {\n\t\t\tconst zoom = clamp(\n\t\t\t\tparentEvent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,\n\t\t\t\tzoomBounds.minZoom,\n\t\t\t\tzoomBounds.maxZoom\n\t\t\t)\n\t\t\tcenterOnPointAtZoom(editor, point, zoom, duration)\n\t\t\treturn\n\t\t}\n\t}\n\n\tconst offset = commentCenterScreenOffset(editor) / editor.getZoomLevel()\n\teditor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration } })\n}\n\n/**\n * Zoom to just past the zoom at which a cluster first unclusters, centered on its centroid. The\n * event that created a visible cluster is the event that splits it, and has the smallest zSplit of\n * everything applied inside it \u2014 so its zSplit is exactly the first split within those comments.\n * The animated zoom drives the runtime cursor like any manual zoom. A no-op with no split event.\n */\nexport function zoomToClusterSplit(\n\teditor: Editor,\n\ttable: ClusterTable,\n\tzoomBounds: ClusterZoomBounds,\n\tnode: ClusterNode\n) {\n\tconst event = table.events.find((e) => e.result.id === node.id)\n\tif (!event || !Number.isFinite(event.zSplit)) return\n\tconst zoom = clamp(\n\t\tevent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,\n\t\tzoomBounds.minZoom,\n\t\tzoomBounds.maxZoom\n\t)\n\tcenterOnPointAtZoom(editor, node.centroid, zoom, CLUSTER_EXPAND_ZOOM_MS)\n}\n\nfunction findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {\n\treturn table.events.find((event) => event.children.some((child) => child.id === threadId))\n}\n\nfunction centerOnPointAtZoom(\n\teditor: Editor,\n\tpoint: { x: number; y: number },\n\tzoom: number,\n\tduration = 200\n) {\n\tconst viewport = editor.getViewportScreenBounds()\n\t// The open sidebar shifts the target left so the pin lands mid-uncovered-area, not under it.\n\tconst offset = commentCenterScreenOffset(editor)\n\teditor.setCamera(\n\t\t{\n\t\t\tx: (viewport.w / 2 - offset) / zoom - point.x,\n\t\t\ty: viewport.h / (2 * zoom) - point.y,\n\t\t\tz: zoom,\n\t\t},\n\t\t{ animation: { duration } }\n\t)\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n\treturn Math.max(min, Math.min(max, value))\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { useEffect, useMemo, useRef, useState } from 'react'\nimport { Editor, react, TLCommentThread, useValue } from 'tldraw'\nimport { computeClusterTable } from '../clustering/computeClusterTable'\nimport { type ClusterRuntime, createClusterRuntime } from '../clustering/runtime'\nimport type { ClusterNode, ClusterTable, MergeEvent } from '../clustering/types'\nimport { type ClusterFadeNode, useFadeVisibleNodes } from './cluster-fade'\nimport {\n\ttype ClusterInput,\n\tclusterInputEqual,\n\tclusterInputIdsEqual,\n\tcollectClusterLeaves,\n} from './cluster-input'\nimport { type CommentingOptions } from './options'\nimport { openThreadId } from './state'\nimport { anchorPagePoint, commentCenterScreenOffset } from './thread-state'\n\n/** Duration of the click-a-badge zoom-to-split animation. */\nexport const CLUSTER_EXPAND_ZOOM_MS = 450\n/** How far past a cluster's split zoom to land when expanding it \u2014 a 5% overshoot, so the badge\n * lands clear of the threshold it just crossed rather than flickering on it. */\nconst CLUSTER_SPLIT_ZOOM_FACTOR = 1.05\n\nconst EMPTY_SET: ReadonlySet<string> = new Set()\nconst MOVED_LEAF_EPSILON = 1e-6\n\n/** Default select-tool states that move shapes continuously, one store write per pointermove. A\n * custom tool in their place just never matches, falling back to a rebuild per frame. */\nconst SHAPE_DRAG_STATE_PATHS = [\n\t'select.translating',\n\t'select.resizing',\n\t'select.rotating',\n\t'select.dragging_handle',\n\t'select.crop.cropping',\n] as const\n\n/** Reactive: `isInAny` reads the tool state path, so a computed reading this re-evaluates when the\n * gesture starts or settles. */\nfunction isShapeDragInProgress(editor: Editor): boolean {\n\treturn editor.isInAny(...SHAPE_DRAG_STATE_PATHS)\n}\n\n/** The clustering table for the current scene, plus the runtime walking its merge events. */\nexport interface ClusterModel {\n\truntime: ClusterRuntime\n\ttable: ClusterTable\n}\n\nexport interface ClusterZoomBounds {\n\tminZoom: number\n\tmaxZoom: number\n}\n\nexport interface ClusterModelState {\n\t/** The partition actually on screen. See {@link useClusterModel} for why it can lag the input. */\n\tmodel: ClusterModel\n\tzoomBounds: ClusterZoomBounds\n\t/** The displayed nodes, each tagged with its cross-fade phase. */\n\tfadeNodes: ClusterFadeNode[]\n\t/** Threads in the input that the displayed partition doesn't show \u2014 render them as plain pins. */\n\torphanThreads: TLCommentThread[]\n\t/** Threads held out of clustering because their anchor moved while folded into a badge. */\n\theldThreads: TLCommentThread[]\n}\n\n/**\n * The clustering state machine behind the comments layer.\n *\n * The core invariant: the only thing that re-flows clustering doc-wide is zoom. Every rebuild is\n * computed immediately as `latestModel`, but the on-screen partition is `renderedModel`, which only\n * changes via (a) the cursor walking on zoom, (b) adoption of the pending rebuild on zoom-out, or\n * (c) LOCAL detach patches, where a leaf that left the input is detached from its own badge in\n * place and nothing else on the canvas moves.\n *\n * Threads the displayed partition can't represent are returned separately (`orphanThreads`,\n * `heldThreads`) for the layer to draw as ordinary pins.\n */\nexport function useClusterModel(\n\teditor: Editor,\n\tthreads: readonly TLCommentThread[],\n\topenId: string | null\n): ClusterModelState {\n\t// Threads held out of clustering because their anchor moved while folded inside a badge\n\t// (drag, nudge, align, undo, a collaborator \u2014 detected by position, not gesture). They render\n\t// as live pins riding their anchor and rejoin clustering on the next zoom-out.\n\tconst [heldThreadIds, setHeldThreadIds] = useState<ReadonlySet<string>>(EMPTY_SET)\n\tconst adoptOnRebuild = useRef(false)\n\t// This input's identity keys the O(N\u00B2) table rebuild below, so it's gated on value equality: a\n\t// reply, a reaction, a resolve \u2014 anything that touches comment records without moving a pin \u2014\n\t// returns the previous input and rebuilds nothing.\n\t//\n\t// Mid-drag the gate ignores positions too \u2014 except a folded leaf's, which a badge can't\n\t// follow. Its first move passes through, the pop-out below holds the pin out as a live pin,\n\t// and the input is static again for the rest of the drag.\n\tconst clusterInputRef = useRef<ClusterInput>({ leaves: [], screenOffsets: undefined })\n\tconst renderedModelRef = useRef<ClusterModel | null>(null)\n\tconst clusterInput = useValue(\n\t\t'comment cluster leaves',\n\t\t() => {\n\t\t\tconst next = collectClusterLeaves(\n\t\t\t\teditor,\n\t\t\t\tthreads.filter((thread) => !heldThreadIds.has(thread.id)),\n\t\t\t\topenThreadId.get(editor)\n\t\t\t)\n\t\t\tconst prev = clusterInputRef.current\n\t\t\tif (\n\t\t\t\tisShapeDragInProgress(editor) &&\n\t\t\t\tclusterInputIdsEqual(prev, next) &&\n\t\t\t\t!anyFoldedLeafMoved(prev, next, renderedModelRef.current)\n\t\t\t) {\n\t\t\t\treturn prev\n\t\t\t}\n\t\t\tif (clusterInputEqual(prev, next)) return prev\n\t\t\tclusterInputRef.current = next\n\t\t\treturn next\n\t\t},\n\t\t[editor, threads, heldThreadIds]\n\t)\n\tconst clusterZoomBounds = useValue(\n\t\t'comment cluster zoom bounds',\n\t\t() => getClusterZoomBounds(editor),\n\t\t[editor]\n\t)\n\tconst latestModel = useMemo(() => {\n\t\tconst table = computeClusterTable(\n\t\t\tclusterInput.leaves,\n\t\t\tclusterZoomBounds,\n\t\t\tclusterInput.screenOffsets\n\t\t)\n\t\tconst runtime = createClusterRuntime(table)\n\t\truntime.seed(editor.getZoomLevel())\n\t\treturn { runtime, table }\n\t}, [clusterInput, clusterZoomBounds, editor])\n\tconst [renderedModel, setRenderedModel] = useState(latestModel)\n\tlet clusterModel = renderedModel\n\t// A page switch replaces the whole scene: hard-reset rather than detach the world.\n\tconst pageId = useValue('comment cluster page', () => editor.getCurrentPageId(), [editor])\n\tconst pageRef = useRef(pageId)\n\tif (pageRef.current !== pageId) {\n\t\tpageRef.current = pageId\n\t\tadoptOnRebuild.current = false\n\t\tlatestModel.runtime.seed(editor.getZoomLevel())\n\t\tif (heldThreadIds.size > 0) setHeldThreadIds(EMPTY_SET)\n\t\tsetRenderedModel(latestModel)\n\t\tclusterModel = latestModel\n\t}\n\t// adoptOnRebuild is set outside React's render cycle, paired with clearing heldThreadIds. Only trust\n\t// it once that pairing is visible here, or an unrelated re-render can land in the gap.\n\tconst rejoinPending = heldThreadIds.size === 0 && adoptOnRebuild.current\n\tif (renderedModel !== latestModel && rejoinPending) {\n\t\tadoptOnRebuild.current = false\n\t\t// Carryover seed: band events inherit the outgoing partition's merged/unmerged state, so\n\t\t// nothing changes state because of the swap alone. Idempotent, so safe during render.\n\t\tlatestModel.runtime.seedFrom(editor.getZoomLevel(), renderedModel.runtime.getVisible())\n\t\tsetRenderedModel(latestModel)\n\t\tclusterModel = latestModel\n\t} else if (heldThreadIds.size === 0 && renderedModel === latestModel) {\n\t\t// Nothing pending and nothing to adopt: clear any leftover force-adopt intent so it can't\n\t\t// survive to force-adopt a later, unrelated rebuild.\n\t\tadoptOnRebuild.current = false\n\t}\n\t// For the input gate above: folded-vs-visible is judged against what's on screen.\n\trenderedModelRef.current = clusterModel\n\t// Pop-out detection: a leaf folded inside a badge can't follow its anchor (the badge position\n\t// is baked into the model), so when its live position drifts from the baked one, hold it out.\n\t// It renders as a live pin riding the anchor; the detach loop below shrinks its badge locally.\n\tconst newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel)\n\tif (newlyMovedIds.length > 0) {\n\t\tconst next = new Set(heldThreadIds)\n\t\tfor (const id of newlyMovedIds) next.add(id)\n\t\tsetHeldThreadIds(next)\n\t}\n\t// Local partition maintenance \u2014 the only non-zoom visual change. Any displayed leaf that has left the\n\t// cluster input is detached from its badge in place; the corrected rebuild already sits in latestModel\n\t// awaiting the next zoom-out. When the two models match, the leaf sets are identical, so skip the scan.\n\tif (clusterModel !== latestModel) {\n\t\tconst latestLeafIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))\n\t\tconst removedLeafIds: string[] = []\n\t\tfor (const leaf of clusterModel.table.leaves) {\n\t\t\tif (!latestLeafIds.has(leaf.id)) {\n\t\t\t\tremovedLeafIds.push(leaf.id)\n\t\t\t}\n\t\t}\n\t\t// Batched: one patch rebuild and one version bump for the whole set.\n\t\tif (removedLeafIds.length > 0) clusterModel.runtime.detachLeaves(removedLeafIds)\n\t}\n\t// Moved pins rejoin clustering on the next zoom-out motion: clear the set (so the rebuild\n\t// includes them again) and adopt that rebuild immediately instead of deferring it. Zooming in\n\t// never folds pins into clusters \u2014 merging is a zoom-out-only move, matching the runtime.\n\tuseEffect(() => {\n\t\tif (heldThreadIds.size === 0) return\n\t\tlet lastZoom = editor.getZoomLevel()\n\t\treturn react('rejoin moved comment pins on zoom out', () => {\n\t\t\tconst zoom = editor.getZoomLevel()\n\t\t\tconst prevZoom = lastZoom\n\t\t\tlastZoom = zoom\n\t\t\tif (zoom >= prevZoom) return\n\t\t\tadoptOnRebuild.current = true\n\t\t\tsetHeldThreadIds(EMPTY_SET)\n\t\t})\n\t}, [heldThreadIds, editor])\n\t// Adopt a pending rebuild only on zoom-out motion: folding deferred additions into clusters is\n\t// a merge, and merging only happens while zooming out. While zooming in, the stale table still\n\t// splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).\n\tuseEffect(() => {\n\t\tif (clusterModel === latestModel) return\n\t\tlet lastZoom = editor.getZoomLevel()\n\t\treturn react('adopt pending cluster model on zoom out', () => {\n\t\t\tconst zoom = editor.getZoomLevel()\n\t\t\tconst prevZoom = lastZoom\n\t\t\tlastZoom = zoom\n\t\t\tif (zoom >= prevZoom) return\n\t\t\tlatestModel.runtime.seedFrom(zoom, clusterModel.runtime.getVisible())\n\t\t\tsetRenderedModel(latestModel)\n\t\t})\n\t}, [clusterModel, latestModel, editor])\n\t// Threads the displayed partition doesn't show anywhere (new comments, reopened threads, undone\n\t// deletions) render as plain pins until the next zoom-out folds them in. Judged against the\n\t// displayed partition, so a detached-then-restored leaf reappears.\n\tconst partitionVersion = clusterModel.runtime.version\n\tconst orphanThreads = useMemo(() => {\n\t\tif (clusterModel === latestModel) return []\n\t\tconst displayed = new Set<string>()\n\t\tfor (const node of clusterModel.runtime.getVisible().values()) {\n\t\t\tfor (const member of node.members) displayed.add(member)\n\t\t}\n\t\tconst latestIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))\n\t\treturn threads.filter((thread) => latestIds.has(thread.id) && !displayed.has(thread.id))\n\t\t// The runtime mutates its partition in place; partitionVersion is its change stamp.\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [clusterModel, latestModel, threads, partitionVersion])\n\tconst heldThreads = useMemo(\n\t\t() => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),\n\t\t[threads, heldThreadIds, openId]\n\t)\n\t// Subscribe to the runtime's partition version, not the raw zoom, so this only re-renders on cluster\n\t// changes rather than every camera frame. The memo below re-reads the version inline because\n\t// render-time detaches bump it after the subscription's computed already evaluated.\n\tuseValue(\n\t\t'comment cluster version',\n\t\t() => {\n\t\t\tclusterModel.runtime.onCamera(editor.getZoomLevel())\n\t\t\treturn clusterModel.runtime.version\n\t\t},\n\t\t[clusterModel, editor]\n\t)\n\tconst visibleNodes = useMemo(() => {\n\t\treturn Array.from(clusterModel.runtime.getVisible().values())\n\t\t// The runtime mutates its partition in place; partitionVersion is its change stamp.\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [clusterModel, partitionVersion])\n\tconst fadeNodes = useFadeVisibleNodes(visibleNodes, clusterModel)\n\n\treturn {\n\t\tmodel: clusterModel,\n\t\tzoomBounds: clusterZoomBounds,\n\t\tfadeNodes,\n\t\torphanThreads,\n\t\theldThreads,\n\t}\n}\n\n/**\n * Whether a position-only input change (ids equal, same order) moved a leaf folded inside a badge\n * of the rendered partition \u2014 the one move the mid-drag freeze must let through.\n *\n * Folded means a member of a displayed badge, not merely \"absent from the displayed partition\":\n * the input also carries orphans (threads the rendered partition has never seen) and detached\n * leaves, which already ride their anchors as plain pins. Neither is in the rendered table, so the\n * pop-out below can never hold one \u2014 counting them here would break the freeze on every\n * pointermove for the rest of the drag.\n * @internal\n */\nexport function anyFoldedLeafMoved(\n\tprev: ClusterInput,\n\tnext: ClusterInput,\n\trendered: ClusterModel | null\n): boolean {\n\tif (!rendered) return false\n\tif (prev.leaves.length !== next.leaves.length) return false\n\tconst folded = new Set<string>()\n\tfor (const node of rendered.runtime.getVisible().values()) {\n\t\tif (node.count < 2) continue\n\t\tfor (const member of node.members) folded.add(member)\n\t}\n\t// No badges on screen, so nothing can be folded \u2014 the common case, and the cheap way out of it.\n\tif (folded.size === 0) return false\n\tfor (let i = 0; i < next.leaves.length; i++) {\n\t\tif (!folded.has(next.leaves[i].id)) continue\n\t\tconst a = prev.leaves[i].point\n\t\tconst b = next.leaves[i].point\n\t\tif (Math.abs(a.x - b.x) > MOVED_LEAF_EPSILON || Math.abs(a.y - b.y) > MOVED_LEAF_EPSILON) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n/**\n * Leaves folded inside a badge whose live anchor no longer matches the position the rendered\n * model was built with. Visible (unclustered) leaf pins track their anchor live, so they can\n * stay deferred; a badge can't follow a member, so these must pop out of clustering.\n */\nfunction findMovedClusteredLeafIds(rendered: ClusterModel, latest: { table: ClusterTable }) {\n\tif (rendered.table === latest.table) return []\n\tconst visible = rendered.runtime.getVisible()\n\tconst latestById = new Map(latest.table.leaves.map((leaf) => [leaf.id, leaf]))\n\tconst moved: string[] = []\n\tfor (const leaf of rendered.table.leaves) {\n\t\tif (visible.has(leaf.id)) continue\n\t\tconst current = latestById.get(leaf.id)\n\t\tif (!current) continue\n\t\tif (\n\t\t\tMath.abs(current.centroid.x - leaf.centroid.x) > MOVED_LEAF_EPSILON ||\n\t\t\tMath.abs(current.centroid.y - leaf.centroid.y) > MOVED_LEAF_EPSILON\n\t\t) {\n\t\t\tmoved.push(leaf.id)\n\t\t}\n\t}\n\treturn moved\n}\n\nfunction getClusterZoomBounds(editor: Editor): ClusterZoomBounds {\n\tconst cameraOptions = editor.getCameraOptions()\n\tconst baseZoom = cameraOptions.constraints ? editor.getBaseZoom() : 1\n\tconst zoomSteps = cameraOptions.zoomSteps\n\treturn {\n\t\tminZoom: zoomSteps[0] * baseZoom,\n\t\tmaxZoom: zoomSteps[zoomSteps.length - 1] * baseZoom,\n\t}\n}\n\n/**\n * Bring a thread's pin into view: switch pages if needed, then zoom to the first cluster split\n * that unfolds it from its badge (or just centre on it when it isn't clustered).\n */\nexport function revealThreadPin(\n\teditor: Editor,\n\tthread: TLCommentThread,\n\ttable: ClusterTable,\n\tzoomBounds: ClusterZoomBounds,\n\toptions: CommentingOptions,\n\tduration = 200\n) {\n\tif (thread.pageId !== editor.getCurrentPageId()) {\n\t\teditor.setCurrentPage(thread.pageId as any)\n\t}\n\n\tconst point = anchorPagePoint(editor, thread.anchor)\n\tif (!point) return\n\n\t// With clustering off the pin always renders individually, so skip the zoom-to-split (its cluster\n\t// badge never exists) and just center on the pin.\n\tif (options.enableClustering) {\n\t\tconst parentEvent = findDirectParentEvent(table, thread.id)\n\t\tif (\n\t\t\tparentEvent &&\n\t\t\tNumber.isFinite(parentEvent.zSplit) &&\n\t\t\tparentEvent.zSplit <= zoomBounds.maxZoom\n\t\t) {\n\t\t\tconst zoom = clamp(\n\t\t\t\tparentEvent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,\n\t\t\t\tzoomBounds.minZoom,\n\t\t\t\tzoomBounds.maxZoom\n\t\t\t)\n\t\t\tcenterOnPointAtZoom(editor, point, zoom, duration)\n\t\t\treturn\n\t\t}\n\t}\n\n\tconst offset = commentCenterScreenOffset(editor) / editor.getZoomLevel()\n\teditor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration } })\n}\n\n/**\n * Zoom to just past the zoom at which a cluster first unclusters, centered on its centroid. The\n * event that created a visible cluster is the event that splits it, and has the smallest zSplit of\n * everything applied inside it \u2014 so its zSplit is exactly the first split within those comments.\n * The animated zoom drives the runtime cursor like any manual zoom. A no-op with no split event.\n */\nexport function zoomToClusterSplit(\n\teditor: Editor,\n\ttable: ClusterTable,\n\tzoomBounds: ClusterZoomBounds,\n\tnode: ClusterNode\n) {\n\tconst event = table.events.find((e) => e.result.id === node.id)\n\tif (!event || !Number.isFinite(event.zSplit)) return\n\tconst zoom = clamp(\n\t\tevent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,\n\t\tzoomBounds.minZoom,\n\t\tzoomBounds.maxZoom\n\t)\n\tcenterOnPointAtZoom(editor, node.centroid, zoom, CLUSTER_EXPAND_ZOOM_MS)\n}\n\nfunction findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {\n\treturn table.events.find((event) => event.children.some((child) => child.id === threadId))\n}\n\nfunction centerOnPointAtZoom(\n\teditor: Editor,\n\tpoint: { x: number; y: number },\n\tzoom: number,\n\tduration = 200\n) {\n\tconst viewport = editor.getViewportScreenBounds()\n\t// The open sidebar shifts the target left so the pin lands mid-uncovered-area, not under it.\n\tconst offset = commentCenterScreenOffset(editor)\n\teditor.setCamera(\n\t\t{\n\t\t\tx: (viewport.w / 2 - offset) / zoom - point.x,\n\t\t\ty: viewport.h / (2 * zoom) - point.y,\n\t\t\tz: zoom,\n\t\t},\n\t\t{ animation: { duration } }\n\t)\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n\treturn Math.max(min, Math.min(max, value))\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/dist-cjs/index.js CHANGED
@@ -144,7 +144,7 @@ var import_state = require("./canvas/state");
144
144
  var import_thread_state = require("./canvas/thread-state");
145
145
  (0, import_utils.registerTldrawLibraryVersion)(
146
146
  "@tldraw/commenting",
147
- "5.3.0-next.dec36c5c2930",
147
+ "5.3.0",
148
148
  "cjs"
149
149
  );
150
150
  //# sourceMappingURL=index.js.map
@@ -28,6 +28,7 @@ function useClusterModel(editor, threads, openId) {
28
28
  const [heldThreadIds, setHeldThreadIds] = useState(EMPTY_SET);
29
29
  const adoptOnRebuild = useRef(false);
30
30
  const clusterInputRef = useRef({ leaves: [], screenOffsets: void 0 });
31
+ const renderedModelRef = useRef(null);
31
32
  const clusterInput = useValue(
32
33
  "comment cluster leaves",
33
34
  () => {
@@ -37,7 +38,9 @@ function useClusterModel(editor, threads, openId) {
37
38
  openThreadId.get(editor)
38
39
  );
39
40
  const prev = clusterInputRef.current;
40
- if (isShapeDragInProgress(editor) && clusterInputIdsEqual(prev, next)) return prev;
41
+ if (isShapeDragInProgress(editor) && clusterInputIdsEqual(prev, next) && !anyFoldedLeafMoved(prev, next, renderedModelRef.current)) {
42
+ return prev;
43
+ }
41
44
  if (clusterInputEqual(prev, next)) return prev;
42
45
  clusterInputRef.current = next;
43
46
  return next;
@@ -80,6 +83,7 @@ function useClusterModel(editor, threads, openId) {
80
83
  } else if (heldThreadIds.size === 0 && renderedModel === latestModel) {
81
84
  adoptOnRebuild.current = false;
82
85
  }
86
+ renderedModelRef.current = clusterModel;
83
87
  const newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel);
84
88
  if (newlyMovedIds.length > 0) {
85
89
  const next = new Set(heldThreadIds);
@@ -154,6 +158,25 @@ function useClusterModel(editor, threads, openId) {
154
158
  heldThreads
155
159
  };
156
160
  }
161
+ function anyFoldedLeafMoved(prev, next, rendered) {
162
+ if (!rendered) return false;
163
+ if (prev.leaves.length !== next.leaves.length) return false;
164
+ const folded = /* @__PURE__ */ new Set();
165
+ for (const node of rendered.runtime.getVisible().values()) {
166
+ if (node.count < 2) continue;
167
+ for (const member of node.members) folded.add(member);
168
+ }
169
+ if (folded.size === 0) return false;
170
+ for (let i = 0; i < next.leaves.length; i++) {
171
+ if (!folded.has(next.leaves[i].id)) continue;
172
+ const a = prev.leaves[i].point;
173
+ const b = next.leaves[i].point;
174
+ if (Math.abs(a.x - b.x) > MOVED_LEAF_EPSILON || Math.abs(a.y - b.y) > MOVED_LEAF_EPSILON) {
175
+ return true;
176
+ }
177
+ }
178
+ return false;
179
+ }
157
180
  function findMovedClusteredLeafIds(rendered, latest) {
158
181
  if (rendered.table === latest.table) return [];
159
182
  const visible = rendered.runtime.getVisible();
@@ -229,6 +252,7 @@ function clamp(value, min, max) {
229
252
  }
230
253
  export {
231
254
  CLUSTER_EXPAND_ZOOM_MS,
255
+ anyFoldedLeafMoved,
232
256
  revealThreadPin,
233
257
  useClusterModel,
234
258
  zoomToClusterSplit
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/canvas/cluster-model.ts"],
4
- "sourcesContent": ["import { useEffect, useMemo, useRef, useState } from 'react'\nimport { Editor, react, TLCommentThread, useValue } from 'tldraw'\nimport { computeClusterTable } from '../clustering/computeClusterTable'\nimport { type ClusterRuntime, createClusterRuntime } from '../clustering/runtime'\nimport type { ClusterNode, ClusterTable, MergeEvent } from '../clustering/types'\nimport { type ClusterFadeNode, useFadeVisibleNodes } from './cluster-fade'\nimport {\n\ttype ClusterInput,\n\tclusterInputEqual,\n\tclusterInputIdsEqual,\n\tcollectClusterLeaves,\n} from './cluster-input'\nimport { type CommentingOptions } from './options'\nimport { openThreadId } from './state'\nimport { anchorPagePoint, commentCenterScreenOffset } from './thread-state'\n\n/** Duration of the click-a-badge zoom-to-split animation. */\nexport const CLUSTER_EXPAND_ZOOM_MS = 450\n/** How far past a cluster's split zoom to land when expanding it \u2014 a 5% overshoot, so the badge\n * lands clear of the threshold it just crossed rather than flickering on it. */\nconst CLUSTER_SPLIT_ZOOM_FACTOR = 1.05\n\nconst EMPTY_SET: ReadonlySet<string> = new Set()\nconst MOVED_LEAF_EPSILON = 1e-6\n\n/** Default select-tool states that move shapes continuously, one store write per pointermove. A\n * custom tool in their place just never matches, falling back to a rebuild per frame. */\nconst SHAPE_DRAG_STATE_PATHS = [\n\t'select.translating',\n\t'select.resizing',\n\t'select.rotating',\n\t'select.dragging_handle',\n\t'select.crop.cropping',\n] as const\n\n/** Reactive: `isInAny` reads the tool state path, so a computed reading this re-evaluates when the\n * gesture starts or settles. */\nfunction isShapeDragInProgress(editor: Editor): boolean {\n\treturn editor.isInAny(...SHAPE_DRAG_STATE_PATHS)\n}\n\n/** The clustering table for the current scene, plus the runtime walking its merge events. */\nexport interface ClusterModel {\n\truntime: ClusterRuntime\n\ttable: ClusterTable\n}\n\nexport interface ClusterZoomBounds {\n\tminZoom: number\n\tmaxZoom: number\n}\n\nexport interface ClusterModelState {\n\t/** The partition actually on screen. See {@link useClusterModel} for why it can lag the input. */\n\tmodel: ClusterModel\n\tzoomBounds: ClusterZoomBounds\n\t/** The displayed nodes, each tagged with its cross-fade phase. */\n\tfadeNodes: ClusterFadeNode[]\n\t/** Threads in the input that the displayed partition doesn't show \u2014 render them as plain pins. */\n\torphanThreads: TLCommentThread[]\n\t/** Threads held out of clustering because their anchor moved while folded into a badge. */\n\theldThreads: TLCommentThread[]\n}\n\n/**\n * The clustering state machine behind the comments layer.\n *\n * The core invariant: the only thing that re-flows clustering doc-wide is zoom. Every rebuild is\n * computed immediately as `latestModel`, but the on-screen partition is `renderedModel`, which only\n * changes via (a) the cursor walking on zoom, (b) adoption of the pending rebuild on zoom-out, or\n * (c) LOCAL detach patches, where a leaf that left the input is detached from its own badge in\n * place and nothing else on the canvas moves.\n *\n * Threads the displayed partition can't represent are returned separately (`orphanThreads`,\n * `heldThreads`) for the layer to draw as ordinary pins.\n */\nexport function useClusterModel(\n\teditor: Editor,\n\tthreads: readonly TLCommentThread[],\n\topenId: string | null\n): ClusterModelState {\n\t// Threads held out of clustering because their anchor moved while folded inside a badge\n\t// (drag, nudge, align, undo, a collaborator \u2014 detected by position, not gesture). They render\n\t// as live pins riding their anchor and rejoin clustering on the next zoom-out.\n\tconst [heldThreadIds, setHeldThreadIds] = useState<ReadonlySet<string>>(EMPTY_SET)\n\tconst adoptOnRebuild = useRef(false)\n\t// This input's identity keys the O(N\u00B2) table rebuild below, so it's gated on value equality: a\n\t// reply, a reaction, a resolve \u2014 anything that touches comment records without moving a pin \u2014\n\t// returns the previous input and rebuilds nothing.\n\t//\n\t// Mid-drag the gate widens to ignore positions too, and that costs something real: it freezes\n\t// `latestModel`, which is where `findMovedClusteredLeafIds` reads live positions from, so a pin\n\t// folded into a badge stops popping out to ride its anchor and corrects on release instead. The\n\t// per-frame rebuild it buys back was paid by every drag on the board, comment-anchored or not.\n\t// An added or deleted thread changes the id set, so it still rebuilds promptly.\n\tconst clusterInputRef = useRef<ClusterInput>({ leaves: [], screenOffsets: undefined })\n\tconst clusterInput = useValue(\n\t\t'comment cluster leaves',\n\t\t() => {\n\t\t\tconst next = collectClusterLeaves(\n\t\t\t\teditor,\n\t\t\t\tthreads.filter((thread) => !heldThreadIds.has(thread.id)),\n\t\t\t\topenThreadId.get(editor)\n\t\t\t)\n\t\t\tconst prev = clusterInputRef.current\n\t\t\tif (isShapeDragInProgress(editor) && clusterInputIdsEqual(prev, next)) return prev\n\t\t\tif (clusterInputEqual(prev, next)) return prev\n\t\t\tclusterInputRef.current = next\n\t\t\treturn next\n\t\t},\n\t\t[editor, threads, heldThreadIds]\n\t)\n\tconst clusterZoomBounds = useValue(\n\t\t'comment cluster zoom bounds',\n\t\t() => getClusterZoomBounds(editor),\n\t\t[editor]\n\t)\n\tconst latestModel = useMemo(() => {\n\t\tconst table = computeClusterTable(\n\t\t\tclusterInput.leaves,\n\t\t\tclusterZoomBounds,\n\t\t\tclusterInput.screenOffsets\n\t\t)\n\t\tconst runtime = createClusterRuntime(table)\n\t\truntime.seed(editor.getZoomLevel())\n\t\treturn { runtime, table }\n\t}, [clusterInput, clusterZoomBounds, editor])\n\tconst [renderedModel, setRenderedModel] = useState(latestModel)\n\tlet clusterModel = renderedModel\n\t// A page switch replaces the whole scene: hard-reset rather than detach the world.\n\tconst pageId = useValue('comment cluster page', () => editor.getCurrentPageId(), [editor])\n\tconst pageRef = useRef(pageId)\n\tif (pageRef.current !== pageId) {\n\t\tpageRef.current = pageId\n\t\tadoptOnRebuild.current = false\n\t\tlatestModel.runtime.seed(editor.getZoomLevel())\n\t\tif (heldThreadIds.size > 0) setHeldThreadIds(EMPTY_SET)\n\t\tsetRenderedModel(latestModel)\n\t\tclusterModel = latestModel\n\t}\n\t// adoptOnRebuild is set outside React's render cycle, paired with clearing heldThreadIds. Only trust\n\t// it once that pairing is visible here, or an unrelated re-render can land in the gap.\n\tconst rejoinPending = heldThreadIds.size === 0 && adoptOnRebuild.current\n\tif (renderedModel !== latestModel && rejoinPending) {\n\t\tadoptOnRebuild.current = false\n\t\t// Carryover seed: band events inherit the outgoing partition's merged/unmerged state, so\n\t\t// nothing changes state because of the swap alone. Idempotent, so safe during render.\n\t\tlatestModel.runtime.seedFrom(editor.getZoomLevel(), renderedModel.runtime.getVisible())\n\t\tsetRenderedModel(latestModel)\n\t\tclusterModel = latestModel\n\t} else if (heldThreadIds.size === 0 && renderedModel === latestModel) {\n\t\t// Nothing pending and nothing to adopt: clear any leftover force-adopt intent so it can't\n\t\t// survive to force-adopt a later, unrelated rebuild.\n\t\tadoptOnRebuild.current = false\n\t}\n\t// Pop-out detection: a leaf folded inside a badge can't follow its anchor (the badge position\n\t// is baked into the model), so when its live position drifts from the baked one, hold it out.\n\t// It renders as a live pin riding the anchor; the detach loop below shrinks its badge locally.\n\tconst newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel)\n\tif (newlyMovedIds.length > 0) {\n\t\tconst next = new Set(heldThreadIds)\n\t\tfor (const id of newlyMovedIds) next.add(id)\n\t\tsetHeldThreadIds(next)\n\t}\n\t// Local partition maintenance \u2014 the only non-zoom visual change. Any displayed leaf that has left the\n\t// cluster input is detached from its badge in place; the corrected rebuild already sits in latestModel\n\t// awaiting the next zoom-out. When the two models match, the leaf sets are identical, so skip the scan.\n\tif (clusterModel !== latestModel) {\n\t\tconst latestLeafIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))\n\t\tconst removedLeafIds: string[] = []\n\t\tfor (const leaf of clusterModel.table.leaves) {\n\t\t\tif (!latestLeafIds.has(leaf.id)) {\n\t\t\t\tremovedLeafIds.push(leaf.id)\n\t\t\t}\n\t\t}\n\t\t// Batched: one patch rebuild and one version bump for the whole set.\n\t\tif (removedLeafIds.length > 0) clusterModel.runtime.detachLeaves(removedLeafIds)\n\t}\n\t// Moved pins rejoin clustering on the next zoom-out motion: clear the set (so the rebuild\n\t// includes them again) and adopt that rebuild immediately instead of deferring it. Zooming in\n\t// never folds pins into clusters \u2014 merging is a zoom-out-only move, matching the runtime.\n\tuseEffect(() => {\n\t\tif (heldThreadIds.size === 0) return\n\t\tlet lastZoom = editor.getZoomLevel()\n\t\treturn react('rejoin moved comment pins on zoom out', () => {\n\t\t\tconst zoom = editor.getZoomLevel()\n\t\t\tconst prevZoom = lastZoom\n\t\t\tlastZoom = zoom\n\t\t\tif (zoom >= prevZoom) return\n\t\t\tadoptOnRebuild.current = true\n\t\t\tsetHeldThreadIds(EMPTY_SET)\n\t\t})\n\t}, [heldThreadIds, editor])\n\t// Adopt a pending rebuild only on zoom-out motion: folding deferred additions into clusters is\n\t// a merge, and merging only happens while zooming out. While zooming in, the stale table still\n\t// splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).\n\tuseEffect(() => {\n\t\tif (clusterModel === latestModel) return\n\t\tlet lastZoom = editor.getZoomLevel()\n\t\treturn react('adopt pending cluster model on zoom out', () => {\n\t\t\tconst zoom = editor.getZoomLevel()\n\t\t\tconst prevZoom = lastZoom\n\t\t\tlastZoom = zoom\n\t\t\tif (zoom >= prevZoom) return\n\t\t\tlatestModel.runtime.seedFrom(zoom, clusterModel.runtime.getVisible())\n\t\t\tsetRenderedModel(latestModel)\n\t\t})\n\t}, [clusterModel, latestModel, editor])\n\t// Threads the displayed partition doesn't show anywhere (new comments, reopened threads, undone\n\t// deletions) render as plain pins until the next zoom-out folds them in. Judged against the\n\t// displayed partition, so a detached-then-restored leaf reappears.\n\tconst partitionVersion = clusterModel.runtime.version\n\tconst orphanThreads = useMemo(() => {\n\t\tif (clusterModel === latestModel) return []\n\t\tconst displayed = new Set<string>()\n\t\tfor (const node of clusterModel.runtime.getVisible().values()) {\n\t\t\tfor (const member of node.members) displayed.add(member)\n\t\t}\n\t\tconst latestIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))\n\t\treturn threads.filter((thread) => latestIds.has(thread.id) && !displayed.has(thread.id))\n\t\t// The runtime mutates its partition in place; partitionVersion is its change stamp.\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [clusterModel, latestModel, threads, partitionVersion])\n\tconst heldThreads = useMemo(\n\t\t() => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),\n\t\t[threads, heldThreadIds, openId]\n\t)\n\t// Subscribe to the runtime's partition version, not the raw zoom, so this only re-renders on cluster\n\t// changes rather than every camera frame. The memo below re-reads the version inline because\n\t// render-time detaches bump it after the subscription's computed already evaluated.\n\tuseValue(\n\t\t'comment cluster version',\n\t\t() => {\n\t\t\tclusterModel.runtime.onCamera(editor.getZoomLevel())\n\t\t\treturn clusterModel.runtime.version\n\t\t},\n\t\t[clusterModel, editor]\n\t)\n\tconst visibleNodes = useMemo(() => {\n\t\treturn Array.from(clusterModel.runtime.getVisible().values())\n\t\t// The runtime mutates its partition in place; partitionVersion is its change stamp.\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [clusterModel, partitionVersion])\n\tconst fadeNodes = useFadeVisibleNodes(visibleNodes, clusterModel)\n\n\treturn {\n\t\tmodel: clusterModel,\n\t\tzoomBounds: clusterZoomBounds,\n\t\tfadeNodes,\n\t\torphanThreads,\n\t\theldThreads,\n\t}\n}\n\n/**\n * Leaves folded inside a badge whose live anchor no longer matches the position the rendered\n * model was built with. Visible (unclustered) leaf pins track their anchor live, so they can\n * stay deferred; a badge can't follow a member, so these must pop out of clustering.\n */\nfunction findMovedClusteredLeafIds(rendered: ClusterModel, latest: { table: ClusterTable }) {\n\tif (rendered.table === latest.table) return []\n\tconst visible = rendered.runtime.getVisible()\n\tconst latestById = new Map(latest.table.leaves.map((leaf) => [leaf.id, leaf]))\n\tconst moved: string[] = []\n\tfor (const leaf of rendered.table.leaves) {\n\t\tif (visible.has(leaf.id)) continue\n\t\tconst current = latestById.get(leaf.id)\n\t\tif (!current) continue\n\t\tif (\n\t\t\tMath.abs(current.centroid.x - leaf.centroid.x) > MOVED_LEAF_EPSILON ||\n\t\t\tMath.abs(current.centroid.y - leaf.centroid.y) > MOVED_LEAF_EPSILON\n\t\t) {\n\t\t\tmoved.push(leaf.id)\n\t\t}\n\t}\n\treturn moved\n}\n\nfunction getClusterZoomBounds(editor: Editor): ClusterZoomBounds {\n\tconst cameraOptions = editor.getCameraOptions()\n\tconst baseZoom = cameraOptions.constraints ? editor.getBaseZoom() : 1\n\tconst zoomSteps = cameraOptions.zoomSteps\n\treturn {\n\t\tminZoom: zoomSteps[0] * baseZoom,\n\t\tmaxZoom: zoomSteps[zoomSteps.length - 1] * baseZoom,\n\t}\n}\n\n/**\n * Bring a thread's pin into view: switch pages if needed, then zoom to the first cluster split\n * that unfolds it from its badge (or just centre on it when it isn't clustered).\n */\nexport function revealThreadPin(\n\teditor: Editor,\n\tthread: TLCommentThread,\n\ttable: ClusterTable,\n\tzoomBounds: ClusterZoomBounds,\n\toptions: CommentingOptions,\n\tduration = 200\n) {\n\tif (thread.pageId !== editor.getCurrentPageId()) {\n\t\teditor.setCurrentPage(thread.pageId as any)\n\t}\n\n\tconst point = anchorPagePoint(editor, thread.anchor)\n\tif (!point) return\n\n\t// With clustering off the pin always renders individually, so skip the zoom-to-split (its cluster\n\t// badge never exists) and just center on the pin.\n\tif (options.enableClustering) {\n\t\tconst parentEvent = findDirectParentEvent(table, thread.id)\n\t\tif (\n\t\t\tparentEvent &&\n\t\t\tNumber.isFinite(parentEvent.zSplit) &&\n\t\t\tparentEvent.zSplit <= zoomBounds.maxZoom\n\t\t) {\n\t\t\tconst zoom = clamp(\n\t\t\t\tparentEvent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,\n\t\t\t\tzoomBounds.minZoom,\n\t\t\t\tzoomBounds.maxZoom\n\t\t\t)\n\t\t\tcenterOnPointAtZoom(editor, point, zoom, duration)\n\t\t\treturn\n\t\t}\n\t}\n\n\tconst offset = commentCenterScreenOffset(editor) / editor.getZoomLevel()\n\teditor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration } })\n}\n\n/**\n * Zoom to just past the zoom at which a cluster first unclusters, centered on its centroid. The\n * event that created a visible cluster is the event that splits it, and has the smallest zSplit of\n * everything applied inside it \u2014 so its zSplit is exactly the first split within those comments.\n * The animated zoom drives the runtime cursor like any manual zoom. A no-op with no split event.\n */\nexport function zoomToClusterSplit(\n\teditor: Editor,\n\ttable: ClusterTable,\n\tzoomBounds: ClusterZoomBounds,\n\tnode: ClusterNode\n) {\n\tconst event = table.events.find((e) => e.result.id === node.id)\n\tif (!event || !Number.isFinite(event.zSplit)) return\n\tconst zoom = clamp(\n\t\tevent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,\n\t\tzoomBounds.minZoom,\n\t\tzoomBounds.maxZoom\n\t)\n\tcenterOnPointAtZoom(editor, node.centroid, zoom, CLUSTER_EXPAND_ZOOM_MS)\n}\n\nfunction findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {\n\treturn table.events.find((event) => event.children.some((child) => child.id === threadId))\n}\n\nfunction centerOnPointAtZoom(\n\teditor: Editor,\n\tpoint: { x: number; y: number },\n\tzoom: number,\n\tduration = 200\n) {\n\tconst viewport = editor.getViewportScreenBounds()\n\t// The open sidebar shifts the target left so the pin lands mid-uncovered-area, not under it.\n\tconst offset = commentCenterScreenOffset(editor)\n\teditor.setCamera(\n\t\t{\n\t\t\tx: (viewport.w / 2 - offset) / zoom - point.x,\n\t\t\ty: viewport.h / (2 * zoom) - point.y,\n\t\t\tz: zoom,\n\t\t},\n\t\t{ animation: { duration } }\n\t)\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n\treturn Math.max(min, Math.min(max, value))\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { useEffect, useMemo, useRef, useState } from 'react'\nimport { Editor, react, TLCommentThread, useValue } from 'tldraw'\nimport { computeClusterTable } from '../clustering/computeClusterTable'\nimport { type ClusterRuntime, createClusterRuntime } from '../clustering/runtime'\nimport type { ClusterNode, ClusterTable, MergeEvent } from '../clustering/types'\nimport { type ClusterFadeNode, useFadeVisibleNodes } from './cluster-fade'\nimport {\n\ttype ClusterInput,\n\tclusterInputEqual,\n\tclusterInputIdsEqual,\n\tcollectClusterLeaves,\n} from './cluster-input'\nimport { type CommentingOptions } from './options'\nimport { openThreadId } from './state'\nimport { anchorPagePoint, commentCenterScreenOffset } from './thread-state'\n\n/** Duration of the click-a-badge zoom-to-split animation. */\nexport const CLUSTER_EXPAND_ZOOM_MS = 450\n/** How far past a cluster's split zoom to land when expanding it \u2014 a 5% overshoot, so the badge\n * lands clear of the threshold it just crossed rather than flickering on it. */\nconst CLUSTER_SPLIT_ZOOM_FACTOR = 1.05\n\nconst EMPTY_SET: ReadonlySet<string> = new Set()\nconst MOVED_LEAF_EPSILON = 1e-6\n\n/** Default select-tool states that move shapes continuously, one store write per pointermove. A\n * custom tool in their place just never matches, falling back to a rebuild per frame. */\nconst SHAPE_DRAG_STATE_PATHS = [\n\t'select.translating',\n\t'select.resizing',\n\t'select.rotating',\n\t'select.dragging_handle',\n\t'select.crop.cropping',\n] as const\n\n/** Reactive: `isInAny` reads the tool state path, so a computed reading this re-evaluates when the\n * gesture starts or settles. */\nfunction isShapeDragInProgress(editor: Editor): boolean {\n\treturn editor.isInAny(...SHAPE_DRAG_STATE_PATHS)\n}\n\n/** The clustering table for the current scene, plus the runtime walking its merge events. */\nexport interface ClusterModel {\n\truntime: ClusterRuntime\n\ttable: ClusterTable\n}\n\nexport interface ClusterZoomBounds {\n\tminZoom: number\n\tmaxZoom: number\n}\n\nexport interface ClusterModelState {\n\t/** The partition actually on screen. See {@link useClusterModel} for why it can lag the input. */\n\tmodel: ClusterModel\n\tzoomBounds: ClusterZoomBounds\n\t/** The displayed nodes, each tagged with its cross-fade phase. */\n\tfadeNodes: ClusterFadeNode[]\n\t/** Threads in the input that the displayed partition doesn't show \u2014 render them as plain pins. */\n\torphanThreads: TLCommentThread[]\n\t/** Threads held out of clustering because their anchor moved while folded into a badge. */\n\theldThreads: TLCommentThread[]\n}\n\n/**\n * The clustering state machine behind the comments layer.\n *\n * The core invariant: the only thing that re-flows clustering doc-wide is zoom. Every rebuild is\n * computed immediately as `latestModel`, but the on-screen partition is `renderedModel`, which only\n * changes via (a) the cursor walking on zoom, (b) adoption of the pending rebuild on zoom-out, or\n * (c) LOCAL detach patches, where a leaf that left the input is detached from its own badge in\n * place and nothing else on the canvas moves.\n *\n * Threads the displayed partition can't represent are returned separately (`orphanThreads`,\n * `heldThreads`) for the layer to draw as ordinary pins.\n */\nexport function useClusterModel(\n\teditor: Editor,\n\tthreads: readonly TLCommentThread[],\n\topenId: string | null\n): ClusterModelState {\n\t// Threads held out of clustering because their anchor moved while folded inside a badge\n\t// (drag, nudge, align, undo, a collaborator \u2014 detected by position, not gesture). They render\n\t// as live pins riding their anchor and rejoin clustering on the next zoom-out.\n\tconst [heldThreadIds, setHeldThreadIds] = useState<ReadonlySet<string>>(EMPTY_SET)\n\tconst adoptOnRebuild = useRef(false)\n\t// This input's identity keys the O(N\u00B2) table rebuild below, so it's gated on value equality: a\n\t// reply, a reaction, a resolve \u2014 anything that touches comment records without moving a pin \u2014\n\t// returns the previous input and rebuilds nothing.\n\t//\n\t// Mid-drag the gate ignores positions too \u2014 except a folded leaf's, which a badge can't\n\t// follow. Its first move passes through, the pop-out below holds the pin out as a live pin,\n\t// and the input is static again for the rest of the drag.\n\tconst clusterInputRef = useRef<ClusterInput>({ leaves: [], screenOffsets: undefined })\n\tconst renderedModelRef = useRef<ClusterModel | null>(null)\n\tconst clusterInput = useValue(\n\t\t'comment cluster leaves',\n\t\t() => {\n\t\t\tconst next = collectClusterLeaves(\n\t\t\t\teditor,\n\t\t\t\tthreads.filter((thread) => !heldThreadIds.has(thread.id)),\n\t\t\t\topenThreadId.get(editor)\n\t\t\t)\n\t\t\tconst prev = clusterInputRef.current\n\t\t\tif (\n\t\t\t\tisShapeDragInProgress(editor) &&\n\t\t\t\tclusterInputIdsEqual(prev, next) &&\n\t\t\t\t!anyFoldedLeafMoved(prev, next, renderedModelRef.current)\n\t\t\t) {\n\t\t\t\treturn prev\n\t\t\t}\n\t\t\tif (clusterInputEqual(prev, next)) return prev\n\t\t\tclusterInputRef.current = next\n\t\t\treturn next\n\t\t},\n\t\t[editor, threads, heldThreadIds]\n\t)\n\tconst clusterZoomBounds = useValue(\n\t\t'comment cluster zoom bounds',\n\t\t() => getClusterZoomBounds(editor),\n\t\t[editor]\n\t)\n\tconst latestModel = useMemo(() => {\n\t\tconst table = computeClusterTable(\n\t\t\tclusterInput.leaves,\n\t\t\tclusterZoomBounds,\n\t\t\tclusterInput.screenOffsets\n\t\t)\n\t\tconst runtime = createClusterRuntime(table)\n\t\truntime.seed(editor.getZoomLevel())\n\t\treturn { runtime, table }\n\t}, [clusterInput, clusterZoomBounds, editor])\n\tconst [renderedModel, setRenderedModel] = useState(latestModel)\n\tlet clusterModel = renderedModel\n\t// A page switch replaces the whole scene: hard-reset rather than detach the world.\n\tconst pageId = useValue('comment cluster page', () => editor.getCurrentPageId(), [editor])\n\tconst pageRef = useRef(pageId)\n\tif (pageRef.current !== pageId) {\n\t\tpageRef.current = pageId\n\t\tadoptOnRebuild.current = false\n\t\tlatestModel.runtime.seed(editor.getZoomLevel())\n\t\tif (heldThreadIds.size > 0) setHeldThreadIds(EMPTY_SET)\n\t\tsetRenderedModel(latestModel)\n\t\tclusterModel = latestModel\n\t}\n\t// adoptOnRebuild is set outside React's render cycle, paired with clearing heldThreadIds. Only trust\n\t// it once that pairing is visible here, or an unrelated re-render can land in the gap.\n\tconst rejoinPending = heldThreadIds.size === 0 && adoptOnRebuild.current\n\tif (renderedModel !== latestModel && rejoinPending) {\n\t\tadoptOnRebuild.current = false\n\t\t// Carryover seed: band events inherit the outgoing partition's merged/unmerged state, so\n\t\t// nothing changes state because of the swap alone. Idempotent, so safe during render.\n\t\tlatestModel.runtime.seedFrom(editor.getZoomLevel(), renderedModel.runtime.getVisible())\n\t\tsetRenderedModel(latestModel)\n\t\tclusterModel = latestModel\n\t} else if (heldThreadIds.size === 0 && renderedModel === latestModel) {\n\t\t// Nothing pending and nothing to adopt: clear any leftover force-adopt intent so it can't\n\t\t// survive to force-adopt a later, unrelated rebuild.\n\t\tadoptOnRebuild.current = false\n\t}\n\t// For the input gate above: folded-vs-visible is judged against what's on screen.\n\trenderedModelRef.current = clusterModel\n\t// Pop-out detection: a leaf folded inside a badge can't follow its anchor (the badge position\n\t// is baked into the model), so when its live position drifts from the baked one, hold it out.\n\t// It renders as a live pin riding the anchor; the detach loop below shrinks its badge locally.\n\tconst newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel)\n\tif (newlyMovedIds.length > 0) {\n\t\tconst next = new Set(heldThreadIds)\n\t\tfor (const id of newlyMovedIds) next.add(id)\n\t\tsetHeldThreadIds(next)\n\t}\n\t// Local partition maintenance \u2014 the only non-zoom visual change. Any displayed leaf that has left the\n\t// cluster input is detached from its badge in place; the corrected rebuild already sits in latestModel\n\t// awaiting the next zoom-out. When the two models match, the leaf sets are identical, so skip the scan.\n\tif (clusterModel !== latestModel) {\n\t\tconst latestLeafIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))\n\t\tconst removedLeafIds: string[] = []\n\t\tfor (const leaf of clusterModel.table.leaves) {\n\t\t\tif (!latestLeafIds.has(leaf.id)) {\n\t\t\t\tremovedLeafIds.push(leaf.id)\n\t\t\t}\n\t\t}\n\t\t// Batched: one patch rebuild and one version bump for the whole set.\n\t\tif (removedLeafIds.length > 0) clusterModel.runtime.detachLeaves(removedLeafIds)\n\t}\n\t// Moved pins rejoin clustering on the next zoom-out motion: clear the set (so the rebuild\n\t// includes them again) and adopt that rebuild immediately instead of deferring it. Zooming in\n\t// never folds pins into clusters \u2014 merging is a zoom-out-only move, matching the runtime.\n\tuseEffect(() => {\n\t\tif (heldThreadIds.size === 0) return\n\t\tlet lastZoom = editor.getZoomLevel()\n\t\treturn react('rejoin moved comment pins on zoom out', () => {\n\t\t\tconst zoom = editor.getZoomLevel()\n\t\t\tconst prevZoom = lastZoom\n\t\t\tlastZoom = zoom\n\t\t\tif (zoom >= prevZoom) return\n\t\t\tadoptOnRebuild.current = true\n\t\t\tsetHeldThreadIds(EMPTY_SET)\n\t\t})\n\t}, [heldThreadIds, editor])\n\t// Adopt a pending rebuild only on zoom-out motion: folding deferred additions into clusters is\n\t// a merge, and merging only happens while zooming out. While zooming in, the stale table still\n\t// splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).\n\tuseEffect(() => {\n\t\tif (clusterModel === latestModel) return\n\t\tlet lastZoom = editor.getZoomLevel()\n\t\treturn react('adopt pending cluster model on zoom out', () => {\n\t\t\tconst zoom = editor.getZoomLevel()\n\t\t\tconst prevZoom = lastZoom\n\t\t\tlastZoom = zoom\n\t\t\tif (zoom >= prevZoom) return\n\t\t\tlatestModel.runtime.seedFrom(zoom, clusterModel.runtime.getVisible())\n\t\t\tsetRenderedModel(latestModel)\n\t\t})\n\t}, [clusterModel, latestModel, editor])\n\t// Threads the displayed partition doesn't show anywhere (new comments, reopened threads, undone\n\t// deletions) render as plain pins until the next zoom-out folds them in. Judged against the\n\t// displayed partition, so a detached-then-restored leaf reappears.\n\tconst partitionVersion = clusterModel.runtime.version\n\tconst orphanThreads = useMemo(() => {\n\t\tif (clusterModel === latestModel) return []\n\t\tconst displayed = new Set<string>()\n\t\tfor (const node of clusterModel.runtime.getVisible().values()) {\n\t\t\tfor (const member of node.members) displayed.add(member)\n\t\t}\n\t\tconst latestIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))\n\t\treturn threads.filter((thread) => latestIds.has(thread.id) && !displayed.has(thread.id))\n\t\t// The runtime mutates its partition in place; partitionVersion is its change stamp.\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [clusterModel, latestModel, threads, partitionVersion])\n\tconst heldThreads = useMemo(\n\t\t() => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),\n\t\t[threads, heldThreadIds, openId]\n\t)\n\t// Subscribe to the runtime's partition version, not the raw zoom, so this only re-renders on cluster\n\t// changes rather than every camera frame. The memo below re-reads the version inline because\n\t// render-time detaches bump it after the subscription's computed already evaluated.\n\tuseValue(\n\t\t'comment cluster version',\n\t\t() => {\n\t\t\tclusterModel.runtime.onCamera(editor.getZoomLevel())\n\t\t\treturn clusterModel.runtime.version\n\t\t},\n\t\t[clusterModel, editor]\n\t)\n\tconst visibleNodes = useMemo(() => {\n\t\treturn Array.from(clusterModel.runtime.getVisible().values())\n\t\t// The runtime mutates its partition in place; partitionVersion is its change stamp.\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [clusterModel, partitionVersion])\n\tconst fadeNodes = useFadeVisibleNodes(visibleNodes, clusterModel)\n\n\treturn {\n\t\tmodel: clusterModel,\n\t\tzoomBounds: clusterZoomBounds,\n\t\tfadeNodes,\n\t\torphanThreads,\n\t\theldThreads,\n\t}\n}\n\n/**\n * Whether a position-only input change (ids equal, same order) moved a leaf folded inside a badge\n * of the rendered partition \u2014 the one move the mid-drag freeze must let through.\n *\n * Folded means a member of a displayed badge, not merely \"absent from the displayed partition\":\n * the input also carries orphans (threads the rendered partition has never seen) and detached\n * leaves, which already ride their anchors as plain pins. Neither is in the rendered table, so the\n * pop-out below can never hold one \u2014 counting them here would break the freeze on every\n * pointermove for the rest of the drag.\n * @internal\n */\nexport function anyFoldedLeafMoved(\n\tprev: ClusterInput,\n\tnext: ClusterInput,\n\trendered: ClusterModel | null\n): boolean {\n\tif (!rendered) return false\n\tif (prev.leaves.length !== next.leaves.length) return false\n\tconst folded = new Set<string>()\n\tfor (const node of rendered.runtime.getVisible().values()) {\n\t\tif (node.count < 2) continue\n\t\tfor (const member of node.members) folded.add(member)\n\t}\n\t// No badges on screen, so nothing can be folded \u2014 the common case, and the cheap way out of it.\n\tif (folded.size === 0) return false\n\tfor (let i = 0; i < next.leaves.length; i++) {\n\t\tif (!folded.has(next.leaves[i].id)) continue\n\t\tconst a = prev.leaves[i].point\n\t\tconst b = next.leaves[i].point\n\t\tif (Math.abs(a.x - b.x) > MOVED_LEAF_EPSILON || Math.abs(a.y - b.y) > MOVED_LEAF_EPSILON) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n/**\n * Leaves folded inside a badge whose live anchor no longer matches the position the rendered\n * model was built with. Visible (unclustered) leaf pins track their anchor live, so they can\n * stay deferred; a badge can't follow a member, so these must pop out of clustering.\n */\nfunction findMovedClusteredLeafIds(rendered: ClusterModel, latest: { table: ClusterTable }) {\n\tif (rendered.table === latest.table) return []\n\tconst visible = rendered.runtime.getVisible()\n\tconst latestById = new Map(latest.table.leaves.map((leaf) => [leaf.id, leaf]))\n\tconst moved: string[] = []\n\tfor (const leaf of rendered.table.leaves) {\n\t\tif (visible.has(leaf.id)) continue\n\t\tconst current = latestById.get(leaf.id)\n\t\tif (!current) continue\n\t\tif (\n\t\t\tMath.abs(current.centroid.x - leaf.centroid.x) > MOVED_LEAF_EPSILON ||\n\t\t\tMath.abs(current.centroid.y - leaf.centroid.y) > MOVED_LEAF_EPSILON\n\t\t) {\n\t\t\tmoved.push(leaf.id)\n\t\t}\n\t}\n\treturn moved\n}\n\nfunction getClusterZoomBounds(editor: Editor): ClusterZoomBounds {\n\tconst cameraOptions = editor.getCameraOptions()\n\tconst baseZoom = cameraOptions.constraints ? editor.getBaseZoom() : 1\n\tconst zoomSteps = cameraOptions.zoomSteps\n\treturn {\n\t\tminZoom: zoomSteps[0] * baseZoom,\n\t\tmaxZoom: zoomSteps[zoomSteps.length - 1] * baseZoom,\n\t}\n}\n\n/**\n * Bring a thread's pin into view: switch pages if needed, then zoom to the first cluster split\n * that unfolds it from its badge (or just centre on it when it isn't clustered).\n */\nexport function revealThreadPin(\n\teditor: Editor,\n\tthread: TLCommentThread,\n\ttable: ClusterTable,\n\tzoomBounds: ClusterZoomBounds,\n\toptions: CommentingOptions,\n\tduration = 200\n) {\n\tif (thread.pageId !== editor.getCurrentPageId()) {\n\t\teditor.setCurrentPage(thread.pageId as any)\n\t}\n\n\tconst point = anchorPagePoint(editor, thread.anchor)\n\tif (!point) return\n\n\t// With clustering off the pin always renders individually, so skip the zoom-to-split (its cluster\n\t// badge never exists) and just center on the pin.\n\tif (options.enableClustering) {\n\t\tconst parentEvent = findDirectParentEvent(table, thread.id)\n\t\tif (\n\t\t\tparentEvent &&\n\t\t\tNumber.isFinite(parentEvent.zSplit) &&\n\t\t\tparentEvent.zSplit <= zoomBounds.maxZoom\n\t\t) {\n\t\t\tconst zoom = clamp(\n\t\t\t\tparentEvent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,\n\t\t\t\tzoomBounds.minZoom,\n\t\t\t\tzoomBounds.maxZoom\n\t\t\t)\n\t\t\tcenterOnPointAtZoom(editor, point, zoom, duration)\n\t\t\treturn\n\t\t}\n\t}\n\n\tconst offset = commentCenterScreenOffset(editor) / editor.getZoomLevel()\n\teditor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration } })\n}\n\n/**\n * Zoom to just past the zoom at which a cluster first unclusters, centered on its centroid. The\n * event that created a visible cluster is the event that splits it, and has the smallest zSplit of\n * everything applied inside it \u2014 so its zSplit is exactly the first split within those comments.\n * The animated zoom drives the runtime cursor like any manual zoom. A no-op with no split event.\n */\nexport function zoomToClusterSplit(\n\teditor: Editor,\n\ttable: ClusterTable,\n\tzoomBounds: ClusterZoomBounds,\n\tnode: ClusterNode\n) {\n\tconst event = table.events.find((e) => e.result.id === node.id)\n\tif (!event || !Number.isFinite(event.zSplit)) return\n\tconst zoom = clamp(\n\t\tevent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,\n\t\tzoomBounds.minZoom,\n\t\tzoomBounds.maxZoom\n\t)\n\tcenterOnPointAtZoom(editor, node.centroid, zoom, CLUSTER_EXPAND_ZOOM_MS)\n}\n\nfunction findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {\n\treturn table.events.find((event) => event.children.some((child) => child.id === threadId))\n}\n\nfunction centerOnPointAtZoom(\n\teditor: Editor,\n\tpoint: { x: number; y: number },\n\tzoom: number,\n\tduration = 200\n) {\n\tconst viewport = editor.getViewportScreenBounds()\n\t// The open sidebar shifts the target left so the pin lands mid-uncovered-area, not under it.\n\tconst offset = commentCenterScreenOffset(editor)\n\teditor.setCamera(\n\t\t{\n\t\t\tx: (viewport.w / 2 - offset) / zoom - point.x,\n\t\t\ty: viewport.h / (2 * zoom) - point.y,\n\t\t\tz: zoom,\n\t\t},\n\t\t{ animation: { duration } }\n\t)\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n\treturn Math.max(min, Math.min(max, value))\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -103,7 +103,7 @@ import {
103
103
  import { anchorPagePoint, focusThread, shapeAnchorAt } from "./canvas/thread-state.mjs";
104
104
  registerTldrawLibraryVersion(
105
105
  "@tldraw/commenting",
106
- "5.3.0-next.dec36c5c2930",
106
+ "5.3.0",
107
107
  "esm"
108
108
  );
109
109
  export {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tldraw/commenting",
3
3
  "description": "tldraw commenting UI components.",
4
- "version": "5.3.0-next.dec36c5c2930",
4
+ "version": "5.3.0",
5
5
  "author": {
6
6
  "name": "tldraw Inc.",
7
7
  "email": "hello@tldraw.com"
@@ -38,7 +38,7 @@
38
38
  "lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
39
39
  },
40
40
  "devDependencies": {
41
- "@tldraw/driver": "5.3.0-next.dec36c5c2930",
41
+ "@tldraw/driver": "5.3.0",
42
42
  "@types/react": "^19.2.7",
43
43
  "@types/react-dom": "^19.2.3",
44
44
  "react": "^19.2.1",
@@ -56,9 +56,9 @@
56
56
  "@tiptap/core": "^3.12.1",
57
57
  "@tiptap/pm": "^3.12.1",
58
58
  "@tiptap/react": "^3.12.1",
59
- "@tldraw/mentions": "5.3.0-next.dec36c5c2930",
60
- "@tldraw/utils": "5.3.0-next.dec36c5c2930",
61
- "tldraw": "5.3.0-next.dec36c5c2930"
59
+ "@tldraw/mentions": "5.3.0",
60
+ "@tldraw/utils": "5.3.0",
61
+ "tldraw": "5.3.0"
62
62
  },
63
63
  "module": "dist-esm/index.mjs",
64
64
  "source": "src/index.ts",
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createClusterRuntime } from '../clustering/runtime'
3
+ import type { ClusterNode, ClusterTable, LeafInput } from '../clustering/types'
4
+ import type { ClusterInput } from './cluster-input'
5
+ import { anyFoldedLeafMoved, type ClusterModel } from './cluster-model'
6
+
7
+ function node(ids: string[], x = 0, y = 0): ClusterNode {
8
+ const members = ids.slice().sort()
9
+ return {
10
+ id: members.length === 1 ? members[0] : `cluster:${members.length}:${members[0]}`,
11
+ centroid: { x, y },
12
+ count: members.length,
13
+ members,
14
+ }
15
+ }
16
+
17
+ /** t1 and t2 fold into one badge below zoom 1; t3 sits far away and never merges. */
18
+ const TABLE: ClusterTable = {
19
+ leaves: [node(['t1'], 0, 0), node(['t2'], 10, 0), node(['t3'], 5000, 0)],
20
+ events: [
21
+ {
22
+ zMerge: 1,
23
+ zSplit: 2,
24
+ children: [node(['t1']), node(['t2'])],
25
+ result: node(['t1', 't2'], 5, 0),
26
+ },
27
+ ],
28
+ }
29
+
30
+ /** The rendered partition at `zoom`: below 1 the badge is up, above 2 every pin stands alone. */
31
+ function rendered(zoom: number): ClusterModel {
32
+ const runtime = createClusterRuntime(TABLE)
33
+ runtime.seed(zoom)
34
+ return { runtime, table: TABLE }
35
+ }
36
+
37
+ const leaf = (id: string, x: number, y: number): LeafInput => ({ id, point: { x, y } })
38
+ const input = (leaves: LeafInput[]): ClusterInput => ({ leaves, screenOffsets: undefined })
39
+
40
+ const AT_REST = [leaf('t1', 0, 0), leaf('t2', 10, 0), leaf('t3', 5000, 0)]
41
+
42
+ describe('anyFoldedLeafMoved', () => {
43
+ it('breaks the freeze when a leaf folded into a badge moves', () => {
44
+ const model = rendered(0.5)
45
+ expect([...model.runtime.getVisible().keys()].sort()).toEqual(['cluster:2:t1', 't3'])
46
+ const next = input([leaf('t1', 40, 0), leaf('t2', 10, 0), leaf('t3', 5000, 0)])
47
+ expect(anyFoldedLeafMoved(input(AT_REST), next, model)).toBe(true)
48
+ })
49
+
50
+ it('holds the freeze when a visible leaf moves — its pin already rides its anchor', () => {
51
+ const next = input([leaf('t1', 0, 0), leaf('t2', 10, 0), leaf('t3', 5040, 0)])
52
+ expect(anyFoldedLeafMoved(input(AT_REST), next, rendered(0.5))).toBe(false)
53
+ // and with the badge split, the same move of a former badge member is visible too
54
+ const split = input([leaf('t1', 40, 0), leaf('t2', 10, 0), leaf('t3', 5000, 0)])
55
+ expect(anyFoldedLeafMoved(input(AT_REST), split, rendered(3))).toBe(false)
56
+ })
57
+
58
+ it('holds the freeze when an orphan moves — it is not in the rendered partition to pop out of', () => {
59
+ // t4 was added since the last adopted rebuild: it is in the input but not in the rendered
60
+ // table, so it renders as a plain live pin and no pop-out can ever hold it. Counting it as
61
+ // folded would rebuild on every pointermove for the rest of the drag.
62
+ const atRest = [...AT_REST, leaf('t4', 20, 0)]
63
+ const next = input([...AT_REST, leaf('t4', 60, 0)])
64
+ expect(anyFoldedLeafMoved(input(atRest), next, rendered(0.5))).toBe(false)
65
+ })
66
+
67
+ it('holds the freeze when a detached leaf moves — it left the badge already', () => {
68
+ const model = rendered(0.5)
69
+ model.runtime.detachLeaves(['t1'])
70
+ expect(
71
+ anyFoldedLeafMoved(input(AT_REST), input([leaf('t1', 40, 0), ...AT_REST.slice(1)]), model)
72
+ ).toBe(false)
73
+ })
74
+
75
+ it('holds the freeze when nothing moved', () => {
76
+ expect(anyFoldedLeafMoved(input(AT_REST), input([...AT_REST]), rendered(0.5))).toBe(false)
77
+ })
78
+
79
+ it('holds the freeze with no rendered model yet', () => {
80
+ const next = input([leaf('t1', 40, 0), leaf('t2', 10, 0), leaf('t3', 5000, 0)])
81
+ expect(anyFoldedLeafMoved(input(AT_REST), next, null)).toBe(false)
82
+ })
83
+
84
+ it('holds the freeze on mismatched inputs rather than reading past the end', () => {
85
+ const next = input([leaf('t1', 40, 0), leaf('t2', 10, 0)])
86
+ expect(anyFoldedLeafMoved(input(AT_REST), next, rendered(0.5))).toBe(false)
87
+ })
88
+ })
@@ -88,12 +88,11 @@ export function useClusterModel(
88
88
  // reply, a reaction, a resolve — anything that touches comment records without moving a pin —
89
89
  // returns the previous input and rebuilds nothing.
90
90
  //
91
- // Mid-drag the gate widens to ignore positions too, and that costs something real: it freezes
92
- // `latestModel`, which is where `findMovedClusteredLeafIds` reads live positions from, so a pin
93
- // folded into a badge stops popping out to ride its anchor and corrects on release instead. The
94
- // per-frame rebuild it buys back was paid by every drag on the board, comment-anchored or not.
95
- // An added or deleted thread changes the id set, so it still rebuilds promptly.
91
+ // Mid-drag the gate ignores positions too except a folded leaf's, which a badge can't
92
+ // follow. Its first move passes through, the pop-out below holds the pin out as a live pin,
93
+ // and the input is static again for the rest of the drag.
96
94
  const clusterInputRef = useRef<ClusterInput>({ leaves: [], screenOffsets: undefined })
95
+ const renderedModelRef = useRef<ClusterModel | null>(null)
97
96
  const clusterInput = useValue(
98
97
  'comment cluster leaves',
99
98
  () => {
@@ -103,7 +102,13 @@ export function useClusterModel(
103
102
  openThreadId.get(editor)
104
103
  )
105
104
  const prev = clusterInputRef.current
106
- if (isShapeDragInProgress(editor) && clusterInputIdsEqual(prev, next)) return prev
105
+ if (
106
+ isShapeDragInProgress(editor) &&
107
+ clusterInputIdsEqual(prev, next) &&
108
+ !anyFoldedLeafMoved(prev, next, renderedModelRef.current)
109
+ ) {
110
+ return prev
111
+ }
107
112
  if (clusterInputEqual(prev, next)) return prev
108
113
  clusterInputRef.current = next
109
114
  return next
@@ -153,6 +158,8 @@ export function useClusterModel(
153
158
  // survive to force-adopt a later, unrelated rebuild.
154
159
  adoptOnRebuild.current = false
155
160
  }
161
+ // For the input gate above: folded-vs-visible is judged against what's on screen.
162
+ renderedModelRef.current = clusterModel
156
163
  // Pop-out detection: a leaf folded inside a badge can't follow its anchor (the badge position
157
164
  // is baked into the model), so when its live position drifts from the baked one, hold it out.
158
165
  // It renders as a live pin riding the anchor; the detach loop below shrinks its badge locally.
@@ -252,6 +259,42 @@ export function useClusterModel(
252
259
  }
253
260
  }
254
261
 
262
+ /**
263
+ * Whether a position-only input change (ids equal, same order) moved a leaf folded inside a badge
264
+ * of the rendered partition — the one move the mid-drag freeze must let through.
265
+ *
266
+ * Folded means a member of a displayed badge, not merely "absent from the displayed partition":
267
+ * the input also carries orphans (threads the rendered partition has never seen) and detached
268
+ * leaves, which already ride their anchors as plain pins. Neither is in the rendered table, so the
269
+ * pop-out below can never hold one — counting them here would break the freeze on every
270
+ * pointermove for the rest of the drag.
271
+ * @internal
272
+ */
273
+ export function anyFoldedLeafMoved(
274
+ prev: ClusterInput,
275
+ next: ClusterInput,
276
+ rendered: ClusterModel | null
277
+ ): boolean {
278
+ if (!rendered) return false
279
+ if (prev.leaves.length !== next.leaves.length) return false
280
+ const folded = new Set<string>()
281
+ for (const node of rendered.runtime.getVisible().values()) {
282
+ if (node.count < 2) continue
283
+ for (const member of node.members) folded.add(member)
284
+ }
285
+ // No badges on screen, so nothing can be folded — the common case, and the cheap way out of it.
286
+ if (folded.size === 0) return false
287
+ for (let i = 0; i < next.leaves.length; i++) {
288
+ if (!folded.has(next.leaves[i].id)) continue
289
+ const a = prev.leaves[i].point
290
+ const b = next.leaves[i].point
291
+ if (Math.abs(a.x - b.x) > MOVED_LEAF_EPSILON || Math.abs(a.y - b.y) > MOVED_LEAF_EPSILON) {
292
+ return true
293
+ }
294
+ }
295
+ return false
296
+ }
297
+
255
298
  /**
256
299
  * Leaves folded inside a badge whose live anchor no longer matches the position the rendered
257
300
  * model was built with. Visible (unclustered) leaf pins track their anchor live, so they can