diff --git a/apps/examples/src/examples/custom-renderer/CustomRenderer.tsx b/apps/examples/src/examples/custom-renderer/CustomRenderer.tsx index 7e20457ea..66cbe70a1 100644 --- a/apps/examples/src/examples/custom-renderer/CustomRenderer.tsx +++ b/apps/examples/src/examples/custom-renderer/CustomRenderer.tsx @@ -36,7 +36,8 @@ export function CustomRenderer() { const theme = getDefaultColorTheme({ isDarkMode: editor.user.getIsDarkMode() }) const currentPageId = editor.getCurrentPageId() - for (const { shape, maskedPageBounds, opacity } of renderingShapes) { + for (const { shape, opacity } of renderingShapes) { + const maskedPageBounds = editor.getShapeMaskedPageBounds(shape) if (!maskedPageBounds) continue ctx.save() diff --git a/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts b/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts index ae181b28d..39b6145f3 100644 --- a/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts +++ b/apps/examples/src/examples/rendering-shape-changes/useRenderingShapesChange.ts @@ -22,9 +22,11 @@ export function useChangedShapesReactor( if (!beforeInfo) { continue } else { - if (afterInfo.isCulled && !beforeInfo.isCulled) { + const isAfterCulled = editor.isShapeCulled(afterInfo.id) + const isBeforeCulled = editor.isShapeCulled(beforeInfo.id) + if (isAfterCulled && !isBeforeCulled) { culled.push(afterInfo.shape) - } else if (!afterInfo.isCulled && beforeInfo.isCulled) { + } else if (!isAfterCulled && isBeforeCulled) { restored.push(afterInfo.shape) } beforeToVisit.delete(beforeInfo) diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index b077e48f5..7d9820a89 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -514,7 +514,7 @@ export function degreesToRadians(d: number): number; export const DOUBLE_CLICK_DURATION = 450; // @internal (undocumented) -export const DRAG_DISTANCE = 4; +export const DRAG_DISTANCE = 16; // @public (undocumented) export const EASINGS: { @@ -721,8 +721,6 @@ export class Editor extends EventEmitter { index: number; backgroundIndex: number; opacity: number; - isCulled: boolean; - maskedPageBounds: Box | undefined; }[]; getSelectedShapeAtPoint(point: VecLike): TLShape | undefined; getSelectedShapeIds(): TLShapeId[]; @@ -783,8 +781,6 @@ export class Editor extends EventEmitter { index: number; backgroundIndex: number; opacity: number; - isCulled: boolean; - maskedPageBounds: Box | undefined; }[]; getViewportPageBounds(): Box; getViewportPageCenter(): Vec; @@ -821,6 +817,7 @@ export class Editor extends EventEmitter { margin?: number | undefined; hitInside?: boolean | undefined; }): boolean; + isShapeCulled(shape: TLShape | TLShapeId): boolean; isShapeInPage(shape: TLShape | TLShapeId, pageId?: TLPageId): boolean; isShapeOfType(shape: TLUnknownShape, type: T['type']): shape is T; // (undocumented) diff --git a/packages/editor/api/api.json b/packages/editor/api/api.json index db2d802ec..e1c673c10 100644 --- a/packages/editor/api/api.json +++ b/packages/editor/api/api.json @@ -11991,16 +11991,7 @@ }, { "kind": "Content", - "text": ">;\n index: number;\n backgroundIndex: number;\n opacity: number;\n isCulled: boolean;\n maskedPageBounds: " - }, - { - "kind": "Reference", - "text": "Box", - "canonicalReference": "@tldraw/editor!Box:class" - }, - { - "kind": "Content", - "text": " | undefined;\n }[]" + "text": ">;\n index: number;\n backgroundIndex: number;\n opacity: number;\n }[]" }, { "kind": "Content", @@ -12010,7 +12001,7 @@ "isStatic": false, "returnTypeTokenRange": { "startIndex": 1, - "endIndex": 12 + "endIndex": 10 }, "releaseTag": "Public", "isProtected": false, @@ -14801,6 +14792,64 @@ "isAbstract": false, "name": "isPointInShape" }, + { + "kind": "Method", + "canonicalReference": "@tldraw/editor!Editor#isShapeCulled:member(1)", + "docComment": "/**\n * Get whether the shape is culled or not.\n *\n * @param shape - The shape (or shape id) to get the culled info for.\n *\n * @example\n * ```ts\n * editor.isShapeCulled(myShape)\n * editor.isShapeCulled(myShapeId)\n * ```\n *\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "isShapeCulled(shape: " + }, + { + "kind": "Reference", + "text": "TLShape", + "canonicalReference": "@tldraw/tlschema!TLShape:type" + }, + { + "kind": "Content", + "text": " | " + }, + { + "kind": "Reference", + "text": "TLShapeId", + "canonicalReference": "@tldraw/tlschema!TLShapeId:type" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Content", + "text": "boolean" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 5, + "endIndex": 6 + }, + "releaseTag": "Public", + "isProtected": false, + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "shape", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 4 + }, + "isOptional": false + } + ], + "isOptional": false, + "isAbstract": false, + "name": "isShapeCulled" + }, { "kind": "Method", "canonicalReference": "@tldraw/editor!Editor#isShapeInPage:member(1)", diff --git a/packages/editor/editor.css b/packages/editor/editor.css index fabe80e1e..cdc8b1ec0 100644 --- a/packages/editor/editor.css +++ b/packages/editor/editor.css @@ -1102,9 +1102,12 @@ input, } /* This part of the rule helps preserve the occlusion rules for the shapes so we * don't click on shapes that are behind other shapes. - * One extra nuance is arrows which have weird geometry and just gets in the way. + * One extra nuance is we don't use this behavior for: + * - arrows which have weird geometry and just gets in the way. + * - draw shapes, because it feels restrictive to have them be 'in the way' of clicking on a textfield */ -.tl-canvas[data-iseditinganything='true'] .tl-shape:not([data-shape-type='arrow']) { +.tl-canvas[data-iseditinganything='true'] + .tl-shape:not([data-shape-type='arrow']):not([data-shape-type='draw']) { pointer-events: all; } /* But, re-disable the pointer-events rule for the svg container. */ diff --git a/packages/editor/src/lib/components/CulledShapes.tsx b/packages/editor/src/lib/components/CulledShapes.tsx index 5a6020621..9b2851534 100644 --- a/packages/editor/src/lib/components/CulledShapes.tsx +++ b/packages/editor/src/lib/components/CulledShapes.tsx @@ -112,8 +112,9 @@ export function CulledShapes() { const shapeVertices = computed('shape vertices', function calculateCulledShapeVertices() { const results: number[] = [] - for (const { isCulled, maskedPageBounds } of editor.getRenderingShapes()) { - if (isCulled && maskedPageBounds) { + for (const { id } of editor.getUnorderedRenderingShapes(true)) { + const maskedPageBounds = editor.getShapeMaskedPageBounds(id) + if (editor.isShapeCulled(id) && maskedPageBounds) { results.push( // triangle 1 maskedPageBounds.minX, diff --git a/packages/editor/src/lib/components/Shape.tsx b/packages/editor/src/lib/components/Shape.tsx index 19a33b64a..8e21a66ed 100644 --- a/packages/editor/src/lib/components/Shape.tsx +++ b/packages/editor/src/lib/components/Shape.tsx @@ -1,6 +1,6 @@ import { useQuickReactor, useStateTracking } from '@tldraw/state' import { TLShape, TLShapeId } from '@tldraw/tlschema' -import { memo, useCallback, useLayoutEffect, useRef } from 'react' +import { memo, useCallback, useRef } from 'react' import { ShapeUtil } from '../editor/shapes/ShapeUtil' import { useEditor } from '../hooks/useEditor' import { useEditorComponents } from '../hooks/useEditorComponents' @@ -26,7 +26,6 @@ export const Shape = memo(function Shape({ index, backgroundIndex, opacity, - isCulled, dprMultiple, }: { id: TLShapeId @@ -35,7 +34,6 @@ export const Shape = memo(function Shape({ index: number backgroundIndex: number opacity: number - isCulled: boolean dprMultiple: number }) { const editor = useEditor() @@ -120,13 +118,18 @@ export const Shape = memo(function Shape({ [opacity, index, backgroundIndex] ) - useLayoutEffect(() => { - const container = containerRef.current - const bgContainer = bgContainerRef.current - setStyleProperty(container, 'display', isCulled ? 'none' : 'block') - setStyleProperty(bgContainer, 'display', isCulled ? 'none' : 'block') - }, [isCulled]) + useQuickReactor( + 'set display', + () => { + const shape = editor.getShape(id) + if (!shape) return // probably the shape was just deleted + const isCulled = editor.isShapeCulled(shape) + setStyleProperty(containerRef.current, 'display', isCulled ? 'none' : 'block') + setStyleProperty(bgContainerRef.current, 'display', isCulled ? 'none' : 'block') + }, + [editor] + ) const annotateError = useCallback( (error: any) => editor.annotateError(error, { origin: 'shape', willCrashApp: false }), [editor] diff --git a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx index 1153b6d8c..af26ba7ac 100644 --- a/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx +++ b/packages/editor/src/lib/components/default-components/DefaultCanvas.tsx @@ -403,6 +403,7 @@ function SelectedIdIndicators() { 'select.idle', 'select.brushing', 'select.scribble_brushing', + 'select.editing_shape', 'select.pointing_shape', 'select.pointing_selection', 'select.pointing_handle' diff --git a/packages/editor/src/lib/constants.ts b/packages/editor/src/lib/constants.ts index caa0fff82..792dde32e 100644 --- a/packages/editor/src/lib/constants.ts +++ b/packages/editor/src/lib/constants.ts @@ -34,10 +34,10 @@ export const DOUBLE_CLICK_DURATION = 450 export const MULTI_CLICK_DURATION = 200 /** @internal */ -export const COARSE_DRAG_DISTANCE = 6 +export const COARSE_DRAG_DISTANCE = 36 // 6 squared /** @internal */ -export const DRAG_DISTANCE = 4 +export const DRAG_DISTANCE = 16 // 4 squared /** @internal */ export const SVG_PADDING = 32 diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 5a02c0148..11c7d9fb8 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -3124,48 +3124,26 @@ export class Editor extends EventEmitter { index: number backgroundIndex: number opacity: number - isCulled: boolean - maskedPageBounds: Box | undefined }[] = [] let nextIndex = MAX_SHAPES_PER_PAGE * 2 let nextBackgroundIndex = MAX_SHAPES_PER_PAGE - // We only really need these if we're using editor state, but that's ok - const editingShapeId = this.getEditingShapeId() - const selectedShapeIds = this.getSelectedShapeIds() const erasingShapeIds = this.getErasingShapeIds() - const renderingBoundsExpanded = this.getRenderingBoundsExpanded() - - // If renderingBoundsMargin is set to Infinity, then we won't cull offscreen shapes - const isCullingOffScreenShapes = Number.isFinite(this.renderingBoundsMargin) const addShapeById = (id: TLShapeId, opacity: number, isAncestorErasing: boolean) => { const shape = this.getShape(id) if (!shape) return opacity *= shape.opacity - let isCulled = false let isShapeErasing = false const util = this.getShapeUtil(shape) - const maskedPageBounds = this.getShapeMaskedPageBounds(id) if (useEditorState) { isShapeErasing = !isAncestorErasing && erasingShapeIds.includes(id) if (isShapeErasing) { opacity *= 0.32 } - - isCulled = - isCullingOffScreenShapes && - // never cull editingg shapes - editingShapeId !== id && - // if the shape is fully outside of its parent's clipping bounds... - (maskedPageBounds === undefined || - // ...or if the shape is outside of the expanded viewport bounds... - (!renderingBoundsExpanded.includes(maskedPageBounds) && - // ...and if it's not selected... then cull it - !selectedShapeIds.includes(id))) } renderingShapes.push({ @@ -3175,8 +3153,6 @@ export class Editor extends EventEmitter { index: nextIndex, backgroundIndex: nextBackgroundIndex, opacity, - isCulled, - maskedPageBounds, }) nextIndex += 1 @@ -4270,6 +4246,51 @@ export class Editor extends EventEmitter { return this.isShapeOrAncestorLocked(this.getShapeParent(shape)) } + @computed + private _getShapeCullingInfoCache(): ComputedCache { + return this.store.createComputedCache( + 'shapeCullingInfo', + ({ id }) => { + // We don't cull shapes that are being edited + if (this.getEditingShapeId() === id) return false + + const maskedPageBounds = this.getShapeMaskedPageBounds(id) + // if the shape is fully outside of its parent's clipping bounds... + if (maskedPageBounds === undefined) return true + + // We don't cull selected shapes + if (this.getSelectedShapeIds().includes(id)) return false + const renderingBoundsExpanded = this.getRenderingBoundsExpanded() + // the shape is outside of the expanded viewport bounds... + return !renderingBoundsExpanded.includes(maskedPageBounds) + }, + (a, b) => this.getShapeMaskedPageBounds(a) === this.getShapeMaskedPageBounds(b) + ) + } + + /** + * Get whether the shape is culled or not. + * + * @example + * ```ts + * editor.isShapeCulled(myShape) + * editor.isShapeCulled(myShapeId) + * ``` + * + * @param shape - The shape (or shape id) to get the culled info for. + * + * @public + */ + isShapeCulled(shape: TLShape | TLShapeId): boolean { + // If renderingBoundsMargin is set to Infinity, then we won't cull offscreen shapes + const isCullingOffScreenShapes = Number.isFinite(this.renderingBoundsMargin) + if (!isCullingOffScreenShapes) return false + + const id = typeof shape === 'string' ? shape : shape.id + + return this._getShapeCullingInfoCache().get(id)! as boolean + } + /** * The bounds of the current page (the common bounds of all of the shapes on the page). * @@ -4641,8 +4662,8 @@ export class Editor extends EventEmitter { * @public */ @computed getCurrentPageRenderingShapesSorted(): TLShape[] { - return this.getRenderingShapes() - .filter(({ isCulled }) => !isCulled) + return this.getUnorderedRenderingShapes(true) + .filter(({ id }) => !this.isShapeCulled(id)) .sort((a, b) => a.index - b.index) .map(({ shape }) => shape) } @@ -8579,7 +8600,7 @@ export class Editor extends EventEmitter { if ( !inputs.isDragging && inputs.isPointing && - originPagePoint.dist(currentPagePoint) > + Vec.Dist2(originPagePoint, currentPagePoint) > (this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / this.getZoomLevel() ) { @@ -8669,7 +8690,7 @@ export class Editor extends EventEmitter { if ( !inputs.isDragging && inputs.isPointing && - originPagePoint.dist(currentPagePoint) > + Vec.Dist2(originPagePoint, currentPagePoint) > (this.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) / this.getZoomLevel() ) { diff --git a/packages/editor/src/lib/editor/getSvgJsx.tsx b/packages/editor/src/lib/editor/getSvgJsx.tsx index 34965135c..1f16b0ce1 100644 --- a/packages/editor/src/lib/editor/getSvgJsx.tsx +++ b/packages/editor/src/lib/editor/getSvgJsx.tsx @@ -38,7 +38,8 @@ export async function getSvgJsx( if (opts.bounds) { bbox = opts.bounds } else { - for (const { maskedPageBounds } of renderingShapes) { + for (const { id } of renderingShapes) { + const maskedPageBounds = editor.getShapeMaskedPageBounds(id) if (!maskedPageBounds) continue if (bbox) { bbox.union(maskedPageBounds) diff --git a/packages/editor/src/lib/editor/managers/ClickManager.ts b/packages/editor/src/lib/editor/managers/ClickManager.ts index ef2c11111..ef5e53b10 100644 --- a/packages/editor/src/lib/editor/managers/ClickManager.ts +++ b/packages/editor/src/lib/editor/managers/ClickManager.ts @@ -227,7 +227,7 @@ export class ClickManager { if ( this._clickState !== 'idle' && this._clickScreenPoint && - this._clickScreenPoint.dist(this.editor.inputs.currentScreenPoint) > + Vec.Dist2(this._clickScreenPoint, this.editor.inputs.currentScreenPoint) > (this.editor.getInstanceState().isCoarsePointer ? COARSE_DRAG_DISTANCE : DRAG_DISTANCE) ) { this.cancelDoubleClickTimeout() diff --git a/packages/editor/src/lib/primitives/Vec.ts b/packages/editor/src/lib/primitives/Vec.ts index 126308e18..663a4bf6c 100644 --- a/packages/editor/src/lib/primitives/Vec.ts +++ b/packages/editor/src/lib/primitives/Vec.ts @@ -309,18 +309,20 @@ export class Vec { return new Vec(A.y, -A.x) } - static Dist2(A: VecLike, B: VecLike): number { - return (A.x - B.x) ** 2 + (A.y - B.y) ** 2 - } - static Abs(A: VecLike): Vec { return new Vec(Math.abs(A.x), Math.abs(A.y)) } + // Get the distance between two points. static Dist(A: VecLike, B: VecLike): number { return Math.hypot(A.y - B.y, A.x - B.x) } + // 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) + } + /** * Dot product of two vectors which is used to calculate the angle between them. */ diff --git a/packages/editor/src/lib/primitives/geometry/Arc2d.ts b/packages/editor/src/lib/primitives/geometry/Arc2d.ts index 5feee17ef..3ca170a47 100644 --- a/packages/editor/src/lib/primitives/geometry/Arc2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Arc2d.ts @@ -52,15 +52,16 @@ export class Arc2d extends Geometry2d { // Get the point (P) on the arc, then pick the nearest of A, B, and P const P = _center.clone().add(point.clone().sub(_center).uni().mul(radius)) - let distance = Infinity let nearest: Vec | undefined - for (const pt of [A, B, P]) { - if (point.dist(pt) < distance) { - nearest = pt - distance = point.dist(pt) + let dist = Infinity + let d: number + for (const p of [A, B, P]) { + d = Vec.Dist2(point, p) + if (d < dist) { + nearest = p + dist = d } } - if (!nearest) throw Error('nearest point not found') return nearest } diff --git a/packages/editor/src/lib/primitives/geometry/CubicBezier2d.ts b/packages/editor/src/lib/primitives/geometry/CubicBezier2d.ts index 9923891b7..912144378 100644 --- a/packages/editor/src/lib/primitives/geometry/CubicBezier2d.ts +++ b/packages/editor/src/lib/primitives/geometry/CubicBezier2d.ts @@ -55,9 +55,11 @@ export class CubicBezier2d extends Polyline2d { nearestPoint(A: Vec): Vec { let nearest: Vec | undefined let dist = Infinity + let d: number + let p: Vec for (const edge of this.segments) { - const p = edge.nearestPoint(A) - const d = p.dist(A) + p = edge.nearestPoint(A) + d = Vec.Dist2(p, A) if (d < dist) { nearest = p dist = d diff --git a/packages/editor/src/lib/primitives/geometry/CubicSpline2d.ts b/packages/editor/src/lib/primitives/geometry/CubicSpline2d.ts index 3c362b91b..8ff99bb0a 100644 --- a/packages/editor/src/lib/primitives/geometry/CubicSpline2d.ts +++ b/packages/editor/src/lib/primitives/geometry/CubicSpline2d.ts @@ -67,15 +67,16 @@ export class CubicSpline2d extends Geometry2d { nearestPoint(A: Vec): Vec { let nearest: Vec | undefined let dist = Infinity + let d: number + let p: Vec for (const segment of this.segments) { - const p = segment.nearestPoint(A) - const d = p.dist(A) + p = segment.nearestPoint(A) + d = Vec.Dist2(p, A) if (d < dist) { nearest = p dist = d } } - if (!nearest) throw Error('nearest point not found') return nearest } diff --git a/packages/editor/src/lib/primitives/geometry/Ellipse2d.ts b/packages/editor/src/lib/primitives/geometry/Ellipse2d.ts index d15b864af..a2340c298 100644 --- a/packages/editor/src/lib/primitives/geometry/Ellipse2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Ellipse2d.ts @@ -76,15 +76,16 @@ export class Ellipse2d extends Geometry2d { nearestPoint(A: Vec): Vec { let nearest: Vec | undefined let dist = Infinity + let d: number + let p: Vec for (const edge of this.edges) { - const p = edge.nearestPoint(A) - const d = p.dist(A) + p = edge.nearestPoint(A) + d = Vec.Dist2(p, A) if (d < dist) { nearest = p dist = d } } - if (!nearest) throw Error('nearest point not found') return nearest } diff --git a/packages/editor/src/lib/primitives/geometry/Geometry2d.ts b/packages/editor/src/lib/primitives/geometry/Geometry2d.ts index e028de31e..b7df7a84d 100644 --- a/packages/editor/src/lib/primitives/geometry/Geometry2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Geometry2d.ts @@ -55,14 +55,17 @@ export abstract class Geometry2d { } nearestPointOnLineSegment(A: Vec, B: Vec): Vec { - let distance = Infinity + const { vertices } = this let nearest: Vec | undefined - for (let i = 0; i < this.vertices.length; i++) { - const point = this.vertices[i] - const d = Vec.DistanceToLineSegment(A, B, point) - if (d < distance) { - distance = d - nearest = point + let dist = Infinity + let d: number + let p: Vec + for (let i = 0; i < vertices.length; i++) { + p = vertices[i] + d = Vec.DistanceToLineSegment(A, B, p) + if (d < dist) { + dist = d + nearest = p } } if (!nearest) throw Error('nearest point not found') diff --git a/packages/editor/src/lib/primitives/geometry/Group2d.ts b/packages/editor/src/lib/primitives/geometry/Group2d.ts index 3caeabfc8..b98f1ea99 100644 --- a/packages/editor/src/lib/primitives/geometry/Group2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Group2d.ts @@ -30,8 +30,8 @@ export class Group2d extends Geometry2d { } override nearestPoint(point: Vec): Vec { - let d = Infinity - let p: Vec | undefined + let dist = Infinity + let nearest: Vec | undefined const { children } = this @@ -39,16 +39,18 @@ export class Group2d extends Geometry2d { throw Error('no children') } + let p: Vec + let d: number for (const child of children) { - const nearest = child.nearestPoint(point) - const dist = nearest.dist(point) - if (dist < d) { - d = dist - p = nearest + p = child.nearestPoint(point) + d = Vec.Dist2(p, point) + if (d < dist) { + dist = d + nearest = p } } - if (!p) throw Error('nearest point not found') - return p + if (!nearest) throw Error('nearest point not found') + return nearest } override distanceToPoint(point: Vec, hitInside = false) { diff --git a/packages/editor/src/lib/primitives/geometry/Polyline2d.ts b/packages/editor/src/lib/primitives/geometry/Polyline2d.ts index ffa541cb2..84c8e7471 100644 --- a/packages/editor/src/lib/primitives/geometry/Polyline2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Polyline2d.ts @@ -51,18 +51,17 @@ export class Polyline2d extends Geometry2d { const { segments } = this let nearest = this.points[0] let dist = Infinity - let p: Vec // current point on segment let d: number // distance from A to p for (let i = 0; i < segments.length; i++) { p = segments[i].nearestPoint(A) - d = p.dist(A) + d = Vec.Dist2(p, A) if (d < dist) { nearest = p dist = d } } - + if (!nearest) throw Error('nearest point not found') return nearest } diff --git a/packages/store/api/api.json b/packages/store/api/api.json index d676a9c7e..cb4e90c11 100644 --- a/packages/store/api/api.json +++ b/packages/store/api/api.json @@ -4231,7 +4231,7 @@ { "kind": "Property", "canonicalReference": "@tldraw/store!Store#onBeforeChange:member", - "docComment": "/**\n * A callback before after each record's change.\n *\n * @param prev - The previous value, if any.\n *\n * @param next - The next value.\n */\n", + "docComment": "/**\n * A callback fired before each record's change.\n *\n * @param prev - The previous value, if any.\n *\n * @param next - The next value.\n */\n", "excerptTokens": [ { "kind": "Content", diff --git a/packages/store/src/lib/Store.ts b/packages/store/src/lib/Store.ts index c0feabd64..87856ef1a 100644 --- a/packages/store/src/lib/Store.ts +++ b/packages/store/src/lib/Store.ts @@ -318,7 +318,7 @@ export class Store { onAfterCreate?: (record: R, source: 'remote' | 'user') => void /** - * A callback before after each record's change. + * A callback fired before each record's change. * * @param prev - The previous value, if any. * @param next - The next value. diff --git a/packages/tldraw/src/lib/canvas/TldrawSelectionForeground.tsx b/packages/tldraw/src/lib/canvas/TldrawSelectionForeground.tsx index 3d78e9514..295b4f8cb 100644 --- a/packages/tldraw/src/lib/canvas/TldrawSelectionForeground.tsx +++ b/packages/tldraw/src/lib/canvas/TldrawSelectionForeground.tsx @@ -87,7 +87,6 @@ export const TldrawSelectionForeground = track(function TldrawSelectionForegroun 'select.scribble_brushing', 'select.pointing_canvas', 'select.pointing_selection', - 'select.editing_shape', 'select.pointing_shape', 'select.crop.idle', 'select.crop.pointing_crop', diff --git a/packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts b/packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts index 7ba9b582b..6946fec1a 100644 --- a/packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts +++ b/packages/tldraw/src/lib/shapes/draw/toolStates/Drawing.ts @@ -309,7 +309,7 @@ export class Drawing extends StateNode { } const hasMovedFarEnough = - Vec.Dist(pagePointWhereNextSegmentChanged, inputs.currentPagePoint) > DRAG_DISTANCE + Vec.Dist2(pagePointWhereNextSegmentChanged, inputs.currentPagePoint) > DRAG_DISTANCE // Find the distance from where the pointer was when shift was released and // where it is now; if it's far enough away, then update the page point where @@ -382,7 +382,7 @@ export class Drawing extends StateNode { } const hasMovedFarEnough = - Vec.Dist(pagePointWhereNextSegmentChanged, inputs.currentPagePoint) > DRAG_DISTANCE + Vec.Dist2(pagePointWhereNextSegmentChanged, inputs.currentPagePoint) > DRAG_DISTANCE // Find the distance from where the pointer was when shift was released and // where it is now; if it's far enough away, then update the page point where diff --git a/packages/tldraw/src/lib/shapes/shared/useEditableText.ts b/packages/tldraw/src/lib/shapes/shared/useEditableText.ts index cafb378c9..59aebf34e 100644 --- a/packages/tldraw/src/lib/shapes/shared/useEditableText.ts +++ b/packages/tldraw/src/lib/shapes/shared/useEditableText.ts @@ -160,9 +160,11 @@ export function useEditableText(id: TLShapeId, type: string, text: string) { // This is important so that when dragging a shape using the text label, // the shape continues to be dragged, even if the cursor is over the UI. - setPointerCapture(e.currentTarget, e) + if (!isEditing) { + setPointerCapture(e.currentTarget, e) + } }, - [editor, id] + [editor, id, isEditing] ) const handleDoubleClick = stopEventPropagation diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts index 8225a81b5..93a663ff8 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/PointingShape.ts @@ -38,9 +38,8 @@ export class PointingShape extends StateNode { outermostSelectingShape.id === focusedGroupId || // ...or if the shape is within the selection selectedShapeIds.includes(outermostSelectingShape.id) || - // ...or if an ancestor of the shape is selected (except note shapes)... - // todo: Consider adding a flag for this hardcoded behaviour - (selectedAncestor && selectedAncestor.type !== 'note') || + // ...or if an ancestor of the shape is selected + selectedAncestor || // ...or if the current point is NOT within the selection bounds (selectedShapeIds.length > 1 && selectionBounds?.containsPoint(currentPagePoint)) ) { diff --git a/packages/tldraw/src/lib/ui/components/HelperButtons/BackToContent.tsx b/packages/tldraw/src/lib/ui/components/HelperButtons/BackToContent.tsx index 89bfd2660..004dedfdf 100644 --- a/packages/tldraw/src/lib/ui/components/HelperButtons/BackToContent.tsx +++ b/packages/tldraw/src/lib/ui/components/HelperButtons/BackToContent.tsx @@ -19,9 +19,10 @@ export function BackToContent() { // Rendering shapes includes all the shapes in the current page. // We have to filter them down to just the shapes that are inside the renderingBounds. - const visibleShapes = renderingShapes.filter( - (s) => s.maskedPageBounds && renderingBounds.includes(s.maskedPageBounds) - ) + const visibleShapes = renderingShapes.filter((s) => { + const maskedPageBounds = editor.getShapeMaskedPageBounds(s.id) + return maskedPageBounds && renderingBounds.includes(maskedPageBounds) + }) const showBackToContentNow = visibleShapes.length === 0 && editor.getCurrentPageShapes().length > 0 diff --git a/packages/tldraw/src/test/renderingShapes.test.tsx b/packages/tldraw/src/test/renderingShapes.test.tsx index 322dfc34f..9b7c2ebbd 100644 --- a/packages/tldraw/src/test/renderingShapes.test.tsx +++ b/packages/tldraw/src/test/renderingShapes.test.tsx @@ -63,42 +63,50 @@ it('updates the rendering viewport when the camera stops moving', () => { it('lists shapes in viewport', () => { const ids = createShapes() editor.selectNone() - expect(editor.getRenderingShapes().map(({ id, isCulled }) => [id, isCulled])).toStrictEqual([ - [ids.A, false], // A is within the expanded rendering bounds, so should not be culled; and it's in the regular viewport too, so it's on screen. - [ids.B, false], - [ids.C, false], - [ids.D, true], // D is clipped and so should always be culled / outside of viewport - ]) + expect(editor.getRenderingShapes().map(({ id }) => [id, editor.isShapeCulled(id)])).toStrictEqual( + [ + [ids.A, false], // A is within the expanded rendering bounds, so should not be culled; and it's in the regular viewport too, so it's on screen. + [ids.B, false], + [ids.C, false], + [ids.D, true], // D is clipped and so should always be culled / outside of viewport + ] + ) // Move the camera 201 pixels to the right and 201 pixels down editor.pan({ x: -201, y: -201 }) jest.advanceTimersByTime(500) - expect(editor.getRenderingShapes().map(({ id, isCulled }) => [id, isCulled])).toStrictEqual([ - [ids.A, false], // A should not be culled, even though it's no longer in the viewport (because it's still in the EXPANDED viewport) - [ids.B, false], - [ids.C, false], - [ids.D, true], // D is clipped and so should always be culled / outside of viewport - ]) + expect(editor.getRenderingShapes().map(({ id }) => [id, editor.isShapeCulled(id)])).toStrictEqual( + [ + [ids.A, false], // A should not be culled, even though it's no longer in the viewport (because it's still in the EXPANDED viewport) + [ids.B, false], + [ids.C, false], + [ids.D, true], // D is clipped and so should always be culled / outside of viewport + ] + ) editor.pan({ x: -100, y: -100 }) jest.advanceTimersByTime(500) - expect(editor.getRenderingShapes().map(({ id, isCulled }) => [id, isCulled])).toStrictEqual([ - [ids.A, true], // A should be culled now that it's outside of the expanded viewport too - [ids.B, false], - [ids.C, false], - [ids.D, true], // D is clipped and so should always be culled, even if it's in the viewport - ]) + expect(editor.getRenderingShapes().map(({ id }) => [id, editor.isShapeCulled(id)])).toStrictEqual( + [ + [ids.A, true], // A should be culled now that it's outside of the expanded viewport too + [ids.B, false], + [ids.C, false], + [ids.D, true], // D is clipped and so should always be culled, even if it's in the viewport + ] + ) editor.pan({ x: -900, y: -900 }) jest.advanceTimersByTime(500) - expect(editor.getRenderingShapes().map(({ id, isCulled }) => [id, isCulled])).toStrictEqual([ - [ids.A, true], - [ids.B, true], - [ids.C, true], - [ids.D, true], - ]) + expect(editor.getRenderingShapes().map(({ id }) => [id, editor.isShapeCulled(id)])).toStrictEqual( + [ + [ids.A, true], + [ids.B, true], + [ids.C, true], + [ids.D, true], + ] + ) }) it('lists shapes in viewport sorted by id with correct indexes & background indexes', () => { diff --git a/packages/utils/api-report.md b/packages/utils/api-report.md index e1379999c..fdf4cd9b4 100644 --- a/packages/utils/api-report.md +++ b/packages/utils/api-report.md @@ -176,6 +176,12 @@ export function mapObjectMapValues( [K in Key]: ValueAfter; }; +// @internal (undocumented) +export function measureCbDuration(name: string, cb: () => any): any; + +// @internal (undocumented) +export function measureDuration(_target: any, propertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor; + // @public export class MediaHelpers { static getImageSize(blob: Blob): Promise<{ diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index b008b9753..598a26dd7 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -36,6 +36,7 @@ export { objectMapKeys, objectMapValues, } from './lib/object' +export { measureCbDuration, measureDuration } from './lib/perf' export { PngHelpers } from './lib/png' export { type IndexKey } from './lib/reordering/IndexKey' export { diff --git a/packages/utils/src/lib/perf.ts b/packages/utils/src/lib/perf.ts new file mode 100644 index 000000000..689f8640a --- /dev/null +++ b/packages/utils/src/lib/perf.ts @@ -0,0 +1,22 @@ +/** @internal */ +export function measureCbDuration(name: string, cb: () => any) { + const now = performance.now() + const result = cb() + // eslint-disable-next-line no-console + console.log(`${name} took`, performance.now() - now, 'ms') + return result +} + +/** @internal */ +export function measureDuration(_target: any, propertyKey: string, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value + descriptor.value = function (...args: any[]) { + const start = performance.now() + const result = originalMethod.apply(this, args) + const end = performance.now() + // eslint-disable-next-line no-console + console.log(`${propertyKey} took ${end - start}ms `) + return result + } + return descriptor +}