Conversation
PR SummaryMedium Risk Overview Updates editor interaction logic to treat shapes of type Written by Cursor Bugbot for commit 8fe2f68. This will update automatically on new commits. Configure here. |
There was a problem hiding this comment.
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
isEmptyLabelgeometries except for frame/grid labels, restoring pass-through behavior for empty unfilled labels while preserving intended frame/grid title hits.
- I updated the Group2d label hit-test to ignore
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) { |
There was a problem hiding this comment.
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.



Patches for v.4.4.x