Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/editor/api-report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { HistoryEntry } from '@tldraw/store';
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 { Node as Node_2 } from '@tiptap/pm/model';
Expand Down Expand Up @@ -705,6 +706,9 @@ export function DefaultGrid({ x, y, z, size }: TLGridProps): JSX.Element;
// @public (undocumented)
export function DefaultSelectionBackground({ bounds, rotation }: TLSelectionBackgroundProps): JSX.Element;

// @public (undocumented)
export function DefaultShapeRenderer({ renderShape }: TLShapeRendererProps): ReactElement<unknown, JSXElementConstructor<any> | string>[];

// @public (undocumented)
export const DefaultShapeWrapper: ForwardRefExoticComponent<TLShapeWrapperProps & RefAttributes<HTMLDivElement>>;

Expand Down Expand Up @@ -3887,6 +3891,8 @@ export interface TLEditorComponents {
// (undocumented)
ShapeErrorFallback?: TLShapeErrorFallbackComponent;
// (undocumented)
ShapeRenderer?: ComponentType<TLShapeRendererProps> | null;
// (undocumented)
ShapeWrapper?: ComponentType<TLShapeWrapperProps & RefAttributes<HTMLDivElement>> | null;
// (undocumented)
Spinner?: ComponentType<React.SVGProps<SVGSVGElement>> | null;
Expand Down Expand Up @@ -4639,6 +4645,12 @@ export interface TLShapeOperationPerfEvent {
timestamp: number;
}

// @public (undocumented)
export interface TLShapeRendererProps {
// (undocumented)
renderShape(shape: TLRenderingShape): ReactElement;
}

// @public
export interface TLShapeUtilCanBeLaidOutOpts {
shapes?: TLShape[];
Expand Down
2 changes: 2 additions & 0 deletions packages/editor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ export * from '@tldraw/validate'
export { DefaultBackground } from './lib/components/default-components/DefaultBackground'
export {
DefaultCanvas,
DefaultShapeRenderer,
type TLCanvasComponentProps,
type TLShapeRendererProps,
} from './lib/components/default-components/DefaultCanvas'
export {
DefaultErrorFallback,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { useQuickReactor, useValue } from '@tldraw/state-react'
import { TLShapeId } from '@tldraw/tlschema'
import { modulate, objectMapValues } from '@tldraw/utils'
import classNames from 'classnames'
import { Fragment, JSX, useEffect, useRef, useState } from 'react'
import { Fragment, JSX, type ReactElement, useEffect, useRef, useState } from 'react'
import type { TLRenderingShape } from '../../editor/Editor'
import { tlenv } from '../../globals/environment'
import { useEditorComponents } from '../../hooks/EditorComponentsContext'
import { useCanvasEvents } from '../../hooks/useCanvasEvents'
Expand Down Expand Up @@ -193,27 +194,42 @@ function GridWrapper() {
}

function ShapesLayer({ canvasRef }: { canvasRef: { readonly current: HTMLDivElement | null } }) {
const editor = useEditor()
const debugSvg = useValue('debug svg', () => debugFlags.debugSvg.get(), [debugFlags])
const renderingShapes = useValue('rendering shapes', () => editor.getRenderingShapes(), [editor])
const { ShapeRenderer } = useEditorComponents()
const Renderer = ShapeRenderer ?? DefaultShapeRenderer

return (
<ShapeCullingProvider>
{renderingShapes.map((result) =>
debugSvg ? (
<Fragment key={result.id + '_fragment'}>
<Shape {...result} />
<DebugSvgCopy id={result.id} mode="iframe" />
</Fragment>
) : (
<Shape key={result.id + '_shape'} {...result} />
)
)}
<Renderer
renderShape={(result) =>
debugSvg ? (
<Fragment key={result.id + '_fragment'}>
<Shape {...result} />
<DebugSvgCopy id={result.id} mode="iframe" />
</Fragment>
) : (
<Shape key={result.id + '_shape'} {...result} />
)
}
/>
<CullingController />
{tlenv.isSafari && <ReflowIfNeeded canvasRef={canvasRef} />}
</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.getRenderingShapes(), [editor])
return renderingShapes.map((shape) => renderShape(shape))
}

function ReflowIfNeeded({ canvasRef }: { canvasRef: { readonly current: HTMLDivElement | null } }) {
const editor = useEditor()
const culledShapesRef = useRef<Set<TLShapeId>>(new Set())
Expand Down
38 changes: 28 additions & 10 deletions packages/editor/src/lib/editor/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5804,11 +5804,13 @@ export class Editor extends EventEmitter<TLEventMap> {

// If the hit is within the frame's outer margin, then select the frame
const distance = geometry.distanceToPoint(pointInShapeSpace, hitFrameInside)
const isGrid = (shape as any).type === 'grid'
if (
hitFrameInside
!isGrid &&
(hitFrameInside
? (distance > 0 && distance <= outerMargin) ||
(distance <= 0 && distance > -innerMargin)
: distance > 0 && distance <= outerMargin
: distance > 0 && distance <= outerMargin)
) {
return inMarginClosestToEdgeHit || shape
}
Expand All @@ -5823,7 +5825,7 @@ export class Editor extends EventEmitter<TLEventMap> {
return (
inMarginClosestToEdgeHit ||
inHollowSmallestAreaHit ||
(hitFrameInside ? shape : undefined)
(hitFrameInside || isGrid ? shape : undefined)
)
}
continue
Expand Down Expand Up @@ -5878,7 +5880,10 @@ export class Editor extends EventEmitter<TLEventMap> {
// If the shape is filled, then it's a hit. Remember, we're
// starting from the TOP-MOST shape in z-index order, so any
// other hits would be occluded by the shape.
return inMarginClosestToEdgeHit || shape
if (distance <= 1) {
return inMarginClosestToEdgeHit || shape
}
// Point is outside the filled shape but within margin — skip (no margin bleed)
} else {
// If the shape is bigger than the viewport, then skip it.
if (this.getShapePageBounds(shape)!.contains(viewportPageBounds)) continue
Expand Down Expand Up @@ -6436,7 +6441,7 @@ export class Editor extends EventEmitter<TLEventMap> {
}

/**
* Run a visitor function for all descendants of a shape.
* Run an iterative visitor function for all descendants of a shape.
*
* @example
* ```ts
Expand All @@ -6450,12 +6455,25 @@ export class Editor extends EventEmitter<TLEventMap> {
*/
visitDescendants(
parent: TLParentId | TLPage | TLShape,
visitor: (id: TLShapeId) => void | false
visitor: (id: TLShapeId) => void | false,
): this {
const children = this.getSortedChildIdsForParent(parent)
for (const id of children) {
if (visitor(id) === false) continue
this.visitDescendants(id, visitor)
const visited = new Set<TLShapeId>()
const parentId = typeof parent === 'string' ? parent : parent.id
const rootChildren = this.getSortedChildIdsForParent(parentId)
const stack: TLShapeId[] = []
for (let i = rootChildren.length - 1; i >= 0; i--) {
stack.push(rootChildren[i])
}
while (stack.length > 0) {
const id = stack.pop()!
if (visited.has(id)) continue
visited.add(id)
if (visitor(id) !== false) {
const children = this.getSortedChildIdsForParent(id)
for (let i = children.length - 1; i >= 0; i--) {
stack.push(children[i])
}
}
}
return this
}
Expand Down
6 changes: 5 additions & 1 deletion packages/editor/src/lib/hooks/EditorComponentsContext.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { ComponentType, RefAttributes, createContext, useContext } from 'react'
import type { TLCanvasComponentProps } from '../components/default-components/DefaultCanvas'
import type {
TLCanvasComponentProps,
TLShapeRendererProps,
} from '../components/default-components/DefaultCanvas'
import type { TLErrorFallbackComponent } from '../components/default-components/DefaultErrorFallback'
import type { TLGridProps } from '../components/default-components/DefaultGrid'
import type { TLSelectionBackgroundProps } from '../components/default-components/DefaultSelectionBackground'
Expand All @@ -15,6 +18,7 @@ export interface TLEditorComponents {
LoadingScreen?: ComponentType | null
OnTheCanvas?: ComponentType | null
SelectionBackground?: ComponentType<TLSelectionBackgroundProps> | null
ShapeRenderer?: ComponentType<TLShapeRendererProps> | null
ShapeWrapper?: ComponentType<TLShapeWrapperProps & RefAttributes<HTMLDivElement>> | null
Spinner?: ComponentType<React.SVGProps<SVGSVGElement>> | null
SvgDefs?: ComponentType | null
Expand Down
3 changes: 2 additions & 1 deletion packages/editor/src/lib/hooks/useEditorComponents.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ReactNode, useMemo } from 'react'
import { DefaultBackground } from '../components/default-components/DefaultBackground'
import { DefaultCanvas } from '../components/default-components/DefaultCanvas'
import { DefaultCanvas, DefaultShapeRenderer } from '../components/default-components/DefaultCanvas'
import { DefaultErrorFallback } from '../components/default-components/DefaultErrorFallback'
import { DefaultGrid } from '../components/default-components/DefaultGrid'
import { DefaultLoadingScreen } from '../components/default-components/DefaultLoadingScreen'
Expand Down Expand Up @@ -34,6 +34,7 @@ export function EditorComponentsProvider({
LoadingScreen: DefaultLoadingScreen,
OnTheCanvas: null,
SelectionBackground: null,
ShapeRenderer: DefaultShapeRenderer,
ShapeWrapper: DefaultShapeWrapper,
Spinner: DefaultSpinner,
SvgDefs: DefaultSvgDefs,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,14 @@ export class PointingShape extends StateNode {
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
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Redundant readonly check after canEditShape guard

Low Severity

The canEditInReadonly check on lines 154–158 is fully redundant with the canEditShape guard on line 152. canEditShape already returns false when getIsReadonly() is true and canEditInReadonly() is false (see Editor.ts line 2701). If canEditShape returns true, the readonly/canEditInReadonly condition here can never trigger. Additionally, setEditingShape internally calls canEditShape again, making this a third redundant layer.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 19fde0f. Configure here.


this.editor.setEditingShape(selectingShape.id)
this.editor.setCurrentTool('select.editing_shape')

Expand Down
3 changes: 3 additions & 0 deletions packages/utils/api-report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,9 @@ export function setInLocalStorage(key: string, value: string): void;
// @internal
export function setInSessionStorage(key: string, value: string): void;

// @public
export function setUniqueIdGenerator(fn: (size?: number) => string): void;

// @internal
export function sleep(ms: number): Promise<void>;

Expand Down
2 changes: 1 addition & 1 deletion packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export { ExecutionQueue } from './lib/ExecutionQueue'
export { FileHelpers } from './lib/file'
export { noop, omitFromStackTrace } from './lib/function'
export { getHashForBuffer, getHashForObject, getHashForString, lns } from './lib/hash'
export { mockUniqueId, restoreUniqueId, uniqueId } from './lib/id'
export { mockUniqueId, restoreUniqueId, setUniqueIdGenerator, uniqueId } from './lib/id'
export { getFirstFromIterable } from './lib/iterable'
export { LruCache } from './lib/LruCache'
export type { JsonArray, JsonObject, JsonPrimitive, JsonValue } from './lib/json-value'
Expand Down
20 changes: 20 additions & 0 deletions packages/utils/src/lib/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,26 @@ function nanoid(size = 21) {
}

let impl = nanoid

/**
* Override the unique ID generator with a custom implementation.
*
* This allows you to customize how IDs are generated across tldraw,
* affecting all ID generation including shapes, pages, and other records.
*
* @param fn - A function that returns a string ID. Takes an optional size parameter.
* @example
* ```ts
* import { setUniqueIdGenerator } from '@tldraw/editor'
*
* setUniqueIdGenerator(() => `id-${Date.now()}-${Math.random()}`)
* ```
* @public
*/
export function setUniqueIdGenerator(fn: (size?: number) => string) {
impl = fn
}

/**
* Mock the unique ID generator with a custom implementation for testing.
*
Expand Down
Loading