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,
TldrawEditor,
TldrawHandles,
TldrawHoveredShapeIndicator,
TldrawScribble,
TldrawSelectionBackground,
TldrawSelectionForeground,
@ -23,7 +22,6 @@ const defaultComponents = {
SelectionForeground: TldrawSelectionForeground,
SelectionBackground: TldrawSelectionBackground,
Handles: TldrawHandles,
HoveredShapeIndicator: TldrawHoveredShapeIndicator,
}
//[2]

View file

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

View file

@ -605,14 +605,6 @@
"text": "Vec",
"canonicalReference": "@tldraw/editor!Vec:class"
},
{
"kind": "Content",
"text": ", _zoom: "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": "): "
@ -628,8 +620,8 @@
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 7,
"endIndex": 8
"startIndex": 5,
"endIndex": 6
},
"releaseTag": "Public",
"isProtected": false,
@ -650,14 +642,6 @@
"endIndex": 4
},
"isOptional": false
},
{
"parameterName": "_zoom",
"parameterTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"isOptional": false
}
],
"isOptional": false,
@ -4438,7 +4422,7 @@
},
{
"kind": "Content",
"text": ", _zoom: "
"text": ", distance?: "
},
{
"kind": "Content",
@ -4483,12 +4467,12 @@
"isOptional": false
},
{
"parameterName": "_zoom",
"parameterName": "distance",
"parameterTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"isOptional": false
"isOptional": true
}
],
"isOptional": false,
@ -5746,14 +5730,6 @@
"text": "Vec",
"canonicalReference": "@tldraw/editor!Vec:class"
},
{
"kind": "Content",
"text": ", zoom: "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": "): "
@ -5769,8 +5745,8 @@
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 7,
"endIndex": 8
"startIndex": 5,
"endIndex": 6
},
"releaseTag": "Public",
"isProtected": false,
@ -5791,14 +5767,6 @@
"endIndex": 4
},
"isOptional": false
},
{
"parameterName": "zoom",
"parameterTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"isOptional": false
}
],
"isOptional": false,
@ -6449,61 +6417,6 @@
],
"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",
"canonicalReference": "@tldraw/editor!DefaultScribble:function(1)",
@ -7134,7 +7047,7 @@
},
{
"kind": "Content",
"text": ", _zoom: "
"text": ", distance?: "
},
{
"kind": "Content",
@ -7179,12 +7092,12 @@
"isOptional": false
},
{
"parameterName": "_zoom",
"parameterName": "distance",
"parameterTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"isOptional": false
"isOptional": true
}
],
"isOptional": false,
@ -20311,14 +20224,6 @@
"text": "Vec",
"canonicalReference": "@tldraw/editor!Vec:class"
},
{
"kind": "Content",
"text": ", zoom: "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": "): "
@ -20334,8 +20239,8 @@
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 7,
"endIndex": 8
"startIndex": 5,
"endIndex": 6
},
"releaseTag": "Public",
"isProtected": false,
@ -20356,14 +20261,6 @@
"endIndex": 4
},
"isOptional": false
},
{
"parameterName": "zoom",
"parameterTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"isOptional": false
}
],
"isOptional": false,
@ -28466,7 +28363,7 @@
},
{
"kind": "Content",
"text": ", zoom: "
"text": ", distance?: "
},
{
"kind": "Content",
@ -28511,12 +28408,12 @@
"isOptional": false
},
{
"parameterName": "zoom",
"parameterName": "distance",
"parameterTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"isOptional": false
"isOptional": true
}
],
"isOptional": false,
@ -39669,41 +39566,6 @@
"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",
"canonicalReference": "@tldraw/editor!TLInterruptEvent:type",
@ -41940,7 +41802,7 @@
},
{
"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",
@ -43835,24 +43697,6 @@
"text": "TLSelectionBackgroundProps",
"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",
"text": "> | null;\n OnTheCanvas: "
@ -43906,7 +43750,7 @@
"fileUrlPath": "packages/editor/src/lib/hooks/useEditorComponents.tsx",
"returnTypeTokenRange": {
"startIndex": 1,
"endIndex": 90
"endIndex": 86
},
"releaseTag": "Public",
"overloadIndex": 1,
@ -46051,6 +45895,88 @@
"isAbstract": false,
"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",
"canonicalReference": "@tldraw/editor!Vec#div:member(1)",

View file

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

View file

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

View file

@ -151,8 +151,7 @@ export function DefaultCanvas({ className }: TLCanvasComponentProps) {
<BrushWrapper />
<ScribbleWrapper />
<ZoomBrushWrapper />
<SelectedIdIndicators />
<HoveredShapeIndicator />
<ShapeIndicators />
<HintedShapeIndicator />
<SnapIndicatorWrapper />
<SelectionForegroundWrapper />
@ -431,16 +430,17 @@ function ShapesToDisplay() {
)
}
function SelectedIdIndicators() {
function ShapeIndicators() {
const editor = useEditor()
const selectedShapeIds = useValue('selectedShapeIds', () => editor.getSelectedShapeIds(), [
editor,
])
const shouldDisplay = useValue(
const renderingShapes = useValue('rendering shapes', () => editor.getRenderingShapes(), [editor])
const rPreviousSelectedShapeIds = useRef<Set<TLShapeId>>(new Set())
const idsToDisplay = useValue(
'should display selected ids',
() => {
// todo: move to tldraw selected ids wrapper
return (
// todo: move to tldraw selected ids wrappe
const prev = rPreviousSelectedShapeIds.current
const next = new Set<TLShapeId>()
if (
editor.isInAny(
'select.idle',
'select.brushing',
@ -449,52 +449,51 @@ function SelectedIdIndicators() {
'select.pointing_shape',
'select.pointing_selection',
'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]
)
const { ShapeIndicator } = useEditorComponents()
if (!ShapeIndicator) return null
if (!shouldDisplay) return null
return (
<>
{selectedShapeIds.map((id) => (
<ShapeIndicator
key={id + '_indicator'}
className="tl-user-indicator__selected"
shapeId={id}
/>
{renderingShapes.map(({ id }) => (
<ShapeIndicator key={id + '_indicator'} shapeId={id} hidden={!idsToDisplay.has(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() {
const editor = useEditor()
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 classNames from 'classnames'
import { memo } from 'react'
import { memo, useLayoutEffect, useRef } from 'react'
import type { Editor } from '../../editor/Editor'
import { ShapeUtil } from '../../editor/shapes/ShapeUtil'
import { useEditor } from '../../hooks/useEditor'
@ -38,6 +38,7 @@ export type TLShapeIndicatorProps = {
color?: string | undefined
opacity?: number
className?: string
hidden?: boolean
}
/** @public */
@ -45,28 +46,34 @@ export const DefaultShapeIndicator = memo(function DefaultShapeIndicator({
shapeId,
className,
color,
hidden,
opacity,
}: TLShapeIndicatorProps) {
const editor = useEditor()
const transform = useValue(
const rIndicator = useRef<SVGSVGElement>(null)
useQuickReactor(
'indicator transform',
() => {
const elm = rIndicator.current
if (!elm) return
const pageTransform = editor.getShapePageTransform(shapeId)
if (!pageTransform) return ''
return pageTransform.toCssString()
if (!pageTransform) return
elm.style.setProperty('transform', pageTransform.toCssString())
},
[editor, shapeId]
)
useLayoutEffect(() => {
const elm = rIndicator.current
if (!elm) return
elm.style.setProperty('display', hidden ? 'none' : 'block')
}, [hidden])
return (
<svg className={classNames('tl-overlays__item', className)}>
<g
className="tl-shape-indicator"
transform={transform}
stroke={color ?? 'var(--color-selected)'}
opacity={opacity}
>
<svg ref={rIndicator} className={classNames('tl-overlays__item', className)}>
<g className="tl-shape-indicator" stroke={color ?? 'var(--color-selected)'} opacity={opacity}>
<InnerIndicator editor={editor} id={shapeId} />
</g>
</svg>

View file

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

View file

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

View file

@ -263,8 +263,7 @@ export function getCurvedArrowInfo(
tB.setTo(handleArc.center).add(u.mul(handleArc.radius))
}
const distAB = Vec.Dist(tA, tB)
if (distAB < minLength) {
if (Vec.DistMin(tA, tB, minLength)) {
if (offsetA !== 0 && offsetB !== 0) {
offsetA *= -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 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) {
// both bound + offset
offsetA *= -1.5
@ -241,7 +240,7 @@ function updateArrowheadPointWithBoundShape(
if (intersection !== null) {
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)
}

View file

@ -146,10 +146,14 @@ export abstract class StateNode implements Partial<TLEventHandlers> {
handleEvent = (info: Exclude<TLEventInfo, TLPinchEventInfo>) => {
const cbName = EVENT_NAME_MAP[info.name]
const x = this.getCurrent()
const currentActiveChild = this._current.__unsafe__getWithoutCapture()
this[cbName]?.(info as any)
if (this.getCurrent() === x && this.getIsActive()) {
x?.handleEvent(info)
if (
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 { DefaultHandle, TLHandleProps } from '../components/default-components/DefaultHandle'
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 {
DefaultSelectionBackground,
@ -71,7 +67,6 @@ export interface BaseEditorComponents {
Spinner: ComponentType
SelectionForeground: ComponentType<TLSelectionForegroundProps>
SelectionBackground: ComponentType<TLSelectionBackgroundProps>
HoveredShapeIndicator: ComponentType<TLHoveredShapeIndicatorProps>
OnTheCanvas: ComponentType
InFrontOfTheCanvas: ComponentType
LoadingScreen: ComponentType
@ -129,7 +124,6 @@ export function EditorComponentsProvider({
Spinner: DefaultSpinner,
SelectionBackground: DefaultSelectionBackground,
SelectionForeground: DefaultSelectionForeground,
HoveredShapeIndicator: DefaultHoveredShapeIndicator,
ShapeIndicator: DefaultShapeIndicator,
OnTheCanvas: null,
InFrontOfTheCanvas: null,

View file

@ -317,6 +317,11 @@ export class Vec {
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.
static Dist2(A: VecLike, B: VecLike): number {
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 {
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 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 (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)

View file

@ -66,7 +66,7 @@ export class Arc2d extends Geometry2d {
return nearest
}
hitTestLineSegment(A: Vec, B: Vec, _zoom: number): boolean {
hitTestLineSegment(A: Vec, B: Vec): boolean {
const { _center, radius, measure, angleStart, angleEnd } = this
const intersection = intersectLineSegmentCircle(A, B, _center, radius)
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))
}
hitTestLineSegment(A: Vec, B: Vec, _zoom: number): boolean {
hitTestLineSegment(A: Vec, B: Vec, distance = 0): boolean {
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
}
hitTestLineSegment(A: Vec, B: Vec, zoom: number): boolean {
return this.segments.some((segment) => segment.hitTestLineSegment(A, B, zoom))
hitTestLineSegment(A: Vec, B: Vec): boolean {
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)
}
override hitTestLineSegment(A: Vec, B: Vec, _zoom: number): boolean {
return linesIntersect(A, B, this.start, this.end)
override hitTestLineSegment(A: Vec, B: Vec, distance = 0): boolean {
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
}
hitTestLineSegment(A: Vec, B: Vec, zoom: number): boolean {
return this.edges.some((edge) => edge.hitTestLineSegment(A, B, zoom))
hitTestLineSegment(A: Vec, B: Vec): boolean {
return this.edges.some((edge) => edge.hitTestLineSegment(A, B))
}
getBounds() {

View file

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

View file

@ -65,7 +65,13 @@ export class Polyline2d extends Geometry2d {
return nearest
}
hitTestLineSegment(A: Vec, B: Vec, zoom: number): boolean {
return this.segments.some((edge) => edge.hitTestLineSegment(A, B, zoom))
hitTestLineSegment(A: Vec, B: Vec, distance = 0): boolean {
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 { Geometry2dOptions } from './Geometry2d'
const STADIUM_VERTICES_LENGTH = 18
/** @public */
export class Stadium2d extends Ellipse2d {
constructor(
@ -12,28 +14,31 @@ export class Stadium2d extends Ellipse2d {
}
getVertices() {
// Perimeter of the ellipse
const w = Math.max(1, this.w)
const h = Math.max(1, this.h)
const cx = w / 2
const cy = h / 2
const len = 10
const points: Vec[] = Array(len * 2 - 2)
const points: Vec[] = Array(STADIUM_VERTICES_LENGTH)
let t1: number, t2: number
if (h > w) {
for (let i = 0; i < len - 1; i++) {
const t1 = -PI + (PI * i) / (len - 2)
const t2 = (PI * i) / (len - 2)
for (let i = 0; i < STADIUM_VERTICES_LENGTH - 1; i++) {
t1 = -PI + (PI * i) / (STADIUM_VERTICES_LENGTH - 2)
t2 = (PI * i) / (STADIUM_VERTICES_LENGTH - 2)
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 {
for (let i = 0; i < len - 1; i++) {
const t1 = -HALF_PI + (PI * i) / (len - 2)
const t2 = HALF_PI + (PI * -i) / (len - 2)
for (let i = 0; i < STADIUM_VERTICES_LENGTH - 1; i++) {
t1 = -HALF_PI + (PI * i) / (STADIUM_VERTICES_LENGTH - 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 + (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 { TLHandlesProps } from '@tldraw/editor';
import { TLHighlightShape } from '@tldraw/editor';
import { TLHoveredShapeIndicatorProps } from '@tldraw/editor';
import { TLImageShape } from '@tldraw/editor';
import { TLInterruptEvent } from '@tldraw/editor';
import { TLKeyboardEvent } from '@tldraw/editor';
@ -1452,9 +1451,6 @@ export interface TldrawFile {
// @public (undocumented)
export function TldrawHandles({ children }: TLHandlesProps): JSX_2.Element | null;
// @public (undocumented)
export function TldrawHoveredShapeIndicator({ shapeId }: TLHoveredShapeIndicatorProps): JSX_2.Element | null;
// @public
export const TldrawImage: NamedExoticComponent< {
snapshot: StoreSnapshot<TLRecord>;

View file

@ -16579,61 +16579,6 @@
],
"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",
"canonicalReference": "tldraw!TldrawImage:var",

View file

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

View file

@ -19,7 +19,6 @@ import {
} from '@tldraw/editor'
import { useLayoutEffect, useMemo } from 'react'
import { TldrawHandles } from './canvas/TldrawHandles'
import { TldrawHoveredShapeIndicator } from './canvas/TldrawHoveredShapeIndicator'
import { TldrawScribble } from './canvas/TldrawScribble'
import { TldrawSelectionBackground } from './canvas/TldrawSelectionBackground'
import { TldrawSelectionForeground } from './canvas/TldrawSelectionForeground'
@ -90,7 +89,6 @@ export function Tldraw(props: TldrawProps) {
SelectionForeground: TldrawSelectionForeground,
SelectionBackground: TldrawSelectionBackground,
Handles: TldrawHandles,
HoveredShapeIndicator: TldrawHoveredShapeIndicator,
..._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 (
firstPoint !== lastPoint &&
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
const segments = [...shape.props.segments, newSegment]
if (this.currentLineLength < STROKE_SIZES[shape.props.size] * 4) {
this.currentLineLength = this.getLineLength(segments)
}
const shapePartial: TLShapePartial<DrawableShape> = {
id: shape.id,
@ -411,7 +413,10 @@ export class Drawing extends StateNode {
}
const finalSegments = [...newSegments, newFreeSegment]
if (this.currentLineLength < STROKE_SIZES[shape.props.size] * 4) {
this.currentLineLength = this.getLineLength(finalSegments)
}
const shapePartial: TLShapePartial<DrawableShape> = {
id,
@ -486,11 +491,10 @@ export class Drawing extends StateNode {
lastPoint,
newPoint
)
const distance = Vec.Dist(nearestPointOnSegment, newPoint)
if (distance < minDistance) {
if (Vec.DistMin(nearestPointOnSegment, newPoint, minDistance)) {
nearestPoint = nearestPointOnSegment.toFixed().toJson()
minDistance = distance
minDistance = Vec.Dist(nearestPointOnSegment, newPoint)
snapSegment = segment
break
}
@ -598,7 +602,9 @@ export class Drawing extends StateNode {
points: newPoints,
}
if (this.currentLineLength < STROKE_SIZES[shape.props.size] * 4) {
this.currentLineLength = this.getLineLength(newSegments)
}
const shapePartial: TLShapePartial<DrawableShape> = {
id,
@ -659,7 +665,7 @@ export class Drawing extends StateNode {
for (let i = 0; i < segment.points.length - 1; i++) {
const A = segment.points[i]
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 avgArcLength =
arcs.reduce((sum, arc) => sum + Vec.Dist(arc.leftPoint, arc.rightPoint), 0) / arcs.length
const shouldMutatePoints = avgArcLength > mutMultiplier * 15
arcs.reduce((sum, arc) => sum + Vec.Dist2(arc.leftPoint, arc.rightPoint), 0) / arcs.length
const shouldMutatePoints = avgArcLength > (mutMultiplier * 15) ** 2
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)}`

View file

@ -53,8 +53,8 @@ export class Pointing extends StateNode {
const points = structuredClone(this.shape.props.points)
if (
Vec.Dist(endHandle, prevEndHandle) < MINIMUM_DISTANCE_BETWEEN_SHIFT_CLICKED_HANDLES ||
Vec.Dist(nextPoint, endHandle) < MINIMUM_DISTANCE_BETWEEN_SHIFT_CLICKED_HANDLES
Vec.DistMin(endHandle, prevEndHandle, 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
points[endHandle.id] = {

View file

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

View file

@ -79,35 +79,49 @@ export class Erasing extends StateNode {
}
update() {
const erasingShapeIds = this.editor.getErasingShapeIds()
const zoomLevel = this.editor.getZoomLevel()
const currentPageShapes = this.editor.getCurrentPageShapes()
const { editor, excludedShapeIds } = this
const erasingShapeIds = editor.getErasingShapeIds()
const zoomLevel = editor.getZoomLevel()
const currentPageShapes = editor.getCurrentPageShapes()
const {
inputs: { currentPagePoint, previousPagePoint },
} = this.editor
const { excludedShapeIds } = this
} = editor
this.pushPointToScribble()
const erasing = new Set<TLShapeId>(erasingShapeIds)
const minDist = HIT_TEST_MARGIN / zoomLevel
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
const pageMask = this.editor.getShapeMask(shape.id)
const pageMask = editor.getShapeMask(shape.id)
if (pageMask && !pointInPolygon(currentPagePoint, pageMask)) {
continue
}
// Hit test the shape using a line segment
const geometry = this.editor.getShapeGeometry(shape)
const A = this.editor.getPointInShapeSpace(shape, previousPagePoint)
const B = this.editor.getPointInShapeSpace(shape, currentPagePoint)
const geometry = editor.getShapeGeometry(shape)
const pageTransform = editor.getShapePageTransform(shape)
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)) {
erasing.add(this.editor.getOutermostSelectableShape(shape).id)
// 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
}
if (geometry.hitTestLineSegment(A, B, minDist)) {
erasing.add(editor.getOutermostSelectableShape(shape).id)
}
}
@ -118,14 +132,16 @@ export class Erasing extends StateNode {
}
complete() {
this.editor.deleteShapes(this.editor.getCurrentPageState().erasingShapeIds)
this.editor.setErasingShapes([])
const { editor } = this
editor.deleteShapes(editor.getCurrentPageState().erasingShapeIds)
editor.setErasingShapes([])
this.parent.transition('idle')
}
cancel() {
this.editor.setErasingShapes([])
this.editor.bailToMark(this.markId)
const { editor } = this
editor.setErasingShapes([])
editor.bailToMark(this.markId)
this.parent.transition('idle', this.info)
}
}

View file

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

View file

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

View file

@ -1,6 +1,5 @@
import {
Geometry2d,
HIT_TEST_MARGIN,
StateNode,
TLEventHandlers,
TLFrameShape,
@ -8,7 +7,7 @@ import {
TLShape,
TLShapeId,
Vec,
intersectLineSegmentPolyline,
intersectLineSegmentPolygon,
pointInPolygon,
} from '@tldraw/editor'
@ -83,7 +82,8 @@ export class ScribbleBrushing extends StateNode {
}
private updateScribbleSelection(addPoint: boolean) {
const zoomLevel = this.editor.getZoomLevel()
const { editor } = this
// const zoomLevel = this.editor.getZoomLevel()
const currentPageShapes = this.editor.getCurrentPageShapes()
const {
inputs: { shiftKey, originPagePoint, previousPagePoint, currentPagePoint },
@ -98,36 +98,53 @@ export class ScribbleBrushing extends StateNode {
const shapes = currentPageShapes
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++) {
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 (
this.editor.isShapeOfType<TLGroupShape>(shape, 'group') ||
editor.isShapeOfType<TLGroupShape>(shape, 'group') ||
newlySelectedShapeIds.has(shape.id) ||
this.editor.isShapeOrAncestorLocked(shape)
editor.isShapeOrAncestorLocked(shape)
) {
continue
}
geometry = editor.getShapeGeometry(shape)
// If the scribble started inside of the frame, don't select it
if (this.editor.isShapeOfType<TLFrameShape>(shape, 'frame')) {
const point = this.editor.getPointInShapeSpace(shape, originPagePoint)
if (geometry.bounds.containsPoint(point)) {
if (
editor.isShapeOfType<TLFrameShape>(shape, 'frame') &&
geometry.bounds.containsPoint(editor.getPointInShapeSpace(shape, originPagePoint))
) {
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)
B = this.editor.getPointInShapeSpace(shape, currentPagePoint)
if (geometry.hitTestLineSegment(A, B, HIT_TEST_MARGIN / zoomLevel)) {
if (geometry.hitTestLineSegment(A, B, minDist)) {
const outermostShape = this.editor.getOutermostSelectableShape(shape)
const pageMask = this.editor.getShapeMask(outermostShape.id)
if (pageMask) {
const intersection = intersectLineSegmentPolyline(
const intersection = intersectLineSegmentPolygon(
previousPagePoint,
currentPagePoint,
pageMask
@ -142,16 +159,13 @@ export class ScribbleBrushing extends StateNode {
}
}
this.editor.setSelectedShapes(
[
...new Set<TLShapeId>(
shiftKey
? [...newlySelectedShapeIds, ...initialSelectedShapeIds]
: [...newlySelectedShapeIds]
),
],
{ squashing: true }
const current = editor.getSelectedShapeIds()
const next = new Set<TLShapeId>(
shiftKey ? [...newlySelectedShapeIds, ...initialSelectedShapeIds] : [...newlySelectedShapeIds]
)
if (current.length !== next.size || current.some((id) => !next.has(id))) {
this.editor.setSelectedShapes(Array.from(next), { squashing: true })
}
}
private complete() {

View file

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

View file

@ -1,6 +1,5 @@
const now = () => {
const hrTime = process.hrtime()
return hrTime[0] * 1000 + hrTime[1] / 1000000
return Number(process.hrtime.bigint()) / 1e6
}
export class PerformanceMeasurer {
@ -17,6 +16,7 @@ export class PerformanceMeasurer {
cold = 0
fastest = Infinity
slowest = -Infinity
didRun = false
totalStart = 0
totalEnd = 0
@ -60,6 +60,12 @@ export class PerformanceMeasurer {
}
run() {
if (this.didRun) {
return this
}
this.didRun = true
const { fns, beforeFns, afterFns, warmupIterations, iterations } = this
// Run the cold run
@ -134,20 +140,21 @@ export class PerformanceMeasurer {
}
static Table(...ps: PerformanceMeasurer[]) {
ps.forEach((p) => p.run())
const table: Record<string, Record<string, number | string>> = {}
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 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))
ps.forEach(
(p) =>
(table[p.name] = {
['Runs']: p.warmupIterations + p.iterations,
['Cold']: Number(p.cold.toFixed(2)),
['Slowest']: Number(p.slowest.toFixed(2)),
['Fastest']: Number(p.fastest.toFixed(2)),
['Average']: Number(p.average.toFixed(2)),
['Slower (Avg)']: Number((p.average / fastest).toFixed(2)),
['Slower (All)']: Number((p.totalTime / totalFastest).toFixed(2)),
// ['Slower (Avg)']: Number((p.average / fastest).toFixed(2)),
// ['Slower (All)']: Number((p.totalTime / totalFastest).toFixed(2)),
['Total']: Number(p.totalTime.toFixed(2)),
})
)
// 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 { PerformanceMeasurer } from './PerformanceMeasurer'
@ -125,9 +125,6 @@ describe.skip('Example perf tests', () => {
editor.updateShapes(shapesToUpdate)
})
withUpdateShape.run()
withUpdateShapes.run()
PerformanceMeasurer.Table(withUpdateShape, withUpdateShapes)
}, 10000)
@ -157,7 +154,6 @@ describe.skip('Example perf tests', () => {
const shape = editor.getCurrentPageShapes()[0]
editor.updateShape({ ...shape, x: shape.x + 1 })
})
.run()
const renderingShapes2 = new PerformanceMeasurer('Measure rendering bounds with 200 shapes', {
warmupIterations: 10,
@ -184,8 +180,32 @@ describe.skip('Example perf tests', () => {
const shape = editor.getCurrentPageShapes()[0]
editor.updateShape({ ...shape, x: shape.x + 1 })
})
.run()
PerformanceMeasurer.Table(renderingShapes, renderingShapes2)
}, 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)
})