arrows: separate out handle behavior from labels (#2621)

This is a followup on the arrows work.
- allow labels to go to the ends if no arrowhead is present
- avoid using / overloading TLHandle and use a new PointingLabel state
to specifically address label movement
- removes the feature flag to launch this feature!

### Change Type

- [x] `patch` — Bug fix

### Release Notes

- Arrow labels: provide more polish on label placement

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
Mime Čuvalo 2024-01-31 11:17:03 +00:00 committed by GitHub
parent f87702bda4
commit 34a95b2ec8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 572 additions and 445 deletions

View file

@ -969,9 +969,7 @@ export const EVENT_NAME_MAP: Record<Exclude<TLEventName, TLPinchEventName>, keyo
export function extractSessionStateFromLegacySnapshot(store: Record<string, UnknownRecord>): null | TLSessionStateSnapshot;
// @internal (undocumented)
export const featureFlags: {
canMoveArrowLabel: DebugFlag<boolean>;
};
export const featureFlags: Record<string, DebugFlag<boolean>>;
// @public (undocumented)
export type GapsSnapLine = {
@ -1041,6 +1039,9 @@ export abstract class Geometry2d {
_vertices: undefined | Vec[];
}
// @public
export function getArcMeasure(A: number, B: number, sweepFlag: number, largeArcFlag: number): number;
// @public (undocumented)
export function getArrowTerminalsInArrowSpace(editor: Editor, shape: TLArrowShape): {
start: Vec;
@ -1092,6 +1093,9 @@ export function getPointerInfo(e: PointerEvent | React.PointerEvent): {
isPen: boolean;
};
// @public
export function getPointInArcT(mAB: number, A: number, B: number, P: number): number;
// @public
export function getPointOnCircle(center: VecLike, r: number, a: number): Vec;
@ -1661,8 +1665,6 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
onDropShapesOver?: TLOnDragHandler<Shape>;
onEditEnd?: TLOnEditEndHandler<Shape>;
onHandleDrag?: TLOnHandleDragHandler<Shape>;
onHandleDragEnd?: TLOnHandleDragStartHandler<Shape>;
onHandleDragStart?: TLOnHandleDragStartHandler<Shape>;
onResize?: TLOnResizeHandler<Shape>;
onResizeEnd?: TLOnResizeEndHandler<Shape>;
onResizeStart?: TLOnResizeStartHandler<Shape>;
@ -2382,9 +2384,6 @@ export type TLOnHandleDragHandler<T extends TLShape> = (shape: T, info: {
initial?: T | undefined;
}) => TLShapePartial<T> | void;
// @public (undocumented)
export type TLOnHandleDragStartHandler<T extends TLShape> = (shape: T) => TLShapePartial<T> | void;
// @public
export type TLOnMountHandler = (editor: Editor) => (() => undefined | void) | undefined | void;

View file

@ -21183,6 +21183,99 @@
],
"implementsTokenRanges": []
},
{
"kind": "Function",
"canonicalReference": "@tldraw/editor!getArcMeasure:function(1)",
"docComment": "/**\n * Get the measure of an arc.\n *\n * @param A - The angle from center to arc's start point (A) on the circle\n *\n * @param B - The angle from center to arc's end point (B) on the circle\n *\n * @param sweepFlag - 1 if the arc is clockwise, 0 if counter-clockwise\n *\n * @param largeArcFlag - 1 if the arc is greater than 180 degrees, 0 if less than 180 degrees\n *\n * @returns The measure of the arc, negative if counter-clockwise\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export declare function getArcMeasure(A: "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": ", B: "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": ", sweepFlag: "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": ", largeArcFlag: "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/editor/src/lib/primitives/utils.ts",
"returnTypeTokenRange": {
"startIndex": 9,
"endIndex": 10
},
"releaseTag": "Public",
"overloadIndex": 1,
"parameters": [
{
"parameterName": "A",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
},
{
"parameterName": "B",
"parameterTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"isOptional": false
},
{
"parameterName": "sweepFlag",
"parameterTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"isOptional": false
},
{
"parameterName": "largeArcFlag",
"parameterTypeTokenRange": {
"startIndex": 7,
"endIndex": 8
},
"isOptional": false
}
],
"name": "getArcMeasure"
},
{
"kind": "Function",
"canonicalReference": "@tldraw/editor!getArrowTerminalsInArrowSpace:function(1)",
@ -21898,6 +21991,99 @@
],
"name": "getPointerInfo"
},
{
"kind": "Function",
"canonicalReference": "@tldraw/editor!getPointInArcT:function(1)",
"docComment": "/**\n * Returns the t value of the point on the arc.\n *\n * @param mAB - The measure of the arc from A to B, negative if counter-clockwise\n *\n * @param A - The angle from center to arc's start point (A) on the circle\n *\n * @param B - The angle from center to arc's end point (B) on the circle\n *\n * @param P - The angle on the circle (P) to find the t value for\n *\n * @returns The t value of the point on the arc, with 0 being the start and 1 being the end\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export declare function getPointInArcT(mAB: "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": ", A: "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": ", B: "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": ", P: "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/editor/src/lib/primitives/utils.ts",
"returnTypeTokenRange": {
"startIndex": 9,
"endIndex": 10
},
"releaseTag": "Public",
"overloadIndex": 1,
"parameters": [
{
"parameterName": "mAB",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
},
{
"parameterName": "A",
"parameterTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"isOptional": false
},
{
"parameterName": "B",
"parameterTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"isOptional": false
},
{
"parameterName": "P",
"parameterTypeTokenRange": {
"startIndex": 7,
"endIndex": 8
},
"isOptional": false
}
],
"name": "getPointInArcT"
},
{
"kind": "Function",
"canonicalReference": "@tldraw/editor!getPointOnCircle:function(1)",
@ -31338,76 +31524,6 @@
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "@tldraw/editor!ShapeUtil#onHandleDragEnd:member",
"docComment": "/**\n * A callback called when a shape starts being dragged.\n *\n * @param shape - The shape.\n *\n * @returns A change to apply to the shape, or void.\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "onHandleDragEnd?: "
},
{
"kind": "Reference",
"text": "TLOnHandleDragStartHandler",
"canonicalReference": "@tldraw/editor!TLOnHandleDragStartHandler:type"
},
{
"kind": "Content",
"text": "<Shape>"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": true,
"releaseTag": "Public",
"name": "onHandleDragEnd",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 3
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "@tldraw/editor!ShapeUtil#onHandleDragStart:member",
"docComment": "/**\n * A callback called when a shape starts being dragged.\n *\n * @param shape - The shape.\n *\n * @returns A change to apply to the shape, or void.\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "onHandleDragStart?: "
},
{
"kind": "Reference",
"text": "TLOnHandleDragStartHandler",
"canonicalReference": "@tldraw/editor!TLOnHandleDragStartHandler:type"
},
{
"kind": "Content",
"text": "<Shape>"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": true,
"releaseTag": "Public",
"name": "onHandleDragStart",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 3
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "@tldraw/editor!ShapeUtil#onResize:member",
@ -39551,63 +39667,6 @@
"endIndex": 8
}
},
{
"kind": "TypeAlias",
"canonicalReference": "@tldraw/editor!TLOnHandleDragStartHandler:type",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export type TLOnHandleDragStartHandler<T extends "
},
{
"kind": "Reference",
"text": "TLShape",
"canonicalReference": "@tldraw/tlschema!TLShape:type"
},
{
"kind": "Content",
"text": "> = "
},
{
"kind": "Content",
"text": "(shape: T) => "
},
{
"kind": "Reference",
"text": "TLShapePartial",
"canonicalReference": "@tldraw/tlschema!TLShapePartial:type"
},
{
"kind": "Content",
"text": "<T> | void"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/editor/src/lib/editor/shapes/ShapeUtil.ts",
"releaseTag": "Public",
"name": "TLOnHandleDragStartHandler",
"typeParameters": [
{
"typeParameterName": "T",
"constraintTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"defaultTypeTokenRange": {
"startIndex": 0,
"endIndex": 0
}
}
],
"typeTokenRange": {
"startIndex": 3,
"endIndex": 6
}
},
{
"kind": "TypeAlias",
"canonicalReference": "@tldraw/editor!TLOnMountHandler:type",

View file

@ -525,16 +525,6 @@ input,
pointer-events: none;
}
.tl-handle__text-adjust {
cursor: var(--tl-cursor-grab);
fill: transparent;
}
.tl-handle__text-adjust:hover {
stroke-width: calc(2px * var(--tl-scale));
stroke: var(--color-selection-stroke);
}
.tl-handle__create {
opacity: 0;
}
@ -1153,6 +1143,10 @@ input,
align-items: center;
}
.tl-arrow-label__inner p {
cursor: var(--tl-cursor-grab);
}
.tl-arrow-label p,
.tl-arrow-label textarea {
margin: 0px;

View file

@ -175,7 +175,6 @@ export {
type TLOnDragHandler,
type TLOnEditEndHandler,
type TLOnHandleDragHandler,
type TLOnHandleDragStartHandler,
type TLOnResizeEndHandler,
type TLOnResizeHandler,
type TLOnResizeStartHandler,
@ -319,6 +318,8 @@ export {
clockwiseAngleDist,
counterClockwiseAngleDist,
degreesToRadians,
getArcMeasure,
getPointInArcT,
getPointOnCircle,
getPolygonVertices,
isSafeFloat,

View file

@ -14,14 +14,6 @@ export type TLHandleComponent = ComponentType<{
/** @public */
export const DefaultHandle: TLHandleComponent = ({ handle, isCoarse, className, zoom }) => {
if (handle.type === 'text-adjust') {
return (
<g className={classNames('tl-handle', 'tl-handle__text-adjust', className)}>
<rect rx={4} ry={4} width={handle.w} height={handle.h} />
</g>
)
}
const bgRadius = (isCoarse ? COARSE_HANDLE_RADIUS : HANDLE_RADIUS) / zoom
const fgRadius = (handle.type === 'create' && isCoarse ? 3 : 4) / zoom

View file

@ -411,15 +411,6 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
*/
onTranslateEnd?: TLOnTranslateEndHandler<Shape>
/**
* A callback called when a shape starts being dragged.
*
* @param shape - The shape.
* @returns A change to apply to the shape, or void.
* @public
*/
onHandleDragStart?: TLOnHandleDragStartHandler<Shape>
/**
* A callback called when a shape's handle changes.
*
@ -430,15 +421,6 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
*/
onHandleDrag?: TLOnHandleDragHandler<Shape>
/**
* A callback called when a shape starts being dragged.
*
* @param shape - The shape.
* @returns A change to apply to the shape, or void.
* @public
*/
onHandleDragEnd?: TLOnHandleDragStartHandler<Shape>
/**
* A callback called when a shape starts being rotated.
*
@ -604,9 +586,6 @@ export type TLOnBindingChangeHandler<T extends TLShape> = (shape: T) => TLShapeP
/** @public */
export type TLOnChildrenChangeHandler<T extends TLShape> = (shape: T) => TLShapePartial[] | void
/** @public */
export type TLOnHandleDragStartHandler<T extends TLShape> = (shape: T) => TLShapePartial<T> | void
/** @public */
export type TLOnHandleDragHandler<T extends TLShape> = (
shape: T,
@ -617,9 +596,6 @@ export type TLOnHandleDragHandler<T extends TLShape> = (
}
) => TLShapePartial<T> | void
/** @public */
export type TLOnHandleDragEndHandler<T extends TLShape> = (shape: T) => TLShapePartial<T> | void
/** @public */
export type TLOnClickHandler<T extends TLShape> = (shape: T) => TLShapePartial<T> | void
/** @public */

View file

@ -1,6 +1,6 @@
import { Vec } from '../Vec'
import { intersectLineSegmentCircle } from '../intersect'
import { PI, PI2, getPointOnCircle, shortAngleDist } from '../utils'
import { getArcMeasure, getPointInArcT, getPointOnCircle } from '../utils'
import { Geometry2d, Geometry2dOptions } from './Geometry2d'
import { getVerticesCountForLength } from './geometry-constants'
@ -89,49 +89,3 @@ export class Arc2d extends Geometry2d {
return vertices
}
}
/**
* Returns the t value of the point on the arc.
*
* @param mAB - The measure of the arc from A to B, negative if counter-clockwise
* @param A - The angle from center to arc's start point (A) on the circle
* @param B - The angle from center to arc's end point (B) on the circle
* @param P - The angle on the circle (P) to find the t value for
*
* @returns The t value of the point on the arc, with 0 being the start and 1 being the end
*
* @public
*/
function getPointInArcT(mAB: number, A: number, B: number, P: number): number {
let mAP: number
if (Math.abs(mAB) > PI) {
mAP = shortAngleDist(A, P)
const mPB = shortAngleDist(P, B)
if (Math.abs(mAP) < Math.abs(mPB)) {
return mAP / mAB
} else {
return (mAB - mPB) / mAB
}
} else {
mAP = shortAngleDist(A, P)
return mAP / mAB
}
}
/**
* Get the measure of an arc.
*
* @param A The angle from center to arc's start point (A) on the circle
* @param B The angle from center to arc's end point (B) on the circle
* @param sweepFlag 1 if the arc is clockwise, 0 if counter-clockwise
* @param largeArcFlag 1 if the arc is greater than 180 degrees, 0 if less than 180 degrees
*
* @returns The measure of the arc, negative if counter-clockwise
*
* @public
*/
function getArcMeasure(A: number, B: number, sweepFlag: number, largeArcFlag: number) {
const m = ((2 * ((B - A) % PI2)) % PI2) - ((B - A) % PI2)
if (!largeArcFlag) return m
return (PI2 - Math.abs(m)) * (sweepFlag ? 1 : -1)
}

View file

@ -0,0 +1,59 @@
import { getPointInArcT } from './utils'
describe('getPointInArcT', () => {
it('should return 0 for the start of the arc', () => {
const mAB = Math.PI / 2 // 90 degrees
const A = 0 // Start angle
const B = Math.PI / 2 // End angle
const P = 0 // Point angle, same as start
expect(getPointInArcT(mAB, A, B, P)).toBe(0)
})
it('should return 1 for the end of the arc', () => {
const mAB = Math.PI / 2 // 90 degrees
const A = 0 // Start angle
const B = Math.PI / 2 // End angle
const P = Math.PI / 2 // Point angle, same as end
expect(getPointInArcT(mAB, A, B, P)).toBe(1)
})
it('should return 0.5 for the midpoint of the arc', () => {
const mAB = Math.PI // 180 degrees
const A = 0 // Start angle
const B = Math.PI // End angle
const P = Math.PI / 2 // Point angle, midpoint
expect(getPointInArcT(mAB, A, B, P)).toBe(0.5)
})
it('should handle negative arcs correctly', () => {
const mAB = -Math.PI / 2 // -90 degrees, counter-clockwise
const A = Math.PI / 2 // Start angle
const B = 0 // End angle
const P = Math.PI / 4 // Point angle, quarter way
expect(getPointInArcT(mAB, A, B, P)).toBe(0.5)
})
it('should return correct t value for arcs larger than PI', () => {
const mAB = Math.PI * 1.5 // 270 degrees
const A = 0 // Start angle
const B = -Math.PI / 2 // End angle, going counter-clockwise
const P = -Math.PI / 4 // Point angle, halfway
expect(getPointInArcT(mAB, A, B, P)).toBe(7 / 6)
})
it('should handle edge case where measurement to center is negative but measure to points near the end are positive', () => {
const mAB = -2.8 // Arc measure
const A = 0 // Start angle
const B = 2.2 // End angle
const P = 1.1 // Point angle, should be near the end
expect(getPointInArcT(mAB, A, B, P)).toBe(0)
})
it('should handle edge case where measurement to center is negative but measure to points near the end are positive with other endpoint', () => {
const mAB = 0 // Arc measure
const A = 0 // Start angle
const B = 2.2 // End angle
const P = 1.1 // Point angle, should be near the end
expect(getPointInArcT(mAB, A, B, P)).toBe(1)
})
})

View file

@ -372,3 +372,58 @@ export function angleDistance(fromAngle: number, toAngle: number, direction: num
: counterClockwiseAngleDist(fromAngle, toAngle)
return dist
}
/**
* Returns the t value of the point on the arc.
*
* @param mAB - The measure of the arc from A to B, negative if counter-clockwise
* @param A - The angle from center to arc's start point (A) on the circle
* @param B - The angle from center to arc's end point (B) on the circle
* @param P - The angle on the circle (P) to find the t value for
*
* @returns The t value of the point on the arc, with 0 being the start and 1 being the end
*
* @public
*/
export function getPointInArcT(mAB: number, A: number, B: number, P: number): number {
let mAP: number
if (Math.abs(mAB) > PI) {
mAP = shortAngleDist(A, P)
const mPB = shortAngleDist(P, B)
if (Math.abs(mAP) < Math.abs(mPB)) {
return mAP / mAB
} else {
return (mAB - mPB) / mAB
}
} else {
mAP = shortAngleDist(A, P)
const t = mAP / mAB
// If the arc is something like -2.8 to 2.2, then we'll get a weird bug
// where the measurement to the center is negative but measure to points
// near the end are positive
if (Math.sign(mAP) !== Math.sign(mAB)) {
return Math.abs(t) > 0.5 ? 1 : 0
}
return t
}
}
/**
* Get the measure of an arc.
*
* @param A - The angle from center to arc's start point (A) on the circle
* @param B - The angle from center to arc's end point (B) on the circle
* @param sweepFlag - 1 if the arc is clockwise, 0 if counter-clockwise
* @param largeArcFlag - 1 if the arc is greater than 180 degrees, 0 if less than 180 degrees
*
* @returns The measure of the arc, negative if counter-clockwise
*
* @public
*/
export function getArcMeasure(A: number, B: number, sweepFlag: number, largeArcFlag: number) {
const m = ((2 * ((B - A) % PI2)) % PI2) - ((B - A) % PI2)
if (!largeArcFlag) return m
return (PI2 - Math.abs(m)) * (sweepFlag ? 1 : -1)
}

View file

@ -7,9 +7,9 @@ import { Atom, atom, react } from '@tldraw/state'
// development. Use `createFeatureFlag` to create a boolean flag which will be
// `true` by default in development and staging, and `false` in production.
/** @internal */
export const featureFlags = {
canMoveArrowLabel: createFeatureFlag('canMoveArrowLabel'),
} satisfies Record<string, DebugFlag<boolean>>
export const featureFlags: Record<string, DebugFlag<boolean>> = {
// canMoveArrowLabel: createFeatureFlag('canMoveArrowLabel'),
}
/** @internal */
export const debugFlags = {
@ -111,16 +111,16 @@ function createDebugValue<T>(
})
}
function createFeatureFlag(
name: string,
defaults: Defaults<boolean> = { all: true, production: false }
) {
return createDebugValueBase({
name,
defaults,
shouldStoreForSession: true,
})
}
// function createFeatureFlag(
// name: string,
// defaults: Defaults<boolean> = { all: true, production: false }
// ) {
// return createDebugValueBase({
// name,
// defaults,
// shouldStoreForSession: true,
// })
// }
function createDebugValueBase<T>(def: DebugFlagDef<T>): DebugFlag<T> {
const defaultValue = getDefaultValue(def)

View file

@ -80,7 +80,6 @@ import { TLOnBeforeUpdateHandler } from '@tldraw/editor';
import { TLOnDoubleClickHandler } from '@tldraw/editor';
import { TLOnEditEndHandler } from '@tldraw/editor';
import { TLOnHandleDragHandler } from '@tldraw/editor';
import { TLOnHandleDragStartHandler } from '@tldraw/editor';
import { TLOnResizeEndHandler } from '@tldraw/editor';
import { TLOnResizeHandler } from '@tldraw/editor';
import { TLOnTranslateHandler } from '@tldraw/editor';
@ -164,8 +163,6 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
// (undocumented)
onHandleDrag: TLOnHandleDragHandler<TLArrowShape>;
// (undocumented)
onHandleDragStart: TLOnHandleDragStartHandler<TLArrowShape>;
// (undocumented)
onResize: TLOnResizeHandler<TLArrowShape>;
// (undocumented)
onTranslate?: TLOnTranslateHandler<TLArrowShape>;
@ -1082,7 +1079,7 @@ function Root({ id, children, modal, debugOpen, }: {
// @public (undocumented)
export class SelectTool extends StateNode {
// (undocumented)
static children: () => (typeof Brushing | typeof Crop | typeof Cropping | typeof DraggingHandle | typeof EditingShape | typeof Idle_11 | typeof PointingCanvas | typeof PointingCropHandle | typeof PointingHandle | typeof PointingResizeHandle | typeof PointingRotateHandle | typeof PointingSelection | typeof PointingShape | typeof Resizing | typeof Rotating | typeof ScribbleBrushing | typeof Translating)[];
static children: () => (typeof Brushing | typeof Crop | typeof Cropping | typeof DraggingHandle | typeof EditingShape | typeof Idle_11 | typeof PointingArrowLabel | typeof PointingCanvas | typeof PointingCropHandle | typeof PointingHandle | typeof PointingResizeHandle | typeof PointingRotateHandle | typeof PointingSelection | typeof PointingShape | typeof Resizing | typeof Rotating | typeof ScribbleBrushing | typeof Translating)[];
// (undocumented)
static id: string;
// (undocumented)

View file

@ -1127,50 +1127,6 @@
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "@tldraw/tldraw!ArrowShapeUtil#onHandleDragStart:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "onHandleDragStart: "
},
{
"kind": "Reference",
"text": "TLOnHandleDragStartHandler",
"canonicalReference": "@tldraw/editor!TLOnHandleDragStartHandler:type"
},
{
"kind": "Content",
"text": "<"
},
{
"kind": "Reference",
"text": "TLArrowShape",
"canonicalReference": "@tldraw/tlschema!TLArrowShape:type"
},
{
"kind": "Content",
"text": ">"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": false,
"releaseTag": "Public",
"name": "onHandleDragStart",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 5
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{
"kind": "Property",
"canonicalReference": "@tldraw/tldraw!ArrowShapeUtil#onResize:member",
@ -12681,6 +12637,15 @@
"kind": "Content",
"text": " | typeof "
},
{
"kind": "Reference",
"text": "PointingArrowLabel",
"canonicalReference": "@tldraw/tldraw!~PointingArrowLabel:class"
},
{
"kind": "Content",
"text": " | typeof "
},
{
"kind": "Reference",
"text": "PointingCanvas",
@ -12791,7 +12756,7 @@
"name": "children",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 36
"endIndex": 38
},
"isStatic": true,
"isProtected": false,

View file

@ -18,7 +18,6 @@ import {
TLHandle,
TLOnEditEndHandler,
TLOnHandleDragHandler,
TLOnHandleDragStartHandler,
TLOnResizeHandler,
TLOnTranslateHandler,
TLOnTranslateStartHandler,
@ -28,10 +27,7 @@ import {
Vec,
arrowShapeMigrations,
arrowShapeProps,
clockwiseAngleDist,
counterClockwiseAngleDist,
deepCopy,
featureFlags,
getArrowTerminalsInArrowSpace,
getDefaultColorTheme,
mapObjectMapValues,
@ -64,7 +60,6 @@ let globalRenderIndex = 0
enum ARROW_HANDLES {
START = 'start',
MIDDLE = 'middle',
LABEL = 'middle-text',
END = 'end',
}
@ -150,11 +145,6 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
override getHandles(shape: TLArrowShape): TLHandle[] {
const info = this.editor.getArrowInfo(shape)!
const hasText = shape.props.text.trim()
const labelGeometry = hasText
? (this.editor.getShapeGeometry<Group2d>(shape).children[1] as Rectangle2d)
: null
return [
{
id: ARROW_HANDLES.START,
@ -172,17 +162,6 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
y: info.middle.y,
canBind: false,
},
featureFlags.canMoveArrowLabel.get() &&
labelGeometry && {
id: ARROW_HANDLES.LABEL,
type: 'text-adjust',
index: 'a4',
x: labelGeometry.x,
y: labelGeometry.y,
w: labelGeometry.w,
h: labelGeometry.h,
canBind: false,
},
{
id: ARROW_HANDLES.END,
type: 'vertex',
@ -194,19 +173,6 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
].filter(Boolean) as TLHandle[]
}
private _labelDragOffset = new Vec(0, 0)
override onHandleDragStart: TLOnHandleDragStartHandler<TLArrowShape> = (shape) => {
const geometry = this.editor.getShapeGeometry<Group2d>(shape)
const labelGeometry = geometry.children[1] as Rectangle2d
if (labelGeometry) {
const pointInShapeSpace = this.editor.getPointInShapeSpace(
shape,
this.editor.inputs.currentPagePoint
)
this._labelDragOffset = Vec.Sub(labelGeometry.center, pointInShapeSpace)
}
}
override onHandleDrag: TLOnHandleDragHandler<TLArrowShape> = (shape, { handle, isPrecise }) => {
const handleId = handle.id as ARROW_HANDLES
@ -227,42 +193,6 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
return { id: shape.id, type: shape.type, props: { bend } }
}
// This is for moving the text label to a different position on the arrow.
if (handleId === ARROW_HANDLES.LABEL) {
const next = deepCopy(shape) as TLArrowShape
const info = this.editor.getArrowInfo(shape)!
const geometry = this.editor.getShapeGeometry<Group2d>(shape)
const lineGeometry = geometry.children[0] as Geometry2d
const pointInShapeSpace = this.editor.getPointInShapeSpace(
shape,
this.editor.inputs.currentPagePoint
)
const nearestPoint = lineGeometry.nearestPoint(
Vec.Add(pointInShapeSpace, this._labelDragOffset)
)
let nextLabelPosition
if (info.isStraight) {
const lineLength = Vec.Dist(info.start.point, info.end.point)
const segmentLength = Vec.Dist(info.end.point, nearestPoint)
nextLabelPosition = 1 - segmentLength / lineLength
} else {
const isClockwise = shape.props.bend < 0
const distFn = isClockwise ? clockwiseAngleDist : counterClockwiseAngleDist
const angleCenterNearestPoint = Vec.Angle(info.handleArc.center, nearestPoint)
const angleCenterStart = Vec.Angle(info.handleArc.center, info.start.point)
const angleCenterEnd = Vec.Angle(info.handleArc.center, info.end.point)
const arcLength = distFn(angleCenterStart, angleCenterEnd)
const segmentLength = distFn(angleCenterNearestPoint, angleCenterEnd)
nextLabelPosition = 1 - segmentLength / arcLength
}
next.props.labelPosition = nextLabelPosition
return next
}
// Start or end, pointing the arrow...
const next = deepCopy(shape) as TLArrowShape

View file

@ -268,14 +268,28 @@ export function getArrowLabelPosition(editor: Editor, shape: TLArrowShape) {
const debugGeom: Geometry2d[] = []
const info = editor.getArrowInfo(shape)!
const hasStartArrowhead = info.start.arrowhead !== 'none'
const hasEndArrowhead = info.end.arrowhead !== 'none'
if (info.isStraight) {
const range = getStraightArrowLabelRange(editor, shape, info)
const clampedPosition = clamp(shape.props.labelPosition, range.start, range.end)
let clampedPosition = clamp(
shape.props.labelPosition,
hasStartArrowhead ? range.start : 0,
hasEndArrowhead ? range.end : 1
)
// This makes the position snap in the middle.
clampedPosition = clampedPosition >= 0.48 && clampedPosition <= 0.52 ? 0.5 : clampedPosition
labelCenter = Vec.Lrp(info.start.point, info.end.point, clampedPosition)
} else {
const range = getCurvedArrowLabelRange(editor, shape, info)
if (range.dbg) debugGeom.push(...range.dbg)
const clampedPosition = clamp(shape.props.labelPosition, range.start, range.end)
let clampedPosition = clamp(
shape.props.labelPosition,
hasStartArrowhead ? range.start : 0,
hasEndArrowhead ? range.end : 1
)
// This makes the position snap in the middle.
clampedPosition = clampedPosition >= 0.48 && clampedPosition <= 0.52 ? 0.5 : clampedPosition
const labelAngle = interpolateArcAngles(
Vec.Angle(info.bodyArc.center, info.start.point),
Vec.Angle(info.bodyArc.center, info.end.point),

View file

@ -5,6 +5,7 @@ import { Cropping } from './childStates/Cropping'
import { DraggingHandle } from './childStates/DraggingHandle'
import { EditingShape } from './childStates/EditingShape'
import { Idle } from './childStates/Idle'
import { PointingArrowLabel } from './childStates/PointingArrowLabel'
import { PointingCanvas } from './childStates/PointingCanvas'
import { PointingCropHandle } from './childStates/PointingCropHandle'
import { PointingHandle } from './childStates/PointingHandle'
@ -39,6 +40,7 @@ export class SelectTool extends StateNode {
Resizing,
Rotating,
PointingRotateHandle,
PointingArrowLabel,
PointingHandle,
DraggingHandle,
]

View file

@ -11,7 +11,6 @@ import {
TLPointerEventInfo,
TLShapeId,
TLShapePartial,
TLUnknownShape,
Vec,
deepCopy,
snapAngle,
@ -110,15 +109,6 @@ export class DraggingHandle extends StateNode {
}
// -->
const util = this.editor.getShapeUtil(shape)
const changes = util.onHandleDragStart?.(shape)
const next: TLShapePartial<any> = { ...shape, ...changes }
if (changes) {
this.editor.updateShapes([next], { squashing: true })
}
this.update()
this.editor.select(this.shapeId)
@ -180,18 +170,6 @@ export class DraggingHandle extends StateNode {
this.editor.setHintingShapes([])
this.editor.snaps.clear()
const { editor, shapeId } = this
const shape = editor.getShape(shapeId) as TLArrowShape | (TLUnknownShape & TLArrowShape)
if (shape) {
const util = this.editor.getShapeUtil(shape)
const changes = util.onHandleDragEnd?.(shape)
const next: TLShapePartial<any> = { ...shape, ...changes }
if (changes) {
this.editor.updateShapes([next], { squashing: true })
}
}
this.editor.updateInstanceState(
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }

View file

@ -1,7 +1,9 @@
import {
Editor,
Group2d,
HIT_TEST_MARGIN,
StateNode,
TLArrowShape,
TLClickEventInfo,
TLEventHandlers,
TLGroupShape,
@ -90,7 +92,25 @@ export class Idle extends StateNode {
break
}
case 'shape': {
if (this.editor.isShapeOrAncestorLocked(info.shape)) {
const { shape } = info
const pointInShapeSpace = this.editor.getPointInShapeSpace(
shape,
this.editor.inputs.currentPagePoint
)
// todo: Extract into general hit test for arrows
if (this.editor.isShapeOfType<TLArrowShape>(shape, 'arrow')) {
// How should we handle multiple labels? Do shapes ever have multiple labels?
const labelGeometry = this.editor.getShapeGeometry<Group2d>(shape).children[1]
// Knowing what we know about arrows... if the shape has no text in its label,
// then the label geometry should not be there.
if (labelGeometry && pointInPolygon(pointInShapeSpace, labelGeometry.vertices)) {
// We're moving the label on a shape.
this.parent.transition('pointing_arrow_label', info)
break
}
}
if (this.editor.isShapeOrAncestorLocked(shape)) {
this.parent.transition('pointing_canvas', info)
break
}

View file

@ -0,0 +1,143 @@
import {
Arc2d,
Geometry2d,
Group2d,
StateNode,
TLArrowShape,
TLEventHandlers,
TLPointerEventInfo,
TLShapeId,
Vec,
getPointInArcT,
} from '@tldraw/editor'
export class PointingArrowLabel extends StateNode {
static override id = 'pointing_arrow_label'
shapeId = '' as TLShapeId
markId = ''
private info = {} as TLPointerEventInfo & {
shape: TLArrowShape
onInteractionEnd?: string
isCreating: boolean
}
private updateCursor() {
this.editor.updateInstanceState({
cursor: {
type: 'grabbing',
rotation: 0,
},
})
}
override onEnter = (
info: TLPointerEventInfo & {
shape: TLArrowShape
onInteractionEnd?: string
isCreating: boolean
}
) => {
const { shape } = info
this.parent.setCurrentToolIdMask(info.onInteractionEnd)
this.info = info
this.shapeId = shape.id
this.updateCursor()
const geometry = this.editor.getShapeGeometry<Group2d>(shape)
const labelGeometry = geometry.children[1]
if (!labelGeometry) {
throw Error(`Expected to find an arrow label geometry for shape: ${shape.id}`)
}
const { currentPagePoint } = this.editor.inputs
const pointInShapeSpace = this.editor.getPointInShapeSpace(shape, currentPagePoint)
this._labelDragOffset = Vec.Sub(labelGeometry.center, pointInShapeSpace)
this.markId = 'label-drag start'
this.editor.mark(this.markId)
this.editor.setSelectedShapes([this.shapeId])
}
override onExit = () => {
this.parent.setCurrentToolIdMask(undefined)
this.editor.updateInstanceState(
{ cursor: { type: 'default', rotation: 0 } },
{ ephemeral: true }
)
}
private _labelDragOffset = new Vec(0, 0)
override onPointerMove = () => {
const { isDragging } = this.editor.inputs
if (!isDragging) return
const shape = this.editor.getShape<TLArrowShape>(this.shapeId)
if (!shape) return
const info = this.editor.getArrowInfo(shape)!
const groupGeometry = this.editor.getShapeGeometry<Group2d>(shape)
const bodyGeometry = groupGeometry.children[0] as Geometry2d
const pointInShapeSpace = this.editor.getPointInShapeSpace(
shape,
this.editor.inputs.currentPagePoint
)
const nearestPoint = bodyGeometry.nearestPoint(
Vec.Add(pointInShapeSpace, this._labelDragOffset)
)
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)
nextLabelPosition = 1 - segmentLength / lineLength
} else {
const { _center, measure, angleEnd, angleStart } = groupGeometry.children[0] as Arc2d
nextLabelPosition = getPointInArcT(measure, angleStart, angleEnd, _center.angle(nearestPoint))
}
this.editor.updateShape<TLArrowShape>(
{ id: shape.id, type: shape.type, props: { labelPosition: nextLabelPosition } },
{ squashing: true }
)
}
override onPointerUp = () => {
this.complete()
}
override onCancel: TLEventHandlers['onCancel'] = () => {
this.cancel()
}
override onComplete: TLEventHandlers['onComplete'] = () => {
this.cancel()
}
override onInterrupt = () => {
this.cancel()
}
private complete() {
if (this.info.onInteractionEnd) {
this.editor.setCurrentTool(this.info.onInteractionEnd, {})
} else {
this.parent.transition('idle')
}
}
private cancel() {
this.editor.bailToMark(this.markId)
if (this.info.onInteractionEnd) {
this.editor.setCurrentTool(this.info.onInteractionEnd, {})
} else {
this.parent.transition('idle')
}
}
}

View file

@ -7,6 +7,7 @@ const ids = {
box1: createShapeId('box1'),
line1: createShapeId('line1'),
embed1: createShapeId('embed1'),
arrow1: createShapeId('arrow1'),
}
jest.useFakeTimers()
@ -166,6 +167,56 @@ describe('DraggingHandle', () => {
})
})
describe('PointingLabel', () => {
it('Enters from pointing_arrow_label and exits to idle', () => {
editor.createShapes([
{ id: ids.arrow1, type: 'arrow', x: 100, y: 100, props: { text: 'Test Label' } },
])
const shape = editor.getShape(ids.arrow1)
editor.pointerDown(150, 150, {
target: 'shape',
shape,
})
editor.pointerMove(100, 100)
editor.expectToBeIn('select.pointing_arrow_label')
editor.pointerUp()
editor.expectToBeIn('select.idle')
})
it('Bails on escape', () => {
editor.createShapes([
{ id: ids.arrow1, type: 'arrow', x: 100, y: 100, props: { text: 'Test Label' } },
])
const shape = editor.getShape(ids.arrow1)
editor.pointerDown(150, 150, {
target: 'shape',
shape,
})
editor.pointerMove(100, 100)
editor.expectToBeIn('select.pointing_arrow_label')
editor.cancel()
editor.expectToBeIn('select.idle')
})
it('Doesnt go into pointing_arrow_label mode if not selecting the arrow shape', () => {
editor.createShapes([
{ id: ids.arrow1, type: 'arrow', x: 100, y: 100, props: { text: 'Test Label' } },
])
const shape = editor.getShape(ids.arrow1)
editor.pointerDown(0, 150, {
target: 'shape',
shape,
})
editor.pointerMove(100, 100)
editor.expectToBeIn('select.translating')
editor.pointerUp()
editor.expectToBeIn('select.idle')
})
})
describe('When double clicking a shape', () => {
it('begins editing a geo shapes label', () => {
editor

View file

@ -960,16 +960,12 @@ export interface TLHandle {
canBind?: boolean;
// (undocumented)
canSnap?: boolean;
// (undocumented)
h?: number;
id: string;
// (undocumented)
index: string;
// (undocumented)
type: TLHandleType;
// (undocumented)
w?: number;
// (undocumented)
x: number;
// (undocumented)
y: number;

View file

@ -6027,33 +6027,6 @@
"endIndex": 2
}
},
{
"kind": "PropertySignature",
"canonicalReference": "@tldraw/tlschema!TLHandle#h:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "h?: "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": true,
"releaseTag": "Public",
"name": "h",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
},
{
"kind": "PropertySignature",
"canonicalReference": "@tldraw/tlschema!TLHandle#id:member",
@ -6136,33 +6109,6 @@
"endIndex": 2
}
},
{
"kind": "PropertySignature",
"canonicalReference": "@tldraw/tlschema!TLHandle#w:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "w?: "
},
{
"kind": "Content",
"text": "number"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": true,
"releaseTag": "Public",
"name": "w",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
},
{
"kind": "PropertySignature",
"canonicalReference": "@tldraw/tlschema!TLHandle#x:member",

View file

@ -5,7 +5,7 @@ import { SetValue } from '../util-types'
* The handle types used by tldraw's default shapes.
*
* @public */
export const TL_HANDLE_TYPES = new Set(['vertex', 'virtual', 'create', 'text-adjust'] as const)
export const TL_HANDLE_TYPES = new Set(['vertex', 'virtual', 'create'] as const)
/**
* A type for the handle types used by tldraw's default shapes.
@ -27,8 +27,6 @@ export interface TLHandle {
index: string
x: number
y: number
w?: number
h?: number
}
/** @internal */
@ -40,6 +38,4 @@ export const handleValidator: T.Validator<TLHandle> = T.object({
index: T.string,
x: T.number,
y: T.number,
w: T.optional(T.number),
h: T.optional(T.number),
})