Faster selection / erasing (#3454)

This PR makes a small improvement to the way we measure distances.
(Often we measure distances multiple times per frame per shape on the
screen). In many cases, we compare a minimum distance. This makes those
checks faster by avoiding a square root.

### Change Type

- [x] `sdk` — Changes the tldraw SDK
- [x] `improvement` — Improving existing features

### Release Notes

- Improve performance of minimum distance checks.
This commit is contained in:
Steve Ruiz 2024-04-13 14:30:30 +01:00 committed by GitHub
parent 152b915704
commit 3ceebc82f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 441 additions and 539 deletions

View file

@ -3,7 +3,6 @@ import {
DefaultContextMenuContent, DefaultContextMenuContent,
TldrawEditor, TldrawEditor,
TldrawHandles, TldrawHandles,
TldrawHoveredShapeIndicator,
TldrawScribble, TldrawScribble,
TldrawSelectionBackground, TldrawSelectionBackground,
TldrawSelectionForeground, TldrawSelectionForeground,
@ -23,7 +22,6 @@ const defaultComponents = {
SelectionForeground: TldrawSelectionForeground, SelectionForeground: TldrawSelectionForeground,
SelectionBackground: TldrawSelectionBackground, SelectionBackground: TldrawSelectionBackground,
Handles: TldrawHandles, Handles: TldrawHandles,
HoveredShapeIndicator: TldrawHoveredShapeIndicator,
} }
//[2] //[2]

View file

@ -118,7 +118,7 @@ export class Arc2d extends Geometry2d {
// (undocumented) // (undocumented)
getVertices(): Vec[]; getVertices(): Vec[];
// (undocumented) // (undocumented)
hitTestLineSegment(A: Vec, B: Vec, _zoom: number): boolean; hitTestLineSegment(A: Vec, B: Vec): boolean;
// (undocumented) // (undocumented)
length: number; length: number;
// (undocumented) // (undocumented)
@ -332,7 +332,7 @@ export class Circle2d extends Geometry2d {
// (undocumented) // (undocumented)
getVertices(): Vec[]; getVertices(): Vec[];
// (undocumented) // (undocumented)
hitTestLineSegment(A: Vec, B: Vec, _zoom: number): boolean; hitTestLineSegment(A: Vec, B: Vec, distance?: number): boolean;
// (undocumented) // (undocumented)
nearestPoint(point: Vec): Vec; nearestPoint(point: Vec): Vec;
// (undocumented) // (undocumented)
@ -414,7 +414,7 @@ export class CubicSpline2d extends Geometry2d {
// (undocumented) // (undocumented)
getVertices(): Vec[]; getVertices(): Vec[];
// (undocumented) // (undocumented)
hitTestLineSegment(A: Vec, B: Vec, zoom: number): boolean; hitTestLineSegment(A: Vec, B: Vec): boolean;
// (undocumented) // (undocumented)
get length(): number; get length(): number;
// (undocumented) // (undocumented)
@ -471,9 +471,6 @@ export function DefaultHandle({ handle, isCoarse, className, zoom }: TLHandlePro
// @public (undocumented) // @public (undocumented)
export const DefaultHandles: ({ children }: TLHandlesProps) => JSX_2.Element; export const DefaultHandles: ({ children }: TLHandlesProps) => JSX_2.Element;
// @public (undocumented)
export function DefaultHoveredShapeIndicator({ shapeId }: TLHoveredShapeIndicatorProps): JSX_2.Element | null;
// @public (undocumented) // @public (undocumented)
export function DefaultScribble({ scribble, zoom, color, opacity, className }: TLScribbleProps): JSX_2.Element | null; export function DefaultScribble({ scribble, zoom, color, opacity, className }: TLScribbleProps): JSX_2.Element | null;
@ -552,7 +549,7 @@ export class Edge2d extends Geometry2d {
// (undocumented) // (undocumented)
getVertices(): Vec[]; getVertices(): Vec[];
// (undocumented) // (undocumented)
hitTestLineSegment(A: Vec, B: Vec, _zoom: number): boolean; hitTestLineSegment(A: Vec, B: Vec, distance?: number): boolean;
// (undocumented) // (undocumented)
get length(): number; get length(): number;
// (undocumented) // (undocumented)
@ -968,7 +965,7 @@ export class Ellipse2d extends Geometry2d {
// (undocumented) // (undocumented)
h: number; h: number;
// (undocumented) // (undocumented)
hitTestLineSegment(A: Vec, B: Vec, zoom: number): boolean; hitTestLineSegment(A: Vec, B: Vec): boolean;
// (undocumented) // (undocumented)
nearestPoint(A: Vec): Vec; nearestPoint(A: Vec): Vec;
// (undocumented) // (undocumented)
@ -1458,7 +1455,7 @@ export class Polyline2d extends Geometry2d {
// (undocumented) // (undocumented)
getVertices(): Vec[]; getVertices(): Vec[];
// (undocumented) // (undocumented)
hitTestLineSegment(A: Vec, B: Vec, zoom: number): boolean; hitTestLineSegment(A: Vec, B: Vec, distance?: number): boolean;
// (undocumented) // (undocumented)
get length(): number; get length(): number;
// (undocumented) // (undocumented)
@ -2302,11 +2299,6 @@ export type TLHistoryMark = {
onRedo: boolean; onRedo: boolean;
}; };
// @public (undocumented)
export type TLHoveredShapeIndicatorProps = {
shapeId: TLShapeId;
};
// @public (undocumented) // @public (undocumented)
export type TLInterruptEvent = (info: TLInterruptEventInfo) => void; export type TLInterruptEvent = (info: TLInterruptEventInfo) => void;
@ -2539,6 +2531,7 @@ export type TLShapeIndicatorProps = {
color?: string | undefined; color?: string | undefined;
opacity?: number; opacity?: number;
className?: string; className?: string;
hidden?: boolean;
}; };
// @public (undocumented) // @public (undocumented)
@ -2726,7 +2719,6 @@ export function useEditorComponents(): Partial<{
Spinner: ComponentType | null; Spinner: ComponentType | null;
SelectionForeground: ComponentType<TLSelectionForegroundProps> | null; SelectionForeground: ComponentType<TLSelectionForegroundProps> | null;
SelectionBackground: ComponentType<TLSelectionBackgroundProps> | null; SelectionBackground: ComponentType<TLSelectionBackgroundProps> | null;
HoveredShapeIndicator: ComponentType<TLHoveredShapeIndicatorProps> | null;
OnTheCanvas: ComponentType | null; OnTheCanvas: ComponentType | null;
InFrontOfTheCanvas: ComponentType | null; InFrontOfTheCanvas: ComponentType | null;
LoadingScreen: ComponentType | null; LoadingScreen: ComponentType | null;
@ -2850,6 +2842,8 @@ export class Vec {
// (undocumented) // (undocumented)
static DistanceToLineThroughPoint(A: VecLike, u: VecLike, P: VecLike): number; static DistanceToLineThroughPoint(A: VecLike, u: VecLike, P: VecLike): number;
// (undocumented) // (undocumented)
static DistMin(A: VecLike, B: VecLike, n: number): boolean;
// (undocumented)
static Div(A: VecLike, t: number): Vec; static Div(A: VecLike, t: number): Vec;
// (undocumented) // (undocumented)
div(t: number): this; div(t: number): this;

View file

@ -605,14 +605,6 @@
"text": "Vec", "text": "Vec",
"canonicalReference": "@tldraw/editor!Vec:class" "canonicalReference": "@tldraw/editor!Vec:class"
}, },
{
"kind": "Content",
"text": ", _zoom: "
},
{
"kind": "Content",
"text": "number"
},
{ {
"kind": "Content", "kind": "Content",
"text": "): " "text": "): "
@ -628,8 +620,8 @@
], ],
"isStatic": false, "isStatic": false,
"returnTypeTokenRange": { "returnTypeTokenRange": {
"startIndex": 7, "startIndex": 5,
"endIndex": 8 "endIndex": 6
}, },
"releaseTag": "Public", "releaseTag": "Public",
"isProtected": false, "isProtected": false,
@ -650,14 +642,6 @@
"endIndex": 4 "endIndex": 4
}, },
"isOptional": false "isOptional": false
},
{
"parameterName": "_zoom",
"parameterTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"isOptional": false
} }
], ],
"isOptional": false, "isOptional": false,
@ -4438,7 +4422,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": ", _zoom: " "text": ", distance?: "
}, },
{ {
"kind": "Content", "kind": "Content",
@ -4483,12 +4467,12 @@
"isOptional": false "isOptional": false
}, },
{ {
"parameterName": "_zoom", "parameterName": "distance",
"parameterTypeTokenRange": { "parameterTypeTokenRange": {
"startIndex": 5, "startIndex": 5,
"endIndex": 6 "endIndex": 6
}, },
"isOptional": false "isOptional": true
} }
], ],
"isOptional": false, "isOptional": false,
@ -5746,14 +5730,6 @@
"text": "Vec", "text": "Vec",
"canonicalReference": "@tldraw/editor!Vec:class" "canonicalReference": "@tldraw/editor!Vec:class"
}, },
{
"kind": "Content",
"text": ", zoom: "
},
{
"kind": "Content",
"text": "number"
},
{ {
"kind": "Content", "kind": "Content",
"text": "): " "text": "): "
@ -5769,8 +5745,8 @@
], ],
"isStatic": false, "isStatic": false,
"returnTypeTokenRange": { "returnTypeTokenRange": {
"startIndex": 7, "startIndex": 5,
"endIndex": 8 "endIndex": 6
}, },
"releaseTag": "Public", "releaseTag": "Public",
"isProtected": false, "isProtected": false,
@ -5791,14 +5767,6 @@
"endIndex": 4 "endIndex": 4
}, },
"isOptional": false "isOptional": false
},
{
"parameterName": "zoom",
"parameterTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"isOptional": false
} }
], ],
"isOptional": false, "isOptional": false,
@ -6449,61 +6417,6 @@
], ],
"name": "DefaultHandles" "name": "DefaultHandles"
}, },
{
"kind": "Function",
"canonicalReference": "@tldraw/editor!DefaultHoveredShapeIndicator:function(1)",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export declare function DefaultHoveredShapeIndicator({ shapeId }: "
},
{
"kind": "Reference",
"text": "TLHoveredShapeIndicatorProps",
"canonicalReference": "@tldraw/editor!TLHoveredShapeIndicatorProps:type"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "import(\"react/jsx-runtime\")."
},
{
"kind": "Reference",
"text": "JSX.Element",
"canonicalReference": "@types/react!JSX.Element:interface"
},
{
"kind": "Content",
"text": " | null"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/editor/src/lib/components/default-components/DefaultHoveredShapeIndicator.tsx",
"returnTypeTokenRange": {
"startIndex": 3,
"endIndex": 6
},
"releaseTag": "Public",
"overloadIndex": 1,
"parameters": [
{
"parameterName": "{ shapeId }",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
}
],
"name": "DefaultHoveredShapeIndicator"
},
{ {
"kind": "Function", "kind": "Function",
"canonicalReference": "@tldraw/editor!DefaultScribble:function(1)", "canonicalReference": "@tldraw/editor!DefaultScribble:function(1)",
@ -7134,7 +7047,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": ", _zoom: " "text": ", distance?: "
}, },
{ {
"kind": "Content", "kind": "Content",
@ -7179,12 +7092,12 @@
"isOptional": false "isOptional": false
}, },
{ {
"parameterName": "_zoom", "parameterName": "distance",
"parameterTypeTokenRange": { "parameterTypeTokenRange": {
"startIndex": 5, "startIndex": 5,
"endIndex": 6 "endIndex": 6
}, },
"isOptional": false "isOptional": true
} }
], ],
"isOptional": false, "isOptional": false,
@ -20311,14 +20224,6 @@
"text": "Vec", "text": "Vec",
"canonicalReference": "@tldraw/editor!Vec:class" "canonicalReference": "@tldraw/editor!Vec:class"
}, },
{
"kind": "Content",
"text": ", zoom: "
},
{
"kind": "Content",
"text": "number"
},
{ {
"kind": "Content", "kind": "Content",
"text": "): " "text": "): "
@ -20334,8 +20239,8 @@
], ],
"isStatic": false, "isStatic": false,
"returnTypeTokenRange": { "returnTypeTokenRange": {
"startIndex": 7, "startIndex": 5,
"endIndex": 8 "endIndex": 6
}, },
"releaseTag": "Public", "releaseTag": "Public",
"isProtected": false, "isProtected": false,
@ -20356,14 +20261,6 @@
"endIndex": 4 "endIndex": 4
}, },
"isOptional": false "isOptional": false
},
{
"parameterName": "zoom",
"parameterTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"isOptional": false
} }
], ],
"isOptional": false, "isOptional": false,
@ -28466,7 +28363,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": ", zoom: " "text": ", distance?: "
}, },
{ {
"kind": "Content", "kind": "Content",
@ -28511,12 +28408,12 @@
"isOptional": false "isOptional": false
}, },
{ {
"parameterName": "zoom", "parameterName": "distance",
"parameterTypeTokenRange": { "parameterTypeTokenRange": {
"startIndex": 5, "startIndex": 5,
"endIndex": 6 "endIndex": 6
}, },
"isOptional": false "isOptional": true
} }
], ],
"isOptional": false, "isOptional": false,
@ -39669,41 +39566,6 @@
"endIndex": 2 "endIndex": 2
} }
}, },
{
"kind": "TypeAlias",
"canonicalReference": "@tldraw/editor!TLHoveredShapeIndicatorProps:type",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export type TLHoveredShapeIndicatorProps = "
},
{
"kind": "Content",
"text": "{\n shapeId: "
},
{
"kind": "Reference",
"text": "TLShapeId",
"canonicalReference": "@tldraw/tlschema!TLShapeId:type"
},
{
"kind": "Content",
"text": ";\n}"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/editor/src/lib/components/default-components/DefaultHoveredShapeIndicator.tsx",
"releaseTag": "Public",
"name": "TLHoveredShapeIndicatorProps",
"typeTokenRange": {
"startIndex": 1,
"endIndex": 4
}
},
{ {
"kind": "TypeAlias", "kind": "TypeAlias",
"canonicalReference": "@tldraw/editor!TLInterruptEvent:type", "canonicalReference": "@tldraw/editor!TLInterruptEvent:type",
@ -41940,7 +41802,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": ";\n color?: string | undefined;\n opacity?: number;\n className?: string;\n}" "text": ";\n color?: string | undefined;\n opacity?: number;\n className?: string;\n hidden?: boolean;\n}"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -43835,24 +43697,6 @@
"text": "TLSelectionBackgroundProps", "text": "TLSelectionBackgroundProps",
"canonicalReference": "@tldraw/editor!TLSelectionBackgroundProps:type" "canonicalReference": "@tldraw/editor!TLSelectionBackgroundProps:type"
}, },
{
"kind": "Content",
"text": "> | null;\n HoveredShapeIndicator: "
},
{
"kind": "Reference",
"text": "ComponentType",
"canonicalReference": "@types/react!React.ComponentType:type"
},
{
"kind": "Content",
"text": "<"
},
{
"kind": "Reference",
"text": "TLHoveredShapeIndicatorProps",
"canonicalReference": "@tldraw/editor!TLHoveredShapeIndicatorProps:type"
},
{ {
"kind": "Content", "kind": "Content",
"text": "> | null;\n OnTheCanvas: " "text": "> | null;\n OnTheCanvas: "
@ -43906,7 +43750,7 @@
"fileUrlPath": "packages/editor/src/lib/hooks/useEditorComponents.tsx", "fileUrlPath": "packages/editor/src/lib/hooks/useEditorComponents.tsx",
"returnTypeTokenRange": { "returnTypeTokenRange": {
"startIndex": 1, "startIndex": 1,
"endIndex": 90 "endIndex": 86
}, },
"releaseTag": "Public", "releaseTag": "Public",
"overloadIndex": 1, "overloadIndex": 1,
@ -46051,6 +45895,88 @@
"isAbstract": false, "isAbstract": false,
"name": "DistanceToLineThroughPoint" "name": "DistanceToLineThroughPoint"
}, },
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Vec.DistMin:member(1)",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "static DistMin(A: "
},
{
"kind": "Reference",
"text": "VecLike",
"canonicalReference": "@tldraw/editor!VecLike:type"
},
{
"kind": "Content",
"text": ", B: "
},
{
"kind": "Reference",
"text": "VecLike",
"canonicalReference": "@tldraw/editor!VecLike:type"
},
{
"kind": "Content",
"text": ", n: "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "boolean"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": true,
"returnTypeTokenRange": {
"startIndex": 7,
"endIndex": 8
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "A",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
},
{
"parameterName": "B",
"parameterTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"isOptional": false
},
{
"parameterName": "n",
"parameterTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "DistMin"
},
{ {
"kind": "Method", "kind": "Method",
"canonicalReference": "@tldraw/editor!Vec#div:member(1)", "canonicalReference": "@tldraw/editor!Vec#div:member(1)",

View file

@ -465,7 +465,7 @@ input,
transform-origin: top left; transform-origin: top left;
fill: none; fill: none;
stroke-width: calc(1.5px * var(--tl-scale)); stroke-width: calc(1.5px * var(--tl-scale));
contain: size; contain: size layout;
} }
/* ------------------ SelectionBox ------------------ */ /* ------------------ SelectionBox ------------------ */

View file

@ -62,10 +62,6 @@ export {
DefaultHandles, DefaultHandles,
type TLHandlesProps, type TLHandlesProps,
} from './lib/components/default-components/DefaultHandles' } from './lib/components/default-components/DefaultHandles'
export {
DefaultHoveredShapeIndicator,
type TLHoveredShapeIndicatorProps,
} from './lib/components/default-components/DefaultHoveredShapeIndicator'
export { export {
DefaultScribble, DefaultScribble,
type TLScribbleProps, type TLScribbleProps,

View file

@ -151,8 +151,7 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
<BrushWrapper /> <BrushWrapper />
<ScribbleWrapper /> <ScribbleWrapper />
<ZoomBrushWrapper /> <ZoomBrushWrapper />
<SelectedIdIndicators /> <ShapeIndicators />
<HoveredShapeIndicator />
<HintedShapeIndicator /> <HintedShapeIndicator />
<SnapIndicatorWrapper /> <SnapIndicatorWrapper />
<SelectionForegroundWrapper /> <SelectionForegroundWrapper />
@ -431,16 +430,17 @@ function ShapesToDisplay() {
) )
} }
function SelectedIdIndicators() { function ShapeIndicators() {
const editor = useEditor() const editor = useEditor()
const selectedShapeIds = useValue('selectedShapeIds', () => editor.getSelectedShapeIds(), [ const renderingShapes = useValue('rendering shapes', () => editor.getRenderingShapes(), [editor])
editor, const rPreviousSelectedShapeIds = useRef<Set<TLShapeId>>(new Set())
]) const idsToDisplay = useValue(
const shouldDisplay = useValue(
'should display selected ids', 'should display selected ids',
() => { () => {
// todo: move to tldraw selected ids wrapper // todo: move to tldraw selected ids wrappe
return ( const prev = rPreviousSelectedShapeIds.current
const next = new Set<TLShapeId>()
if (
editor.isInAny( editor.isInAny(
'select.idle', 'select.idle',
'select.brushing', 'select.brushing',
@ -449,52 +449,51 @@ function SelectedIdIndicators() {
'select.pointing_shape', 'select.pointing_shape',
'select.pointing_selection', 'select.pointing_selection',
'select.pointing_handle' 'select.pointing_handle'
) && !editor.getInstanceState().isChangingStyle ) &&
) !editor.getInstanceState().isChangingStyle
) {
const selected = editor.getSelectedShapeIds()
for (const id of selected) {
next.add(id)
}
if (editor.isInAny('select.idle', 'select.editing_shape')) {
const instanceState = editor.getInstanceState()
if (instanceState.isHoveringCanvas && !instanceState.isCoarsePointer) {
const hovered = editor.getHoveredShapeId()
if (hovered) next.add(hovered)
}
}
}
if (prev.size !== next.size) {
rPreviousSelectedShapeIds.current = next
return next
}
for (const id of next) {
if (!prev.has(id)) {
rPreviousSelectedShapeIds.current = next
return next
}
}
return prev
}, },
[editor] [editor]
) )
const { ShapeIndicator } = useEditorComponents() const { ShapeIndicator } = useEditorComponents()
if (!ShapeIndicator) return null if (!ShapeIndicator) return null
if (!shouldDisplay) return null
return ( return (
<> <>
{selectedShapeIds.map((id) => ( {renderingShapes.map(({ id }) => (
<ShapeIndicator <ShapeIndicator key={id + '_indicator'} shapeId={id} hidden={!idsToDisplay.has(id)} />
key={id + '_indicator'}
className="tl-user-indicator__selected"
shapeId={id}
/>
))} ))}
</> </>
) )
} }
const HoveredShapeIndicator = function HoveredShapeIndicator() {
const editor = useEditor()
const { HoveredShapeIndicator } = useEditorComponents()
const isCoarsePointer = useValue(
'coarse pointer',
() => editor.getInstanceState().isCoarsePointer,
[editor]
)
const isHoveringCanvas = useValue(
'hovering canvas',
() => editor.getInstanceState().isHoveringCanvas,
[editor]
)
const hoveredShapeId = useValue('hovered id', () => editor.getCurrentPageState().hoveredShapeId, [
editor,
])
if (isCoarsePointer || !isHoveringCanvas || !hoveredShapeId || !HoveredShapeIndicator) return null
return <HoveredShapeIndicator shapeId={hoveredShapeId} />
}
function HintedShapeIndicator() { function HintedShapeIndicator() {
const editor = useEditor() const editor = useEditor()
const { ShapeIndicator } = useEditorComponents() const { ShapeIndicator } = useEditorComponents()

View file

@ -1,14 +0,0 @@
import { TLShapeId } from '@tldraw/tlschema'
import { useEditorComponents } from '../../hooks/useEditorComponents'
/** @public */
export type TLHoveredShapeIndicatorProps = {
shapeId: TLShapeId
}
/** @public */
export function DefaultHoveredShapeIndicator({ shapeId }: TLHoveredShapeIndicatorProps) {
const { ShapeIndicator } = useEditorComponents()
if (!ShapeIndicator) return null
return <ShapeIndicator className="tl-user-indicator__hovered" shapeId={shapeId} />
}

View file

@ -1,7 +1,7 @@
import { useStateTracking, useValue } from '@tldraw/state' import { useQuickReactor, useStateTracking, useValue } from '@tldraw/state'
import { TLShape, TLShapeId } from '@tldraw/tlschema' import { TLShape, TLShapeId } from '@tldraw/tlschema'
import classNames from 'classnames' import classNames from 'classnames'
import { memo } from 'react' import { memo, useLayoutEffect, useRef } from 'react'
import type { Editor } from '../../editor/Editor' import type { Editor } from '../../editor/Editor'
import { ShapeUtil } from '../../editor/shapes/ShapeUtil' import { ShapeUtil } from '../../editor/shapes/ShapeUtil'
import { useEditor } from '../../hooks/useEditor' import { useEditor } from '../../hooks/useEditor'
@ -38,6 +38,7 @@ export type TLShapeIndicatorProps = {
color?: string | undefined color?: string | undefined
opacity?: number opacity?: number
className?: string className?: string
hidden?: boolean
} }
/** @public */ /** @public */
@ -45,28 +46,34 @@ export const DefaultShapeIndicator = memo(function DefaultShapeIndicator({
shapeId, shapeId,
className, className,
color, color,
hidden,
opacity, opacity,
}: TLShapeIndicatorProps) { }: TLShapeIndicatorProps) {
const editor = useEditor() const editor = useEditor()
const transform = useValue( const rIndicator = useRef<SVGSVGElement>(null)
useQuickReactor(
'indicator transform', 'indicator transform',
() => { () => {
const elm = rIndicator.current
if (!elm) return
const pageTransform = editor.getShapePageTransform(shapeId) const pageTransform = editor.getShapePageTransform(shapeId)
if (!pageTransform) return '' if (!pageTransform) return
return pageTransform.toCssString() elm.style.setProperty('transform', pageTransform.toCssString())
}, },
[editor, shapeId] [editor, shapeId]
) )
useLayoutEffect(() => {
const elm = rIndicator.current
if (!elm) return
elm.style.setProperty('display', hidden ? 'none' : 'block')
}, [hidden])
return ( return (
<svg className={classNames('tl-overlays__item', className)}> <svg ref={rIndicator} className={classNames('tl-overlays__item', className)}>
<g <g className="tl-shape-indicator" stroke={color ?? 'var(--color-selected)'} opacity={opacity}>
className="tl-shape-indicator"
transform={transform}
stroke={color ?? 'var(--color-selected)'}
opacity={opacity}
>
<InnerIndicator editor={editor} id={shapeId} /> <InnerIndicator editor={editor} id={shapeId} />
</g> </g>
</svg> </svg>

View file

@ -4490,7 +4490,6 @@ export class Editor extends EventEmitter<TLEventMap> {
* *
* @public * @public
*/ */
isPointInShape( isPointInShape(
shape: TLShape | TLShapeId, shape: TLShape | TLShapeId,
point: VecLike, point: VecLike,
@ -8377,7 +8376,7 @@ export class Editor extends EventEmitter<TLEventMap> {
private _pendingEventsForNextTick: TLEventInfo[] = [] private _pendingEventsForNextTick: TLEventInfo[] = []
private _flushEventsForTick = (elapsed: number) => { private _flushEventsForTick(elapsed: number) {
this.batch(() => { this.batch(() => {
if (this._pendingEventsForNextTick.length > 0) { if (this._pendingEventsForNextTick.length > 0) {
const events = [...this._pendingEventsForNextTick] const events = [...this._pendingEventsForNextTick]

View file

@ -133,10 +133,8 @@ export class HandleSnaps {
let minDistanceForSnapPoint = snapThreshold let minDistanceForSnapPoint = snapThreshold
let nearestSnapPoint: Vec | null = null let nearestSnapPoint: Vec | null = null
for (const snapPoint of this.iterateSnapPointsInPageSpace(currentShapeId, handle)) { for (const snapPoint of this.iterateSnapPointsInPageSpace(currentShapeId, handle)) {
const distance = Vec.Dist(handleInPageSpace, snapPoint) if (Vec.DistMin(handleInPageSpace, snapPoint, minDistanceForSnapPoint)) {
minDistanceForSnapPoint = Vec.Dist(handleInPageSpace, snapPoint)
if (distance < minDistanceForSnapPoint) {
minDistanceForSnapPoint = distance
nearestSnapPoint = snapPoint nearestSnapPoint = snapPoint
} }
} }
@ -154,10 +152,9 @@ export class HandleSnaps {
const nearestShapePointInShapeSpace = outline.nearestPoint(pointInShapeSpace) const nearestShapePointInShapeSpace = outline.nearestPoint(pointInShapeSpace)
const nearestInPageSpace = shapePageTransform.applyToPoint(nearestShapePointInShapeSpace) const nearestInPageSpace = shapePageTransform.applyToPoint(nearestShapePointInShapeSpace)
const distance = Vec.Dist(handleInPageSpace, nearestInPageSpace)
if (distance < minDistanceForOutline) { if (Vec.DistMin(handleInPageSpace, nearestInPageSpace, minDistanceForOutline)) {
minDistanceForOutline = distance minDistanceForOutline = Vec.Dist(handleInPageSpace, nearestInPageSpace)
nearestPointOnOutline = nearestInPageSpace nearestPointOnOutline = nearestInPageSpace
} }
} }

View file

@ -263,8 +263,7 @@ export function getCurvedArrowInfo(
tB.setTo(handleArc.center).add(u.mul(handleArc.radius)) tB.setTo(handleArc.center).add(u.mul(handleArc.radius))
} }
const distAB = Vec.Dist(tA, tB) if (Vec.DistMin(tA, tB, minLength)) {
if (distAB < minLength) {
if (offsetA !== 0 && offsetB !== 0) { if (offsetA !== 0 && offsetB !== 0) {
offsetA *= -1.5 offsetA *= -1.5
offsetB *= -1.5 offsetB *= -1.5

View file

@ -152,9 +152,8 @@ export function getStraightArrowInfo(editor: Editor, shape: TLArrowShape): TLArr
const tA = a.clone().add(u.clone().mul(offsetA * (didFlip ? -1 : 1))) const tA = a.clone().add(u.clone().mul(offsetA * (didFlip ? -1 : 1)))
const tB = b.clone().sub(u.clone().mul(offsetB * (didFlip ? -1 : 1))) const tB = b.clone().sub(u.clone().mul(offsetB * (didFlip ? -1 : 1)))
const distAB = Vec.Dist(tA, tB)
if (distAB < minLength) { if (Vec.DistMin(tA, tB, minLength)) {
if (offsetA !== 0 && offsetB !== 0) { if (offsetA !== 0 && offsetB !== 0) {
// both bound + offset // both bound + offset
offsetA *= -1.5 offsetA *= -1.5
@ -241,7 +240,7 @@ function updateArrowheadPointWithBoundShape(
if (intersection !== null) { if (intersection !== null) {
targetInt = targetInt =
intersection.sort((p1, p2) => Vec.Dist(p1, targetFrom) - Vec.Dist(p2, targetFrom))[0] ?? intersection.sort((p1, p2) => Vec.Dist2(p1, targetFrom) - Vec.Dist2(p2, targetFrom))[0] ??
(isClosed ? undefined : targetTo) (isClosed ? undefined : targetTo)
} }

View file

@ -146,10 +146,14 @@ export abstract class StateNode implements Partial<TLEventHandlers> {
handleEvent = (info: Exclude<TLEventInfo, TLPinchEventInfo>) => { handleEvent = (info: Exclude<TLEventInfo, TLPinchEventInfo>) => {
const cbName = EVENT_NAME_MAP[info.name] const cbName = EVENT_NAME_MAP[info.name]
const x = this.getCurrent() const currentActiveChild = this._current.__unsafe__getWithoutCapture()
this[cbName]?.(info as any) this[cbName]?.(info as any)
if (this.getCurrent() === x && this.getIsActive()) { if (
x?.handleEvent(info) this._isActive.__unsafe__getWithoutCapture() &&
currentActiveChild &&
currentActiveChild === this._current.__unsafe__getWithoutCapture()
) {
currentActiveChild.handleEvent(info)
} }
} }

View file

@ -17,10 +17,6 @@ import {
import { DefaultGrid, TLGridProps } from '../components/default-components/DefaultGrid' import { DefaultGrid, TLGridProps } from '../components/default-components/DefaultGrid'
import { DefaultHandle, TLHandleProps } from '../components/default-components/DefaultHandle' import { DefaultHandle, TLHandleProps } from '../components/default-components/DefaultHandle'
import { DefaultHandles, TLHandlesProps } from '../components/default-components/DefaultHandles' import { DefaultHandles, TLHandlesProps } from '../components/default-components/DefaultHandles'
import {
DefaultHoveredShapeIndicator,
TLHoveredShapeIndicatorProps,
} from '../components/default-components/DefaultHoveredShapeIndicator'
import { DefaultScribble, TLScribbleProps } from '../components/default-components/DefaultScribble' import { DefaultScribble, TLScribbleProps } from '../components/default-components/DefaultScribble'
import { import {
DefaultSelectionBackground, DefaultSelectionBackground,
@ -71,7 +67,6 @@ export interface BaseEditorComponents {
Spinner: ComponentType Spinner: ComponentType
SelectionForeground: ComponentType<TLSelectionForegroundProps> SelectionForeground: ComponentType<TLSelectionForegroundProps>
SelectionBackground: ComponentType<TLSelectionBackgroundProps> SelectionBackground: ComponentType<TLSelectionBackgroundProps>
HoveredShapeIndicator: ComponentType<TLHoveredShapeIndicatorProps>
OnTheCanvas: ComponentType OnTheCanvas: ComponentType
InFrontOfTheCanvas: ComponentType InFrontOfTheCanvas: ComponentType
LoadingScreen: ComponentType LoadingScreen: ComponentType
@ -129,7 +124,6 @@ export function EditorComponentsProvider({
Spinner: DefaultSpinner, Spinner: DefaultSpinner,
SelectionBackground: DefaultSelectionBackground, SelectionBackground: DefaultSelectionBackground,
SelectionForeground: DefaultSelectionForeground, SelectionForeground: DefaultSelectionForeground,
HoveredShapeIndicator: DefaultHoveredShapeIndicator,
ShapeIndicator: DefaultShapeIndicator, ShapeIndicator: DefaultShapeIndicator,
OnTheCanvas: null, OnTheCanvas: null,
InFrontOfTheCanvas: null, InFrontOfTheCanvas: null,

View file

@ -317,6 +317,11 @@ export class Vec {
return Math.hypot(A.y - B.y, A.x - B.x) return Math.hypot(A.y - B.y, A.x - B.x)
} }
// Get whether a distance between two points is less than a number. This is faster to calulate than using `Vec.Dist(a, b) < n`.
static DistMin(A: VecLike, B: VecLike, n: number): boolean {
return (A.x - B.x) * (A.x - B.x) + (A.y - B.y) * (A.y - B.y) < n ** 2
}
// Get the squared distance between two points. This is faster to calculate (no square root) so useful for "minimum distance" checks where the actual measurement does not matter. // Get the squared distance between two points. This is faster to calculate (no square root) so useful for "minimum distance" checks where the actual measurement does not matter.
static Dist2(A: VecLike, B: VecLike): number { static Dist2(A: VecLike, B: VecLike): number {
return (A.x - B.x) * (A.x - B.x) + (A.y - B.y) * (A.y - B.y) return (A.x - B.x) * (A.x - B.x) + (A.y - B.y) * (A.y - B.y)
@ -416,11 +421,12 @@ export class Vec {
} }
static NearestPointOnLineSegment(A: VecLike, B: VecLike, P: VecLike, clamp = true): Vec { static NearestPointOnLineSegment(A: VecLike, B: VecLike, P: VecLike, clamp = true): Vec {
if (Vec.Equals(A, P)) return Vec.From(P)
if (Vec.Equals(B, P)) return Vec.From(P)
const u = Vec.Tan(B, A) const u = Vec.Tan(B, A)
const C = Vec.Add(A, Vec.Mul(u, Vec.Sub(P, A).pry(u))) const C = Vec.Add(A, Vec.Mul(u, Vec.Sub(P, A).pry(u)))
// todo: fix error P is B or A, which leads to a NaN value
if (clamp) { if (clamp) {
if (C.x < Math.min(A.x, B.x)) return Vec.Cast(A.x < B.x ? A : B) if (C.x < Math.min(A.x, B.x)) return Vec.Cast(A.x < B.x ? A : B)
if (C.x > Math.max(A.x, B.x)) return Vec.Cast(A.x > B.x ? A : B) if (C.x > Math.max(A.x, B.x)) return Vec.Cast(A.x > B.x ? A : B)

View file

@ -66,7 +66,7 @@ export class Arc2d extends Geometry2d {
return nearest return nearest
} }
hitTestLineSegment(A: Vec, B: Vec, _zoom: number): boolean { hitTestLineSegment(A: Vec, B: Vec): boolean {
const { _center, radius, measure, angleStart, angleEnd } = this const { _center, radius, measure, angleStart, angleEnd } = this
const intersection = intersectLineSegmentCircle(A, B, _center, radius) const intersection = intersectLineSegmentCircle(A, B, _center, radius)
if (intersection === null) return false if (intersection === null) return false

View file

@ -49,8 +49,8 @@ export class Circle2d extends Geometry2d {
return _center.clone().add(point.clone().sub(_center).uni().mul(radius)) return _center.clone().add(point.clone().sub(_center).uni().mul(radius))
} }
hitTestLineSegment(A: Vec, B: Vec, _zoom: number): boolean { hitTestLineSegment(A: Vec, B: Vec, distance = 0): boolean {
const { _center, radius } = this const { _center, radius } = this
return intersectLineSegmentCircle(A, B, _center, radius) !== null return intersectLineSegmentCircle(A, B, _center, radius + distance) !== null
} }
} }

View file

@ -81,7 +81,7 @@ export class CubicSpline2d extends Geometry2d {
return nearest return nearest
} }
hitTestLineSegment(A: Vec, B: Vec, zoom: number): boolean { hitTestLineSegment(A: Vec, B: Vec): boolean {
return this.segments.some((segment) => segment.hitTestLineSegment(A, B, zoom)) return this.segments.some((segment) => segment.hitTestLineSegment(A, B))
} }
} }

View file

@ -53,7 +53,9 @@ export class Edge2d extends Geometry2d {
return new Vec(cx, cy) return new Vec(cx, cy)
} }
override hitTestLineSegment(A: Vec, B: Vec, _zoom: number): boolean { override hitTestLineSegment(A: Vec, B: Vec, distance = 0): boolean {
return linesIntersect(A, B, this.start, this.end) return (
linesIntersect(A, B, this.start, this.end) || this.distanceToLineSegment(A, B) <= distance
)
} }
} }

View file

@ -90,8 +90,8 @@ export class Ellipse2d extends Geometry2d {
return nearest return nearest
} }
hitTestLineSegment(A: Vec, B: Vec, zoom: number): boolean { hitTestLineSegment(A: Vec, B: Vec): boolean {
return this.edges.some((edge) => edge.hitTestLineSegment(A, B, zoom)) return this.edges.some((edge) => edge.hitTestLineSegment(A, B))
} }
getBounds() { getBounds() {

View file

@ -30,24 +30,46 @@ export abstract class Geometry2d {
abstract nearestPoint(point: Vec): Vec abstract nearestPoint(point: Vec): Vec
// hitTestPoint(point: Vec, margin = 0, hitInside = false) {
// // We've removed the broad phase here; that should be done outside of the call
// return this.distanceToPoint(point, hitInside) <= margin
// }
hitTestPoint(point: Vec, margin = 0, hitInside = false) { hitTestPoint(point: Vec, margin = 0, hitInside = false) {
// We've removed the broad phase here; that should be done outside of the call // First check whether the point is inside
return this.distanceToPoint(point, hitInside) <= margin if (this.isClosed && (this.isFilled || hitInside) && pointInPolygon(point, this.vertices)) {
return true
}
// Then check whether the distance is within the margin
return Vec.Dist2(point, this.nearestPoint(point)) <= margin * margin
} }
distanceToPoint(point: Vec, hitInside = false) { distanceToPoint(point: Vec, hitInside = false) {
const dist = point.dist(this.nearestPoint(point)) return (
point.dist(this.nearestPoint(point)) *
if (this.isClosed && (this.isFilled || hitInside) && pointInPolygon(point, this.vertices)) { (this.isClosed && (this.isFilled || hitInside) && pointInPolygon(point, this.vertices)
return -dist ? -1
} : 1)
return dist )
} }
distanceToLineSegment(A: Vec, B: Vec) { distanceToLineSegment(A: Vec, B: Vec) {
const point = this.nearestPointOnLineSegment(A, B) if (A.equals(B)) return this.distanceToPoint(A)
const dist = Vec.DistanceToLineSegment(A, B, point) // repeated, bleh const { vertices } = this
return this.isClosed && this.isFilled && pointInPolygon(point, this.vertices) ? -dist : dist let nearest: Vec | undefined
let dist = Infinity
let d: number, p: Vec, q: Vec
for (let i = 0; i < vertices.length; i++) {
p = vertices[i]
q = Vec.NearestPointOnLineSegment(A, B, p, true)
d = Vec.Dist2(p, q)
if (d < dist) {
dist = d
nearest = q
}
}
if (!nearest) throw Error('nearest point not found')
return this.isClosed && this.isFilled && pointInPolygon(nearest, this.vertices) ? -dist : dist
} }
hitTestLineSegment(A: Vec, B: Vec, distance = 0): boolean { hitTestLineSegment(A: Vec, B: Vec, distance = 0): boolean {
@ -58,14 +80,14 @@ export abstract class Geometry2d {
const { vertices } = this const { vertices } = this
let nearest: Vec | undefined let nearest: Vec | undefined
let dist = Infinity let dist = Infinity
let d: number let d: number, p: Vec, q: Vec
let p: Vec
for (let i = 0; i < vertices.length; i++) { for (let i = 0; i < vertices.length; i++) {
p = vertices[i] p = vertices[i]
d = Vec.DistanceToLineSegment(A, B, p) q = Vec.NearestPointOnLineSegment(A, B, p, true)
d = Vec.Dist2(p, q)
if (d < dist) { if (d < dist) {
dist = d dist = d
nearest = p nearest = q
} }
} }
if (!nearest) throw Error('nearest point not found') if (!nearest) throw Error('nearest point not found')

View file

@ -65,7 +65,13 @@ export class Polyline2d extends Geometry2d {
return nearest return nearest
} }
hitTestLineSegment(A: Vec, B: Vec, zoom: number): boolean { hitTestLineSegment(A: Vec, B: Vec, distance = 0): boolean {
return this.segments.some((edge) => edge.hitTestLineSegment(A, B, zoom)) const { segments } = this
for (let i = 0, n = segments.length; i < n; i++) {
if (segments[i].hitTestLineSegment(A, B, distance)) {
return true
}
}
return false
} }
} }

View file

@ -3,6 +3,8 @@ import { HALF_PI, PI } from '../utils'
import { Ellipse2d } from './Ellipse2d' import { Ellipse2d } from './Ellipse2d'
import { Geometry2dOptions } from './Geometry2d' import { Geometry2dOptions } from './Geometry2d'
const STADIUM_VERTICES_LENGTH = 18
/** @public */ /** @public */
export class Stadium2d extends Ellipse2d { export class Stadium2d extends Ellipse2d {
constructor( constructor(
@ -12,28 +14,31 @@ export class Stadium2d extends Ellipse2d {
} }
getVertices() { getVertices() {
// Perimeter of the ellipse
const w = Math.max(1, this.w) const w = Math.max(1, this.w)
const h = Math.max(1, this.h) const h = Math.max(1, this.h)
const cx = w / 2 const cx = w / 2
const cy = h / 2 const cy = h / 2
const points: Vec[] = Array(STADIUM_VERTICES_LENGTH)
const len = 10 let t1: number, t2: number
const points: Vec[] = Array(len * 2 - 2)
if (h > w) { if (h > w) {
for (let i = 0; i < len - 1; i++) { for (let i = 0; i < STADIUM_VERTICES_LENGTH - 1; i++) {
const t1 = -PI + (PI * i) / (len - 2) t1 = -PI + (PI * i) / (STADIUM_VERTICES_LENGTH - 2)
const t2 = (PI * i) / (len - 2) t2 = (PI * i) / (STADIUM_VERTICES_LENGTH - 2)
points[i] = new Vec(cx + cx * Math.cos(t1), cx + cx * Math.sin(t1)) points[i] = new Vec(cx + cx * Math.cos(t1), cx + cx * Math.sin(t1))
points[i + (len - 1)] = new Vec(cx + cx * Math.cos(t2), h - cx + cx * Math.sin(t2)) points[i + (STADIUM_VERTICES_LENGTH - 1)] = new Vec(
cx + cx * Math.cos(t2),
h - cx + cx * Math.sin(t2)
)
} }
} else { } else {
for (let i = 0; i < len - 1; i++) { for (let i = 0; i < STADIUM_VERTICES_LENGTH - 1; i++) {
const t1 = -HALF_PI + (PI * i) / (len - 2) t1 = -HALF_PI + (PI * i) / (STADIUM_VERTICES_LENGTH - 2)
const t2 = HALF_PI + (PI * -i) / (len - 2) t2 = HALF_PI + (PI * -i) / (STADIUM_VERTICES_LENGTH - 2)
points[i] = new Vec(w - cy + cy * Math.cos(t1), h - cy + cy * Math.sin(t1)) points[i] = new Vec(w - cy + cy * Math.cos(t1), h - cy + cy * Math.sin(t1))
points[i + (len - 1)] = new Vec(cy - cy * Math.cos(t2), h - cy + cy * Math.sin(t2)) points[i + (STADIUM_VERTICES_LENGTH - 1)] = new Vec(
cy - cy * Math.cos(t2),
h - cy + cy * Math.sin(t2)
)
} }
} }

View file

@ -82,7 +82,6 @@ import { TLGeoShape } from '@tldraw/editor';
import { TLHandle } from '@tldraw/editor'; import { TLHandle } from '@tldraw/editor';
import { TLHandlesProps } from '@tldraw/editor'; import { TLHandlesProps } from '@tldraw/editor';
import { TLHighlightShape } from '@tldraw/editor'; import { TLHighlightShape } from '@tldraw/editor';
import { TLHoveredShapeIndicatorProps } from '@tldraw/editor';
import { TLImageShape } from '@tldraw/editor'; import { TLImageShape } from '@tldraw/editor';
import { TLInterruptEvent } from '@tldraw/editor'; import { TLInterruptEvent } from '@tldraw/editor';
import { TLKeyboardEvent } from '@tldraw/editor'; import { TLKeyboardEvent } from '@tldraw/editor';
@ -1452,9 +1451,6 @@ export interface TldrawFile {
// @public (undocumented) // @public (undocumented)
export function TldrawHandles({ children }: TLHandlesProps): JSX_2.Element | null; export function TldrawHandles({ children }: TLHandlesProps): JSX_2.Element | null;
// @public (undocumented)
export function TldrawHoveredShapeIndicator({ shapeId }: TLHoveredShapeIndicatorProps): JSX_2.Element | null;
// @public // @public
export const TldrawImage: NamedExoticComponent< { export const TldrawImage: NamedExoticComponent< {
snapshot: StoreSnapshot<TLRecord>; snapshot: StoreSnapshot<TLRecord>;

View file

@ -16579,61 +16579,6 @@
], ],
"name": "TldrawHandles" "name": "TldrawHandles"
}, },
{
"kind": "Function",
"canonicalReference": "tldraw!TldrawHoveredShapeIndicator:function(1)",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export declare function TldrawHoveredShapeIndicator({ shapeId }: "
},
{
"kind": "Reference",
"text": "TLHoveredShapeIndicatorProps",
"canonicalReference": "@tldraw/editor!TLHoveredShapeIndicatorProps:type"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "import(\"react/jsx-runtime\")."
},
{
"kind": "Reference",
"text": "JSX.Element",
"canonicalReference": "@types/react!JSX.Element:interface"
},
{
"kind": "Content",
"text": " | null"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/tldraw/src/lib/canvas/TldrawHoveredShapeIndicator.tsx",
"returnTypeTokenRange": {
"startIndex": 3,
"endIndex": 6
},
"releaseTag": "Public",
"overloadIndex": 1,
"parameters": [
{
"parameterName": "{ shapeId }",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
}
],
"name": "TldrawHoveredShapeIndicator"
},
{ {
"kind": "Variable", "kind": "Variable",
"canonicalReference": "tldraw!TldrawImage:var", "canonicalReference": "tldraw!TldrawImage:var",

View file

@ -9,7 +9,6 @@ export * from '@tldraw/editor'
export { Tldraw, type TldrawProps } from './lib/Tldraw' export { Tldraw, type TldrawProps } from './lib/Tldraw'
export { TldrawImage, type TldrawImageProps } from './lib/TldrawImage' export { TldrawImage, type TldrawImageProps } from './lib/TldrawImage'
export { TldrawHandles } from './lib/canvas/TldrawHandles' export { TldrawHandles } from './lib/canvas/TldrawHandles'
export { TldrawHoveredShapeIndicator } from './lib/canvas/TldrawHoveredShapeIndicator'
export { TldrawScribble } from './lib/canvas/TldrawScribble' export { TldrawScribble } from './lib/canvas/TldrawScribble'
export { TldrawSelectionBackground } from './lib/canvas/TldrawSelectionBackground' export { TldrawSelectionBackground } from './lib/canvas/TldrawSelectionBackground'
export { TldrawSelectionForeground } from './lib/canvas/TldrawSelectionForeground' export { TldrawSelectionForeground } from './lib/canvas/TldrawSelectionForeground'

View file

@ -19,7 +19,6 @@ import {
} from '@tldraw/editor' } from '@tldraw/editor'
import { useLayoutEffect, useMemo } from 'react' import { useLayoutEffect, useMemo } from 'react'
import { TldrawHandles } from './canvas/TldrawHandles' import { TldrawHandles } from './canvas/TldrawHandles'
import { TldrawHoveredShapeIndicator } from './canvas/TldrawHoveredShapeIndicator'
import { TldrawScribble } from './canvas/TldrawScribble' import { TldrawScribble } from './canvas/TldrawScribble'
import { TldrawSelectionBackground } from './canvas/TldrawSelectionBackground' import { TldrawSelectionBackground } from './canvas/TldrawSelectionBackground'
import { TldrawSelectionForeground } from './canvas/TldrawSelectionForeground' import { TldrawSelectionForeground } from './canvas/TldrawSelectionForeground'
@ -90,7 +89,6 @@ export function Tldraw(props: TldrawProps) {
SelectionForeground: TldrawSelectionForeground, SelectionForeground: TldrawSelectionForeground,
SelectionBackground: TldrawSelectionBackground, SelectionBackground: TldrawSelectionBackground,
Handles: TldrawHandles, Handles: TldrawHandles,
HoveredShapeIndicator: TldrawHoveredShapeIndicator,
..._components, ..._components,
}), }),
[_components] [_components]

View file

@ -1,32 +0,0 @@
import {
TLHoveredShapeIndicatorProps,
useEditor,
useEditorComponents,
useValue,
} from '@tldraw/editor'
/** @public */
export function TldrawHoveredShapeIndicator({ shapeId }: TLHoveredShapeIndicatorProps) {
const editor = useEditor()
const { ShapeIndicator } = useEditorComponents()
const showHoveredShapeIndicator = useValue(
'show hovered',
() => {
// When the editor is editing a shape and hovering that shape,
// don't show its indicator; but DO show other hover indicators
if (editor.isIn('select.editing_shape')) {
return editor.getHoveredShapeId() !== editor.getEditingShapeId()
}
// Otherise, only show the hovered indicator when the editor
// is in the idle state
return editor.isInAny('select.idle')
},
[editor]
)
if (!ShapeIndicator) return null
if (!showHoveredShapeIndicator) return null
return <ShapeIndicator className="tl-user-indicator__hovered" shapeId={shapeId} />
}
//

View file

@ -160,7 +160,7 @@ export class Drawing extends StateNode {
return ( return (
firstPoint !== lastPoint && firstPoint !== lastPoint &&
this.currentLineLength > strokeWidth * 4 && this.currentLineLength > strokeWidth * 4 &&
Vec.Dist(firstPoint, lastPoint) < strokeWidth * 2 Vec.DistMin(firstPoint, lastPoint, strokeWidth * 2)
) )
} }
@ -224,7 +224,9 @@ export class Drawing extends StateNode {
this.pagePointWhereNextSegmentChanged = null this.pagePointWhereNextSegmentChanged = null
const segments = [...shape.props.segments, newSegment] const segments = [...shape.props.segments, newSegment]
if (this.currentLineLength < STROKE_SIZES[shape.props.size] * 4) {
this.currentLineLength = this.getLineLength(segments) this.currentLineLength = this.getLineLength(segments)
}
const shapePartial: TLShapePartial<DrawableShape> = { const shapePartial: TLShapePartial<DrawableShape> = {
id: shape.id, id: shape.id,
@ -411,7 +413,10 @@ export class Drawing extends StateNode {
} }
const finalSegments = [...newSegments, newFreeSegment] const finalSegments = [...newSegments, newFreeSegment]
if (this.currentLineLength < STROKE_SIZES[shape.props.size] * 4) {
this.currentLineLength = this.getLineLength(finalSegments) this.currentLineLength = this.getLineLength(finalSegments)
}
const shapePartial: TLShapePartial<DrawableShape> = { const shapePartial: TLShapePartial<DrawableShape> = {
id, id,
@ -486,11 +491,10 @@ export class Drawing extends StateNode {
lastPoint, lastPoint,
newPoint newPoint
) )
const distance = Vec.Dist(nearestPointOnSegment, newPoint)
if (distance < minDistance) { if (Vec.DistMin(nearestPointOnSegment, newPoint, minDistance)) {
nearestPoint = nearestPointOnSegment.toFixed().toJson() nearestPoint = nearestPointOnSegment.toFixed().toJson()
minDistance = distance minDistance = Vec.Dist(nearestPointOnSegment, newPoint)
snapSegment = segment snapSegment = segment
break break
} }
@ -598,7 +602,9 @@ export class Drawing extends StateNode {
points: newPoints, points: newPoints,
} }
if (this.currentLineLength < STROKE_SIZES[shape.props.size] * 4) {
this.currentLineLength = this.getLineLength(newSegments) this.currentLineLength = this.getLineLength(newSegments)
}
const shapePartial: TLShapePartial<DrawableShape> = { const shapePartial: TLShapePartial<DrawableShape> = {
id, id,
@ -659,7 +665,7 @@ export class Drawing extends StateNode {
for (let i = 0; i < segment.points.length - 1; i++) { for (let i = 0; i < segment.points.length - 1; i++) {
const A = segment.points[i] const A = segment.points[i]
const B = segment.points[i + 1] const B = segment.points[i + 1]
length += Vec.Sub(B, A).len2() length += Vec.Dist2(B, A)
} }
} }

View file

@ -311,8 +311,8 @@ export function inkyCloudSvgPath(
} }
const arcs = getCloudArcs(width, height, seed, size) const arcs = getCloudArcs(width, height, seed, size)
const avgArcLength = const avgArcLength =
arcs.reduce((sum, arc) => sum + Vec.Dist(arc.leftPoint, arc.rightPoint), 0) / arcs.length arcs.reduce((sum, arc) => sum + Vec.Dist2(arc.leftPoint, arc.rightPoint), 0) / arcs.length
const shouldMutatePoints = avgArcLength > mutMultiplier * 15 const shouldMutatePoints = avgArcLength > (mutMultiplier * 15) ** 2
const mutPoint = shouldMutatePoints ? (p: Vec) => new Vec(mut(p.x), mut(p.y)) : (p: Vec) => p const mutPoint = shouldMutatePoints ? (p: Vec) => new Vec(mut(p.x), mut(p.y)) : (p: Vec) => p
let pathA = `M${toDomPrecision(arcs[0].leftPoint.x)},${toDomPrecision(arcs[0].leftPoint.y)}` let pathA = `M${toDomPrecision(arcs[0].leftPoint.x)},${toDomPrecision(arcs[0].leftPoint.y)}`

View file

@ -53,8 +53,8 @@ export class Pointing extends StateNode {
const points = structuredClone(this.shape.props.points) const points = structuredClone(this.shape.props.points)
if ( if (
Vec.Dist(endHandle, prevEndHandle) < MINIMUM_DISTANCE_BETWEEN_SHIFT_CLICKED_HANDLES || Vec.DistMin(endHandle, prevEndHandle, MINIMUM_DISTANCE_BETWEEN_SHIFT_CLICKED_HANDLES) ||
Vec.Dist(nextPoint, endHandle) < MINIMUM_DISTANCE_BETWEEN_SHIFT_CLICKED_HANDLES Vec.DistMin(nextPoint, endHandle, MINIMUM_DISTANCE_BETWEEN_SHIFT_CLICKED_HANDLES)
) { ) {
// Don't add a new point if the distance between the last two points is too small // Don't add a new point if the distance between the last two points is too small
points[endHandle.id] = { points[endHandle.id] = {

View file

@ -68,7 +68,7 @@ export function getStrokePoints(
// Strip points that are too close to the first point. // Strip points that are too close to the first point.
let pt = pts[1] let pt = pts[1]
while (pt) { while (pt) {
if (Vec.Dist(pt, pts[0]) > size / 3) break if (Vec.Dist2(pt, pts[0]) > (size / 3) ** 2) break
pts[0].z = Math.max(pts[0].z, pt.z) // Use maximum pressure pts[0].z = Math.max(pts[0].z, pt.z) // Use maximum pressure
pts.splice(1, 1) pts.splice(1, 1)
pt = pts[1] pt = pts[1]
@ -78,7 +78,7 @@ export function getStrokePoints(
const last = pts.pop()! const last = pts.pop()!
pt = pts[pts.length - 1] pt = pts[pts.length - 1]
while (pt) { while (pt) {
if (Vec.Dist(pt, last) > size / 3) break if (Vec.Dist2(pt, last) > (size / 3) ** 2) break
pts.pop() pts.pop()
pt = pts[pts.length - 1] pt = pts[pts.length - 1]
pointsRemovedFromNearEnd++ pointsRemovedFromNearEnd++
@ -88,7 +88,7 @@ export function getStrokePoints(
const isComplete = const isComplete =
options.last || options.last ||
!options.simulatePressure || !options.simulatePressure ||
(pts.length > 1 && Vec.Dist(pts[pts.length - 1], pts[pts.length - 2]) < size) || (pts.length > 1 && Vec.Dist2(pts[pts.length - 1], pts[pts.length - 2]) < size ** 2) ||
pointsRemovedFromNearEnd > 0 pointsRemovedFromNearEnd > 0
// Add extra points between the two, to help avoid "dash" lines // Add extra points between the two, to help avoid "dash" lines

View file

@ -79,35 +79,49 @@ export class Erasing extends StateNode {
} }
update() { update() {
const erasingShapeIds = this.editor.getErasingShapeIds() const { editor, excludedShapeIds } = this
const zoomLevel = this.editor.getZoomLevel() const erasingShapeIds = editor.getErasingShapeIds()
const currentPageShapes = this.editor.getCurrentPageShapes() const zoomLevel = editor.getZoomLevel()
const currentPageShapes = editor.getCurrentPageShapes()
const { const {
inputs: { currentPagePoint, previousPagePoint }, inputs: { currentPagePoint, previousPagePoint },
} = this.editor } = editor
const { excludedShapeIds } = this
this.pushPointToScribble() this.pushPointToScribble()
const erasing = new Set<TLShapeId>(erasingShapeIds) const erasing = new Set<TLShapeId>(erasingShapeIds)
const minDist = HIT_TEST_MARGIN / zoomLevel
for (const shape of currentPageShapes) { for (const shape of currentPageShapes) {
if (this.editor.isShapeOfType<TLGroupShape>(shape, 'group')) continue if (editor.isShapeOfType<TLGroupShape>(shape, 'group')) continue
// Avoid testing masked shapes, unless the pointer is inside the mask // Avoid testing masked shapes, unless the pointer is inside the mask
const pageMask = this.editor.getShapeMask(shape.id) const pageMask = editor.getShapeMask(shape.id)
if (pageMask && !pointInPolygon(currentPagePoint, pageMask)) { if (pageMask && !pointInPolygon(currentPagePoint, pageMask)) {
continue continue
} }
// Hit test the shape using a line segment // Hit test the shape using a line segment
const geometry = this.editor.getShapeGeometry(shape) const geometry = editor.getShapeGeometry(shape)
const A = this.editor.getPointInShapeSpace(shape, previousPagePoint) const pageTransform = editor.getShapePageTransform(shape)
const B = this.editor.getPointInShapeSpace(shape, currentPagePoint) if (!geometry || !pageTransform) continue
const pt = pageTransform.clone().invert()
const A = pt.applyToPoint(previousPagePoint)
const B = pt.applyToPoint(currentPagePoint)
if (geometry.hitTestLineSegment(A, B, HIT_TEST_MARGIN / zoomLevel)) { // If the line segment is entirely above / below / left / right of the shape's bounding box, skip the hit test
erasing.add(this.editor.getOutermostSelectableShape(shape).id) const { bounds } = geometry
if (
bounds.minX - minDist > Math.max(A.x, B.x) ||
bounds.minY - minDist > Math.max(A.y, B.y) ||
bounds.maxX + minDist < Math.min(A.x, B.x) ||
bounds.maxY + minDist < Math.min(A.y, B.y)
) {
continue
}
if (geometry.hitTestLineSegment(A, B, minDist)) {
erasing.add(editor.getOutermostSelectableShape(shape).id)
} }
} }
@ -118,14 +132,16 @@ export class Erasing extends StateNode {
} }
complete() { complete() {
this.editor.deleteShapes(this.editor.getCurrentPageState().erasingShapeIds) const { editor } = this
this.editor.setErasingShapes([]) editor.deleteShapes(editor.getCurrentPageState().erasingShapeIds)
editor.setErasingShapes([])
this.parent.transition('idle') this.parent.transition('idle')
} }
cancel() { cancel() {
this.editor.setErasingShapes([]) const { editor } = this
this.editor.bailToMark(this.markId) editor.setErasingShapes([])
editor.bailToMark(this.markId)
this.parent.transition('idle', this.info) this.parent.transition('idle', this.info)
} }
} }

View file

@ -1,6 +1,5 @@
import { import {
Box, Box,
HIT_TEST_MARGIN,
Mat, Mat,
StateNode, StateNode,
TLCancelEvent, TLCancelEvent,
@ -24,7 +23,6 @@ export class Brushing extends StateNode {
info = {} as TLPointerEventInfo & { target: 'canvas' } info = {} as TLPointerEventInfo & { target: 'canvas' }
brush = new Box()
initialSelectedShapeIds: TLShapeId[] = [] initialSelectedShapeIds: TLShapeId[] = []
excludedShapeIds = new Set<TLShapeId>() excludedShapeIds = new Set<TLShapeId>()
isWrapMode = false isWrapMode = false
@ -103,18 +101,22 @@ export class Brushing extends StateNode {
} }
private hitTestShapes() { private hitTestShapes() {
const zoomLevel = this.editor.getZoomLevel() const { editor, excludedShapeIds, isWrapMode } = this
const currentPageShapes = this.editor.getCurrentPageShapes()
const currentPageId = this.editor.getCurrentPageId()
const { const {
inputs: { originPagePoint, currentPagePoint, shiftKey, ctrlKey }, inputs: { originPagePoint, currentPagePoint, shiftKey, ctrlKey },
} = this.editor } = editor
// We'll be collecting shape ids of selected shapes; if we're holding shift key, we start from our initial shapes
const results = new Set(shiftKey ? this.initialSelectedShapeIds : [])
// In wrap mode, we need to completely enclose a shape to select it
const isWrapping = isWrapMode ? !ctrlKey : ctrlKey
// Set the brush to contain the current and origin points // Set the brush to contain the current and origin points
this.brush.setTo(Box.FromPoints([originPagePoint, currentPagePoint])) const brush = Box.FromPoints([originPagePoint, currentPagePoint])
// We'll be collecting shape ids // We'll be testing the corners of the brush against the shapes
const results = new Set(shiftKey ? this.initialSelectedShapeIds : []) const { corners } = brush
let A: Vec, let A: Vec,
B: Vec, B: Vec,
@ -123,64 +125,59 @@ export class Brushing extends StateNode {
pageTransform: Mat | undefined, pageTransform: Mat | undefined,
localCorners: Vec[] localCorners: Vec[]
// We'll be testing the corners of the brush against the shapes const currentPageShapes = editor.getCurrentPageShapes()
const { corners } = this.brush const currentPageId = editor.getCurrentPageId()
const { excludedShapeIds, isWrapMode } = this
const isWrapping = isWrapMode ? !ctrlKey : ctrlKey
testAllShapes: for (let i = 0, n = currentPageShapes.length; i < n; i++) { testAllShapes: for (let i = 0, n = currentPageShapes.length; i < n; i++) {
shape = currentPageShapes[i] shape = currentPageShapes[i]
if (excludedShapeIds.has(shape.id)) continue testAllShapes if (excludedShapeIds.has(shape.id) || results.has(shape.id)) continue testAllShapes
if (results.has(shape.id)) continue testAllShapes
pageBounds = this.editor.getShapePageBounds(shape) pageBounds = editor.getShapePageBounds(shape)
if (!pageBounds) continue testAllShapes if (!pageBounds) continue testAllShapes
// If the brush fully wraps a shape, it's almost certainly a hit // If the brush fully wraps a shape, it's almost certainly a hit
if (this.brush.contains(pageBounds)) { if (brush.contains(pageBounds)) {
this.handleHit(shape, currentPagePoint, currentPageId, results, corners) this.handleHit(shape, currentPagePoint, currentPageId, results, corners)
continue testAllShapes continue testAllShapes
} }
// Should we even test for a single segment intersections? Only if // If we're in wrap mode and the brush did not fully encloses the shape, it's a miss
// we're not holding the ctrl key for alternate selection mode // We also skip frames unless we've completely selected the frame.
// (only wraps count!), or if the shape is a frame. if (isWrapping || editor.isShapeOfType<TLFrameShape>(shape, 'frame')) {
if (isWrapping || this.editor.isShapeOfType<TLFrameShape>(shape, 'frame')) {
continue testAllShapes continue testAllShapes
} }
// If the brush collides the page bounds, then do hit tests against // If the brush collides the page bounds, then do hit tests against
// each of the brush's four sides. // each of the brush's four sides.
if (this.brush.collides(pageBounds)) { if (brush.collides(pageBounds)) {
// Shapes expect to hit test line segments in their own coordinate system, // Shapes expect to hit test line segments in their own coordinate system,
// so we first need to get the brush corners in the shape's local space. // so we first need to get the brush corners in the shape's local space.
const geometry = this.editor.getShapeGeometry(shape) pageTransform = editor.getShapePageTransform(shape)
if (!pageTransform) continue testAllShapes
pageTransform = this.editor.getShapePageTransform(shape)
if (!pageTransform) {
continue testAllShapes
}
// Check whether any of the the brush edges intersect the shape
localCorners = pageTransform.clone().invert().applyToPoints(corners) localCorners = pageTransform.clone().invert().applyToPoints(corners)
// See if any of the edges intersect the shape's geometry
hitTestBrushEdges: for (let i = 0; i < localCorners.length; i++) { const geometry = editor.getShapeGeometry(shape)
hitTestBrushEdges: for (let i = 0; i < 4; i++) {
A = localCorners[i] A = localCorners[i]
B = localCorners[(i + 1) % localCorners.length] B = localCorners[(i + 1) % 4]
if (geometry.hitTestLineSegment(A, B, 0)) {
if (geometry.hitTestLineSegment(A, B, HIT_TEST_MARGIN / zoomLevel)) {
this.handleHit(shape, currentPagePoint, currentPageId, results, corners) this.handleHit(shape, currentPagePoint, currentPageId, results, corners)
break hitTestBrushEdges break hitTestBrushEdges
} }
} }
} }
} }
editor.getInstanceState().isCoarsePointer
this.editor.updateInstanceState({ brush: { ...this.brush.toJson() } }) const currentBrush = editor.getInstanceState().brush
this.editor.setSelectedShapes(Array.from(results), { squashing: true }) if (!currentBrush || !brush.equals(currentBrush)) {
editor.updateInstanceState({ brush: { ...brush.toJson() } })
}
const current = editor.getSelectedShapeIds()
if (current.length !== results.size || current.some((id) => !results.has(id))) {
editor.setSelectedShapes(Array.from(results), { squashing: true })
}
} }
override onInterrupt: TLInterruptEvent = () => { override onInterrupt: TLInterruptEvent = () => {
@ -203,7 +200,6 @@ export class Brushing extends StateNode {
// page mask; and if so, check to see if the brush intersects it // page mask; and if so, check to see if the brush intersects it
const selectedShape = this.editor.getOutermostSelectableShape(shape) const selectedShape = this.editor.getOutermostSelectableShape(shape)
const pageMask = this.editor.getShapeMask(selectedShape.id) const pageMask = this.editor.getShapeMask(selectedShape.id)
if ( if (
pageMask && pageMask &&
!polygonsIntersect(pageMask, corners) && !polygonsIntersect(pageMask, corners) &&
@ -211,7 +207,6 @@ export class Brushing extends StateNode {
) { ) {
return return
} }
results.add(selectedShape.id) results.add(selectedShape.id)
} }
} }

View file

@ -88,8 +88,8 @@ export class PointingArrowLabel extends StateNode {
let nextLabelPosition let nextLabelPosition
if (info.isStraight) { if (info.isStraight) {
// straight arrows // straight arrows
const lineLength = Vec.Dist(info.start.point, info.end.point) const lineLength = Vec.Dist2(info.start.point, info.end.point)
const segmentLength = Vec.Dist(info.end.point, nearestPoint) const segmentLength = Vec.Dist2(info.end.point, nearestPoint)
nextLabelPosition = 1 - segmentLength / lineLength nextLabelPosition = 1 - segmentLength / lineLength
} else { } else {
const { _center, measure, angleEnd, angleStart } = groupGeometry.children[0] as Arc2d const { _center, measure, angleEnd, angleStart } = groupGeometry.children[0] as Arc2d

View file

@ -1,6 +1,5 @@
import { import {
Geometry2d, Geometry2d,
HIT_TEST_MARGIN,
StateNode, StateNode,
TLEventHandlers, TLEventHandlers,
TLFrameShape, TLFrameShape,
@ -8,7 +7,7 @@ import {
TLShape, TLShape,
TLShapeId, TLShapeId,
Vec, Vec,
intersectLineSegmentPolyline, intersectLineSegmentPolygon,
pointInPolygon, pointInPolygon,
} from '@tldraw/editor' } from '@tldraw/editor'
@ -83,7 +82,8 @@ export class ScribbleBrushing extends StateNode {
} }
private updateScribbleSelection(addPoint: boolean) { private updateScribbleSelection(addPoint: boolean) {
const zoomLevel = this.editor.getZoomLevel() const { editor } = this
// const zoomLevel = this.editor.getZoomLevel()
const currentPageShapes = this.editor.getCurrentPageShapes() const currentPageShapes = this.editor.getCurrentPageShapes()
const { const {
inputs: { shiftKey, originPagePoint, previousPagePoint, currentPagePoint }, inputs: { shiftKey, originPagePoint, previousPagePoint, currentPagePoint },
@ -98,36 +98,53 @@ export class ScribbleBrushing extends StateNode {
const shapes = currentPageShapes const shapes = currentPageShapes
let shape: TLShape, geometry: Geometry2d, A: Vec, B: Vec let shape: TLShape, geometry: Geometry2d, A: Vec, B: Vec
const minDist = 0 // HIT_TEST_MARGIN / zoomLevel
for (let i = 0, n = shapes.length; i < n; i++) { for (let i = 0, n = shapes.length; i < n; i++) {
shape = shapes[i] shape = shapes[i]
geometry = this.editor.getShapeGeometry(shape)
// If the shape is a group or is already selected or locked, don't select it // If the shape is a group or is already selected or locked, don't select it
if ( if (
this.editor.isShapeOfType<TLGroupShape>(shape, 'group') || editor.isShapeOfType<TLGroupShape>(shape, 'group') ||
newlySelectedShapeIds.has(shape.id) || newlySelectedShapeIds.has(shape.id) ||
this.editor.isShapeOrAncestorLocked(shape) editor.isShapeOrAncestorLocked(shape)
) { ) {
continue continue
} }
geometry = editor.getShapeGeometry(shape)
// If the scribble started inside of the frame, don't select it // If the scribble started inside of the frame, don't select it
if (this.editor.isShapeOfType<TLFrameShape>(shape, 'frame')) { if (
const point = this.editor.getPointInShapeSpace(shape, originPagePoint) editor.isShapeOfType<TLFrameShape>(shape, 'frame') &&
if (geometry.bounds.containsPoint(point)) { geometry.bounds.containsPoint(editor.getPointInShapeSpace(shape, originPagePoint))
) {
continue continue
} }
// Hit test the shape using a line segment
const pageTransform = editor.getShapePageTransform(shape)
if (!geometry || !pageTransform) continue
const pt = pageTransform.clone().invert()
A = pt.applyToPoint(previousPagePoint)
B = pt.applyToPoint(currentPagePoint)
// If the line segment is entirely above / below / left / right of the shape's bounding box, skip the hit test
const { bounds } = geometry
if (
bounds.minX - minDist > Math.max(A.x, B.x) ||
bounds.minY - minDist > Math.max(A.y, B.y) ||
bounds.maxX + minDist < Math.min(A.x, B.x) ||
bounds.maxY + minDist < Math.min(A.y, B.y)
) {
continue
} }
A = this.editor.getPointInShapeSpace(shape, previousPagePoint) if (geometry.hitTestLineSegment(A, B, minDist)) {
B = this.editor.getPointInShapeSpace(shape, currentPagePoint)
if (geometry.hitTestLineSegment(A, B, HIT_TEST_MARGIN / zoomLevel)) {
const outermostShape = this.editor.getOutermostSelectableShape(shape) const outermostShape = this.editor.getOutermostSelectableShape(shape)
const pageMask = this.editor.getShapeMask(outermostShape.id) const pageMask = this.editor.getShapeMask(outermostShape.id)
if (pageMask) { if (pageMask) {
const intersection = intersectLineSegmentPolyline( const intersection = intersectLineSegmentPolygon(
previousPagePoint, previousPagePoint,
currentPagePoint, currentPagePoint,
pageMask pageMask
@ -142,16 +159,13 @@ export class ScribbleBrushing extends StateNode {
} }
} }
this.editor.setSelectedShapes( const current = editor.getSelectedShapeIds()
[ const next = new Set<TLShapeId>(
...new Set<TLShapeId>( shiftKey ? [...newlySelectedShapeIds, ...initialSelectedShapeIds] : [...newlySelectedShapeIds]
shiftKey
? [...newlySelectedShapeIds, ...initialSelectedShapeIds]
: [...newlySelectedShapeIds]
),
],
{ squashing: true }
) )
if (current.length !== next.size || current.some((id) => !next.has(id))) {
this.editor.setSelectedShapes(Array.from(next), { squashing: true })
}
} }
private complete() { private complete() {

View file

@ -213,15 +213,14 @@ describe('<TldrawEditor />', () => {
// Is the shape's component rendering? // Is the shape's component rendering?
expect(document.querySelectorAll('.tl-shape')).toHaveLength(1) expect(document.querySelectorAll('.tl-shape')).toHaveLength(1)
// though indicator should be display none
expect(document.querySelectorAll('.tl-shape-indicator')).toHaveLength(0) expect(document.querySelectorAll('.tl-shape-indicator')).toHaveLength(1)
// Select the shape // Select the shape
await act(async () => editor.select(id)) await act(async () => editor.select(id))
expect(editor.getSelectedShapeIds().length).toBe(1) expect(editor.getSelectedShapeIds().length).toBe(1)
// though indicator it should be visible
// Is the shape's component rendering?
expect(document.querySelectorAll('.tl-shape-indicator')).toHaveLength(1) expect(document.querySelectorAll('.tl-shape-indicator')).toHaveLength(1)
// Select the eraser tool... // Select the eraser tool...

View file

@ -1,6 +1,5 @@
const now = () => { const now = () => {
const hrTime = process.hrtime() return Number(process.hrtime.bigint()) / 1e6
return hrTime[0] * 1000 + hrTime[1] / 1000000
} }
export class PerformanceMeasurer { export class PerformanceMeasurer {
@ -17,6 +16,7 @@ export class PerformanceMeasurer {
cold = 0 cold = 0
fastest = Infinity fastest = Infinity
slowest = -Infinity slowest = -Infinity
didRun = false
totalStart = 0 totalStart = 0
totalEnd = 0 totalEnd = 0
@ -60,6 +60,12 @@ export class PerformanceMeasurer {
} }
run() { run() {
if (this.didRun) {
return this
}
this.didRun = true
const { fns, beforeFns, afterFns, warmupIterations, iterations } = this const { fns, beforeFns, afterFns, warmupIterations, iterations } = this
// Run the cold run // Run the cold run
@ -134,20 +140,21 @@ export class PerformanceMeasurer {
} }
static Table(...ps: PerformanceMeasurer[]) { static Table(...ps: PerformanceMeasurer[]) {
ps.forEach((p) => p.run())
const table: Record<string, Record<string, number | string>> = {} const table: Record<string, Record<string, number | string>> = {}
const fastest = ps.map((p) => p.average).reduce((a, b) => Math.min(a, b)) // const fastest = ps.map((p) => p.average).reduce((a, b) => Math.min(a, b))
const totalFastest = ps.map((p) => p.totalTime).reduce((a, b) => Math.min(a, b)) // const totalFastest = ps.map((p) => p.totalTime).reduce((a, b) => Math.min(a, b))
ps.forEach( ps.forEach(
(p) => (p) =>
(table[p.name] = { (table[p.name] = {
['Runs']: p.warmupIterations + p.iterations,
['Cold']: Number(p.cold.toFixed(2)), ['Cold']: Number(p.cold.toFixed(2)),
['Slowest']: Number(p.slowest.toFixed(2)), ['Slowest']: Number(p.slowest.toFixed(2)),
['Fastest']: Number(p.fastest.toFixed(2)), ['Fastest']: Number(p.fastest.toFixed(2)),
['Average']: Number(p.average.toFixed(2)), ['Average']: Number(p.average.toFixed(2)),
['Slower (Avg)']: Number((p.average / fastest).toFixed(2)), // ['Slower (Avg)']: Number((p.average / fastest).toFixed(2)),
['Slower (All)']: Number((p.totalTime / totalFastest).toFixed(2)), // ['Slower (All)']: Number((p.totalTime / totalFastest).toFixed(2)),
['Total']: Number(p.totalTime.toFixed(2)),
}) })
) )
// eslint-disable-next-line no-console // eslint-disable-next-line no-console

View file

@ -1,4 +1,4 @@
import { TLShapePartial, createShapeId } from '@tldraw/editor' import { TLShapePartial, Vec, createShapeId } from '@tldraw/editor'
import { TestEditor } from '../TestEditor' import { TestEditor } from '../TestEditor'
import { PerformanceMeasurer } from './PerformanceMeasurer' import { PerformanceMeasurer } from './PerformanceMeasurer'
@ -125,9 +125,6 @@ describe.skip('Example perf tests', () => {
editor.updateShapes(shapesToUpdate) editor.updateShapes(shapesToUpdate)
}) })
withUpdateShape.run()
withUpdateShapes.run()
PerformanceMeasurer.Table(withUpdateShape, withUpdateShapes) PerformanceMeasurer.Table(withUpdateShape, withUpdateShapes)
}, 10000) }, 10000)
@ -157,7 +154,6 @@ describe.skip('Example perf tests', () => {
const shape = editor.getCurrentPageShapes()[0] const shape = editor.getCurrentPageShapes()[0]
editor.updateShape({ ...shape, x: shape.x + 1 }) editor.updateShape({ ...shape, x: shape.x + 1 })
}) })
.run()
const renderingShapes2 = new PerformanceMeasurer('Measure rendering bounds with 200 shapes', { const renderingShapes2 = new PerformanceMeasurer('Measure rendering bounds with 200 shapes', {
warmupIterations: 10, warmupIterations: 10,
@ -184,8 +180,32 @@ describe.skip('Example perf tests', () => {
const shape = editor.getCurrentPageShapes()[0] const shape = editor.getCurrentPageShapes()[0]
editor.updateShape({ ...shape, x: shape.x + 1 }) editor.updateShape({ ...shape, x: shape.x + 1 })
}) })
.run()
PerformanceMeasurer.Table(renderingShapes, renderingShapes2) PerformanceMeasurer.Table(renderingShapes, renderingShapes2)
}, 10000) }, 10000)
}) })
it.skip('measures dist', () => {
const ITEMS = 100000
const MIN_DIST = 0.712311
const vecs = Array.from(Array(ITEMS)).map(
() => new Vec((Math.random() - 0.5) * 2, (Math.random() - 0.5) * 2)
)
const withDistA = new PerformanceMeasurer('old', {
warmupIterations: 10,
iterations: 100,
}).add(() => {
for (let i = 0; i < ITEMS - 1; i++) {
Vec.Dist(vecs[i], vecs[i + 1]) < MIN_DIST
}
})
const withDistB = new PerformanceMeasurer('new', {
warmupIterations: 10,
iterations: 100,
}).add(() => {
for (let i = 0; i < ITEMS - 1; i++) {
Vec.DistMin(vecs[i], vecs[i + 1], MIN_DIST)
}
})
PerformanceMeasurer.Table(withDistA, withDistB)
})