Skip to content

Kaz/v4.4.x#5

Closed
kaznaan wants to merge 2 commits intov4.4.xfrom
kaz/v4.4.x
Closed

Kaz/v4.4.x#5
kaznaan wants to merge 2 commits intov4.4.xfrom
kaz/v4.4.x

Conversation

@kaznaan
Copy link
Copy Markdown
Collaborator

@kaznaan kaznaan commented Feb 27, 2026

Patches for v.4.4.x

@cursor
Copy link
Copy Markdown

cursor Bot commented Feb 27, 2026

PR Summary

Medium Risk
Changes core canvas rendering extensibility and modifies hit-testing/paste logic, which can subtly affect selection, click-through behavior, and duplication placement for container-like shapes.

Overview
Adds a new overridable ShapeRenderer component (with DefaultShapeRenderer + TLShapeRendererProps) so consumers can control how getRenderingShapes() are turned into React elements, instead of DefaultCanvas directly mapping shapes.

Updates editor interaction logic to treat shapes of type grid similarly to frame during hit-testing and paste/duplicate behaviors (candidate filtering, click-through prevention, and duplicate offset checks), and simplifies label hit-testing to rely on Group2d label geometry rather than shape-type heuristics.

Written by Cursor Bugbot for commit 8fe2f68. This will update automatically on new commits. Configure here.

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Label hit test now triggers on empty labels
    • I updated the Group2d label hit-test to ignore isEmptyLabel geometries except for frame/grid labels, restoring pass-through behavior for empty unfilled labels while preserving intended frame/grid title hits.

Create PR

Or push these changes by commenting:

@cursor push 8778bee464
Preview (8778bee464)
diff --git a/packages/editor/api-report.api.md b/packages/editor/api-report.api.md
--- a/packages/editor/api-report.api.md
+++ b/packages/editor/api-report.api.md
@@ -21,6 +21,7 @@
 import { IndexKey } from '@tldraw/utils';
 import { JsonObject } from '@tldraw/utils';
 import { JSX } from 'react/jsx-runtime';
+import { JSXElementConstructor } from 'react';
 import { LegacyMigrations } from '@tldraw/store';
 import { MigrationSequence } from '@tldraw/store';
 import { NamedExoticComponent } from 'react';
@@ -658,6 +659,9 @@
 export const DefaultShapeIndicators: NamedExoticComponent<TLShapeIndicatorsProps>;
 
 // @public (undocumented)
+export function DefaultShapeRenderer({ renderShape }: TLShapeRendererProps): ReactElement<unknown, JSXElementConstructor<any> | string>[];
+
+// @public (undocumented)
 export const DefaultShapeWrapper: ForwardRefExoticComponent<TLShapeWrapperProps & RefAttributes<HTMLDivElement>>;
 
 // @public (undocumented)
@@ -1335,6 +1339,7 @@
     getViewportPageBounds(): Box;
     getViewportScreenBounds(): Box;
     getViewportScreenCenter(): Vec;
+    getVisibleRenderingShapes(): TLRenderingShape[];
     getZoomLevel(): number;
     groupShapes(shapes: TLShape[], opts?: Partial<{
         groupId: TLShapeId;
@@ -3618,6 +3623,8 @@
     // (undocumented)
     ShapeIndicators?: ComponentType | null;
     // (undocumented)
+    ShapeRenderer?: ComponentType<TLShapeRendererProps> | null;
+    // (undocumented)
     ShapeWrapper?: ComponentType<TLShapeWrapperProps & RefAttributes<HTMLDivElement>> | null;
     // (undocumented)
     SnapIndicator?: ComponentType<TLSnapIndicatorProps> | null;
@@ -4342,6 +4349,12 @@
     showAll?: boolean;
 }
 
+// @public (undocumented)
+export interface TLShapeRendererProps {
+    // (undocumented)
+    renderShape(shape: TLRenderingShape): ReactElement;
+}
+
 // @public
 export interface TLShapeUtilCanBeLaidOutOpts {
     shapes?: TLShape[];

diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts
--- a/packages/editor/src/index.ts
+++ b/packages/editor/src/index.ts
@@ -17,7 +17,9 @@
 export { DefaultBrush, type TLBrushProps } from './lib/components/default-components/DefaultBrush'
 export {
 	DefaultCanvas,
+	DefaultShapeRenderer,
 	type TLCanvasComponentProps,
+	type TLShapeRendererProps,
 } from './lib/components/default-components/DefaultCanvas'
 export {
 	DefaultCollaboratorHint,

diff --git a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx
--- a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx
+++ b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx
@@ -3,7 +3,8 @@
 import { TLHandle, TLShapeId } from '@tldraw/tlschema'
 import { dedupe, modulate, objectMapValues } from '@tldraw/utils'
 import classNames from 'classnames'
-import { Fragment, JSX, useEffect, useRef, useState } from 'react'
+import { Fragment, JSX, ReactElement, useEffect, useRef, useState } from 'react'
+import type { TLRenderingShape } from '../../editor/Editor'
 import { tlenv } from '../../globals/environment'
 import { useCanvasEvents } from '../../hooks/useCanvasEvents'
 import { useCoarsePointer } from '../../hooks/useCoarsePointer'
@@ -399,7 +400,9 @@
 function ShapesWithSVGs() {
 	const editor = useEditor()
 
-	const renderingShapes = useValue('rendering shapes', () => editor.getRenderingShapes(), [editor])
+	const renderingShapes = useValue('rendering shapes', () => editor.getVisibleRenderingShapes(), [
+		editor,
+	])
 
 	return renderingShapes.map((result) => (
 		<Fragment key={result.id + '_fragment'}>
@@ -450,21 +453,32 @@
 }
 
 function ShapesToDisplay() {
-	const editor = useEditor()
+	const { ShapeRenderer } = useEditorComponents()
+	const Renderer = ShapeRenderer ?? DefaultShapeRenderer
 
-	const renderingShapes = useValue('rendering shapes', () => editor.getRenderingShapes(), [editor])
-
 	return (
 		<ShapeCullingProvider>
-			{renderingShapes.map((result) => (
-				<Shape key={result.id + '_shape'} {...result} />
-			))}
+			<Renderer renderShape={(shape) => <Shape key={shape.id + '_shape'} {...shape} />} />
 			<CullingController />
 			{tlenv.isSafari && <ReflowIfNeeded />}
 		</ShapeCullingProvider>
 	)
 }
 
+/** @public */
+export interface TLShapeRendererProps {
+	renderShape(shape: TLRenderingShape): ReactElement
+}
+
+/** @public @react */
+export function DefaultShapeRenderer({ renderShape }: TLShapeRendererProps) {
+	const editor = useEditor()
+	const renderingShapes = useValue('rendering shapes', () => editor.getVisibleRenderingShapes(), [
+		editor,
+	])
+	return renderingShapes.map((shape) => renderShape(shape))
+}
+
 function HintedShapeIndicator() {
 	const editor = useEditor()
 	const { ShapeIndicator } = useEditorComponents()

diff --git a/packages/editor/src/lib/components/default-components/DefaultShapeIndicators.tsx b/packages/editor/src/lib/components/default-components/DefaultShapeIndicators.tsx
--- a/packages/editor/src/lib/components/default-components/DefaultShapeIndicators.tsx
+++ b/packages/editor/src/lib/components/default-components/DefaultShapeIndicators.tsx
@@ -85,8 +85,10 @@
 		[editor]
 	)
 
-	// Show indicators only for the shapes that are currently being rendered (ie that are on screen)
-	const renderingShapes = useValue('rendering shapes', () => editor.getRenderingShapes(), [editor])
+	// Show indicators only for shapes that are currently visible.
+	const renderingShapes = useValue('rendering shapes', () => editor.getVisibleRenderingShapes(), [
+		editor,
+	])
 
 	const { ShapeIndicator } = useEditorComponents()
 

diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts
--- a/packages/editor/src/lib/editor/Editor.ts
+++ b/packages/editor/src/lib/editor/Editor.ts
@@ -4267,6 +4267,16 @@
 		return renderingShapes.sort(sortById)
 	}
 
+	/**
+	 * Get the rendering shapes that are currently visible in the viewport.
+	 *
+	 * @public
+	 */
+	@computed getVisibleRenderingShapes() {
+		const culledShapes = this.getCulledShapes()
+		return this.getRenderingShapes().filter(({ id }) => !culledShapes.has(id))
+	}
+
 	/* --------------------- Pages ---------------------- */
 
 	@computed private _getAllPagesQuery() {
@@ -5294,7 +5304,12 @@
 				: this.getCurrentPageShapesSorted()
 		).filter((shape) => {
 			// Frames have labels positioned above the shape (outside bounds), so always include them
-			if (!candidateIds.has(shape.id) && !this.isShapeOfType(shape, 'frame')) return false
+			if (
+				!candidateIds.has(shape.id) &&
+				!this.isShapeOfType(shape, 'frame') &&
+				(shape as any).type !== 'grid'
+			)
+				return false
 
 			if (
 				(shape.isLocked && !hitLocked) ||
@@ -5316,21 +5331,21 @@
 			const pointInShapeSpace = this.getPointInShapeSpace(shape, point)
 
 			// Check labels first
-			if (
-				this.isShapeOfType(shape, 'frame') ||
-				((this.isShapeOfType(shape, 'note') ||
-					this.isShapeOfType(shape, 'arrow') ||
-					(this.isShapeOfType(shape, 'geo') && shape.props.fill === 'none')) &&
-					this.getShapeUtil(shape).getText(shape)?.trim())
-			) {
+			if (geometry instanceof Group2d) {
+				const hitEmptyLabel = this.isShapeOfType(shape, 'frame') || (shape as any).type === 'grid'
+
 				for (const childGeometry of (geometry as Group2d).children) {
-					if (childGeometry.isLabel && childGeometry.isPointInBounds(pointInShapeSpace)) {
+					if (
+						childGeometry.isLabel &&
+						(!childGeometry.isEmptyLabel || hitEmptyLabel) &&
+						childGeometry.isPointInBounds(pointInShapeSpace)
+					) {
 						return shape
 					}
 				}
 			}
 
-			if (this.isShapeOfType(shape, 'frame')) {
+			if (this.isShapeOfType(shape, 'frame') || (shape as any).type === 'grid') {
 				// On the rare case that we've hit a frame (not its label), test again hitInside to be forced true;
 				// this prevents clicks from passing through the body of a frame to shapes behind it.
 
@@ -5489,7 +5504,12 @@
 		return this.getCurrentPageShapesSorted()
 			.filter((shape) => {
 				if (this.isShapeHidden(shape)) return false
-				if (!candidateIds.has(shape.id) && !this.isShapeOfType(shape, 'frame')) return false
+				if (
+					!candidateIds.has(shape.id) &&
+					!this.isShapeOfType(shape, 'frame') &&
+					(shape as any).type !== 'grid'
+				)
+					return false
 				return this.isPointInShape(shape, point, opts)
 			})
 			.reverse()
@@ -9363,7 +9383,7 @@
 		for (const shape of this.getSelectedShapes()) {
 			if (lowestDepth === 0) break
 
-			const isFrame = this.isShapeOfType(shape, 'frame')
+			const isFrame = this.isShapeOfType(shape, 'frame') || (shape as any).type == 'grid'
 			const ancestors = this.getShapeAncestors(shape)
 			if (isFrame) ancestors.push(shape)
 
@@ -9425,11 +9445,17 @@
 				} else {
 					if (rootShapeIds.length === 1) {
 						const rootShape = shapes.find((s) => s.id === rootShapeIds[0])!
+
+						const isParentFrame =
+							this.isShapeOfType(parent, 'frame') || (parent as any).type == 'grid'
+						const isRootFrame =
+							this.isShapeOfType(rootShape, 'frame') || (rootShape as any).type == 'grid'
+
 						if (
-							this.isShapeOfType(parent, 'frame') &&
-							this.isShapeOfType(rootShape, 'frame') &&
-							rootShape.props.w === parent?.props.w &&
-							rootShape.props.h === parent?.props.h
+							isParentFrame &&
+							isRootFrame &&
+							(rootShape as any).props.w === (parent as any).props.w &&
+							(rootShape as any).props.h === (parent as any).props.h
 						) {
 							isDuplicating = true
 						}
@@ -9602,13 +9628,13 @@
 				const onlyRoot = rootShapes[0] as TLFrameShape
 				// If the old bounds are in the viewport...
 				// todo: replace frame references with shapes that can accept children
-				if (this.isShapeOfType(onlyRoot, 'frame')) {
+				if (this.isShapeOfType(onlyRoot, 'frame') || (onlyRoot as any).type == 'grid') {
 					while (
 						this.getShapesAtPoint(point).some(
 							(shape) =>
-								this.isShapeOfType(shape, 'frame') &&
-								shape.props.w === onlyRoot.props.w &&
-								shape.props.h === onlyRoot.props.h
+								(this.isShapeOfType(shape, 'frame') || (shape as any).type == 'grid') &&
+								(shape as any).props.w === (onlyRoot as any).props.w &&
+								(shape as any).props.h === (onlyRoot as any).props.h
 						)
 					) {
 						point.x += bounds.w + 16

diff --git a/packages/editor/src/lib/hooks/useEditorComponents.tsx b/packages/editor/src/lib/hooks/useEditorComponents.tsx
--- a/packages/editor/src/lib/hooks/useEditorComponents.tsx
+++ b/packages/editor/src/lib/hooks/useEditorComponents.tsx
@@ -3,7 +3,9 @@
 import { DefaultBrush, TLBrushProps } from '../components/default-components/DefaultBrush'
 import {
 	DefaultCanvas,
+	DefaultShapeRenderer,
 	TLCanvasComponentProps,
+	TLShapeRendererProps,
 } from '../components/default-components/DefaultCanvas'
 import {
 	DefaultCollaboratorHint,
@@ -72,6 +74,7 @@
 	SelectionForeground?: ComponentType<TLSelectionForegroundProps> | null
 	ShapeIndicator?: ComponentType<TLShapeIndicatorProps> | null
 	ShapeIndicators?: ComponentType | null
+	ShapeRenderer?: ComponentType<TLShapeRendererProps> | null
 	ShapeWrapper?: ComponentType<TLShapeWrapperProps & RefAttributes<HTMLDivElement>> | null
 	SnapIndicator?: ComponentType<TLSnapIndicatorProps> | null
 	Spinner?: ComponentType<React.SVGProps<SVGSVGElement>> | null
@@ -119,6 +122,7 @@
 			SelectionForeground: DefaultSelectionForeground,
 			ShapeIndicator: DefaultShapeIndicator,
 			ShapeIndicators: DefaultShapeIndicators,
+			ShapeRenderer: DefaultShapeRenderer,
 			ShapeWrapper: DefaultShapeWrapper,
 			SnapIndicator: DefaultSnapIndicator,
 			Spinner: DefaultSpinner,

diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts
--- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts
+++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts
@@ -150,6 +150,14 @@
 										this.editor.select(selectingShape.id)
 
 										if (!this.editor.canEditShape(selectingShape)) return
+
+										const util = this.editor.getShapeUtil(selectingShape)
+										if (this.editor.getIsReadonly()) {
+											if (!util.canEditInReadonly(selectingShape)) {
+												return
+											}
+										}
+
 										this.editor.setEditingShape(selectingShape.id)
 										this.editor.setCurrentTool('select.editing_shape')

(this.isShapeOfType(shape, 'geo') && shape.props.fill === 'none')) &&
this.getShapeUtil(shape).getText(shape)?.trim())
) {
if (geometry instanceof Group2d) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Label hit test now triggers on empty labels

Medium Severity

The label hit test was broadened from checking specific shape types with non-empty text to checking all Group2d geometries. The old code guarded against empty labels via this.getShapeUtil(shape).getText(shape)?.trim(), but the new code omits this. The isEmptyLabel flag exists on Geometry2d (and is already checked in overlapsPolygon) but the new label check doesn't filter on childGeometry.isEmptyLabel. For unfilled geo shapes with no text, getLabelBounds still produces non-zero minimum bounds (~100×30px), so isPointInBounds returns true for clicks in that invisible area. This makes hollow unfilled rectangles selectable by clicking their empty center, where previously clicks would pass through to shapes behind them.

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant