diff --git a/packages/core/src/components/bounds/link-handle.tsx b/packages/core/src/components/bounds/link-handle.tsx index 2ae98c23f..a28e36289 100644 --- a/packages/core/src/components/bounds/link-handle.tsx +++ b/packages/core/src/components/bounds/link-handle.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { useTLContext } from '+hooks' +import { useBoundsHandleEvents, useTLContext } from '+hooks' import type { TLBounds } from '+types' interface LinkHandleProps { @@ -9,35 +9,40 @@ interface LinkHandleProps { bounds: TLBounds } -export function LinkHandle({ size, bounds, targetSize, isHidden }: LinkHandleProps) { - const { callbacks, inputs } = useTLContext() - - const handleClick = React.useCallback( - (e: React.PointerEvent) => { - e.stopPropagation() - const info = inputs.pointerDown(e, 'link') - callbacks.onPointLinkHandle?.(info, e) - }, - [callbacks.onPointLinkHandle] - ) +export function LinkHandle({ size, bounds, isHidden }: LinkHandleProps) { + const leftEvents = useBoundsHandleEvents('left') + const centerEvents = useBoundsHandleEvents('center') + const rightEvents = useBoundsHandleEvents('right') return ( - - - + + + + + + + + + + + ) } diff --git a/packages/core/src/hooks/useBoundsHandleEvents.tsx b/packages/core/src/hooks/useBoundsHandleEvents.tsx index e2fc63f98..d68b612dd 100644 --- a/packages/core/src/hooks/useBoundsHandleEvents.tsx +++ b/packages/core/src/hooks/useBoundsHandleEvents.tsx @@ -2,7 +2,9 @@ import * as React from 'react' import type { TLBoundsEdge, TLBoundsCorner } from '+types' import { useTLContext } from './useTLContext' -export function useBoundsHandleEvents(id: TLBoundsCorner | TLBoundsEdge | 'rotate') { +export function useBoundsHandleEvents( + id: TLBoundsCorner | TLBoundsEdge | 'rotate' | 'center' | 'left' | 'right' +) { const { callbacks, inputs } = useTLContext() const onPointerDown = React.useCallback( diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index dd055b3c7..a184c9169 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -164,7 +164,7 @@ export type TLCanvasEventHandler = (info: TLPointerInfo<'canvas'>, e: React.Poin export type TLBoundsEventHandler = (info: TLPointerInfo<'bounds'>, e: React.PointerEvent) => void export type TLBoundsHandleEventHandler = ( - info: TLPointerInfo, + info: TLPointerInfo, e: React.PointerEvent ) => void @@ -231,7 +231,6 @@ export interface TLCallbacks { onRenderCountChange: (ids: string[]) => void onError: (error: Error) => void onBoundsChange: (bounds: TLBounds) => void - onPointLinkHandle: TLPointerEventHandler // Keyboard event handlers onKeyDown: TLKeyboardEventHandler diff --git a/packages/tldraw/src/components/tldraw/tldraw.tsx b/packages/tldraw/src/components/tldraw/tldraw.tsx index cb3ba30da..ca33e40cc 100644 --- a/packages/tldraw/src/components/tldraw/tldraw.tsx +++ b/packages/tldraw/src/components/tldraw/tldraw.tsx @@ -276,7 +276,6 @@ function InnerTldraw({ onShapeChange={tlstate.onShapeChange} onShapeBlur={tlstate.onShapeBlur} onShapeClone={tlstate.onShapeClone} - onPointLinkHandle={tlstate.onPointLinkHandle} onBoundsChange={tlstate.updateBounds} onKeyDown={tlstate.onKeyDown} onKeyUp={tlstate.onKeyUp} diff --git a/packages/tldraw/src/state/command/toggle-decoration/toggle-decoration.command.ts b/packages/tldraw/src/state/command/toggle-decoration/toggle-decoration.command.ts index 370513fa7..215c5147b 100644 --- a/packages/tldraw/src/state/command/toggle-decoration/toggle-decoration.command.ts +++ b/packages/tldraw/src/state/command/toggle-decoration/toggle-decoration.command.ts @@ -1,31 +1,41 @@ import { Decoration } from '~types' import type { ArrowShape, TLDrawCommand, Data } from '~types' import { TLDR } from '~state/tldr' +import type { Patch } from 'rko' export function toggleDecoration( data: Data, ids: string[], - handleId: 'start' | 'end' + decorationId: 'start' | 'end' ): TLDrawCommand { const { currentPageId } = data.appState - const { before, after } = TLDR.mutateShapes( - data, - ids, - (shape) => { - const decorations = shape.decorations - ? { - ...shape.decorations, - [handleId]: shape.decorations[handleId] ? undefined : Decoration.Arrow, - } - : { - [handleId]: Decoration.Arrow, - } - return { - decorations, - } - }, - currentPageId + const beforeShapes: Record> = Object.fromEntries( + ids.map((id) => [ + id, + { + decorations: { + [decorationId]: TLDR.getShape(data, id, currentPageId).decorations?.[ + decorationId + ], + }, + }, + ]) + ) + + const afterShapes: Record> = Object.fromEntries( + ids.map((id) => [ + id, + { + decorations: { + [decorationId]: TLDR.getShape(data, id, currentPageId).decorations?.[ + decorationId + ] + ? undefined + : Decoration.Arrow, + }, + }, + ]) ) return { @@ -33,14 +43,14 @@ export function toggleDecoration( before: { document: { pages: { - [currentPageId]: { shapes: before }, + [currentPageId]: { shapes: beforeShapes }, }, }, }, after: { document: { pages: { - [currentPageId]: { shapes: after }, + [currentPageId]: { shapes: afterShapes }, }, }, }, diff --git a/packages/tldraw/src/state/session/sessions/translate/translate.session.spec.ts b/packages/tldraw/src/state/session/sessions/translate/translate.session.spec.ts index 155595e4c..8066022e0 100644 --- a/packages/tldraw/src/state/session/sessions/translate/translate.session.spec.ts +++ b/packages/tldraw/src/state/session/sessions/translate/translate.session.spec.ts @@ -339,5 +339,7 @@ describe('When snapping', () => { }) describe('When translating linked shapes', () => { - it.todo('Translates all shapes that are chain-linked to the selected shapes') + it.todo('translates all linked shapes when center is dragged') + it.todo('translates all upstream linked shapes when left is dragged') + it.todo('translates all downstream linked shapes when right is dragged') }) diff --git a/packages/tldraw/src/state/session/sessions/translate/translate.session.ts b/packages/tldraw/src/state/session/sessions/translate/translate.session.ts index 44e955893..947f03e8a 100644 --- a/packages/tldraw/src/state/session/sessions/translate/translate.session.ts +++ b/packages/tldraw/src/state/session/sessions/translate/translate.session.ts @@ -12,6 +12,7 @@ import { GroupShape, SessionType, ArrowBinding, + TLDrawShapeType, } from '~types' import { SNAP_DISTANCE } from '~state/constants' import { TLDR } from '~state/tldr' @@ -55,9 +56,14 @@ export class TranslateSession implements Session { snapLines: TLSnapLine[] = [] isCloning = false isCreate: boolean - isLinked = false + isLinked: 'left' | 'right' | 'center' | false - constructor(data: Data, point: number[], isCreate = false, isLinked = false) { + constructor( + data: Data, + point: number[], + isCreate = false, + isLinked: 'left' | 'right' | 'center' | false = false + ) { this.origin = point this.snapshot = getTranslateSnapshot(data, isLinked) this.isCreate = isCreate @@ -617,48 +623,17 @@ export class TranslateSession implements Session { } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function getTranslateSnapshot(data: Data, isLinked: boolean) { +export function getTranslateSnapshot( + data: Data, + linkDirection: 'left' | 'right' | 'center' | false +) { const { currentPageId } = data.appState const page = TLDR.getPage(data, currentPageId) const selectedIds = TLDR.getSelectedIds(data, currentPageId) - let ids = selectedIds - - if (isLinked) { - const linkedIds = new Set() - - const checkedIds = new Set() - - const idsToCheck = [...selectedIds] - - const bindings = Object.values(page.bindings) - - while (idsToCheck.length > 0) { - const id = idsToCheck.pop() - - if (!id) break - - checkedIds.add(id) - - bindings.forEach(({ fromId, toId }) => { - if (fromId === id) { - linkedIds.add(fromId) - if (!checkedIds.has(toId)) { - idsToCheck.push(toId) - } - } else if (toId === id) { - linkedIds.add(toId) - if (!checkedIds.has(fromId)) { - idsToCheck.push(fromId) - } - } - }) - - ids = Array.from(linkedIds.values()) - } - } + const ids = linkDirection ? TLDR.getLinkedShapes(data, currentPageId, linkDirection) : selectedIds const selectedShapes = ids.flatMap((id) => TLDR.getShape(data, id, currentPageId)) @@ -699,7 +674,6 @@ export function getTranslateSnapshot(data: Data, isLinked: boolean) { return { selectedIds, - linkedBounds: Utils.getCommonBounds(shapesToMove.map(TLDR.getRotatedBounds)), hasUnlockedShapes, initialParentChildren, idsToMove, diff --git a/packages/tldraw/src/state/tldr.ts b/packages/tldraw/src/state/tldr.ts index c09acd5bc..34bc8065a 100644 --- a/packages/tldraw/src/state/tldr.ts +++ b/packages/tldraw/src/state/tldr.ts @@ -1,6 +1,6 @@ import { TLBounds, TLTransformInfo, Utils, TLPageState } from '@tldraw/core' import { getShapeUtils } from '~shape' -import type { +import { Data, ShapeStyles, ShapesWithProp, @@ -10,6 +10,8 @@ import type { TLDrawCommand, TLDrawPatch, TLDrawShapeUtil, + TLDrawShapeType, + ArrowShape, } from '~types' import { Vec } from '@tldraw/vec' @@ -267,6 +269,109 @@ export class TLDR { }, data) } + static getLinkedShapes( + data: Data, + pageId: string, + direction: 'center' | 'left' | 'right', + includeArrows = true + ) { + const selectedIds = TLDR.getSelectedIds(data, pageId) + + const page = TLDR.getPage(data, pageId) + + const linkedIds = new Set(selectedIds) + + const checkedIds = new Set() + + const idsToCheck = [...selectedIds] + + const arrows = new Set( + Object.values(page.shapes).filter((shape) => { + return ( + shape.type === TLDrawShapeType.Arrow && + (shape.handles.start.bindingId || shape.handles?.end.bindingId) + ) + }) as ArrowShape[] + ) + + while (idsToCheck.length) { + const id = idsToCheck.pop() + + if (!(id && arrows.size)) break + + if (checkedIds.has(id)) continue + + checkedIds.add(id) + + arrows.forEach((arrow) => { + const { + handles: { + start: { bindingId: startBindingId }, + end: { bindingId: endBindingId }, + }, + } = arrow + + const startBinding = startBindingId ? page.bindings[startBindingId] : null + const endBinding = endBindingId ? page.bindings[endBindingId] : null + + let hit = false + + if (startBinding && startBinding.toId === id) { + if (direction === 'center') { + hit = true + } else if (arrow.decorations?.start && endBinding) { + // The arrow is pointing to this shape at its start + hit = direction === 'left' + } else { + // The arrow is pointing away from this shape + hit = direction === 'right' + } + + if (hit) { + // This arrow is bound to this shape + if (includeArrows) linkedIds.add(arrow.id) + linkedIds.add(id) + + if (endBinding) { + linkedIds.add(endBinding.toId) + idsToCheck.push(endBinding.toId) + } + } + } else if (endBinding && endBinding.toId === id) { + // This arrow is bound to this shape at its end + if (direction === 'center') { + hit = true + } else if (arrow.decorations?.end && startBinding) { + // The arrow is pointing to this shape + hit = direction === 'left' + } else { + // The arrow is pointing away from this shape + hit = direction === 'right' + } + + if (hit) { + if (includeArrows) linkedIds.add(arrow.id) + linkedIds.add(id) + + if (startBinding) { + linkedIds.add(startBinding.toId) + idsToCheck.push(startBinding.toId) + } + } + } + + if ( + (!startBinding || linkedIds.has(startBinding.toId)) && + (!endBinding || linkedIds.has(endBinding.toId)) + ) { + arrows.delete(arrow) + } + }) + } + + return Array.from(linkedIds.values()) + } + static getChildIndexAbove(data: Data, id: string, pageId: string): number { const page = data.document.pages[pageId] const shape = page.shapes[id] diff --git a/packages/tldraw/src/state/tlstate.ts b/packages/tldraw/src/state/tlstate.ts index 76d37e638..f0cc8c94a 100644 --- a/packages/tldraw/src/state/tlstate.ts +++ b/packages/tldraw/src/state/tlstate.ts @@ -2341,9 +2341,6 @@ export class TLDrawState extends StateManager { onShapeClone: TLShapeCloneHandler = (info, e) => this.currentTool.onShapeClone?.(info, e) - onPointLinkHandle: TLPointerEventHandler = (info, e) => - this.currentTool.onPointLinkHandle?.(info, e) - onRenderCountChange = (ids: string[]) => { const appState = this.getAppState() if (appState.isEmptyCanvas && ids.length > 0) { diff --git a/packages/tldraw/src/state/tool/BaseTool/BaseTool.ts b/packages/tldraw/src/state/tool/BaseTool/BaseTool.ts index 5d70680a7..0a9763198 100644 --- a/packages/tldraw/src/state/tool/BaseTool/BaseTool.ts +++ b/packages/tldraw/src/state/tool/BaseTool/BaseTool.ts @@ -192,5 +192,4 @@ export abstract class BaseTool { // Misc onShapeBlur?: TLShapeBlurHandler onShapeClone?: TLShapeCloneHandler - onPointLinkHandle?: TLPointerEventHandler } diff --git a/packages/tldraw/src/state/tool/SelectTool/SelectTool.spec.ts b/packages/tldraw/src/state/tool/SelectTool/SelectTool.spec.ts index bd799d8ee..334ccfc54 100644 --- a/packages/tldraw/src/state/tool/SelectTool/SelectTool.spec.ts +++ b/packages/tldraw/src/state/tool/SelectTool/SelectTool.spec.ts @@ -7,3 +7,9 @@ describe('SelectTool', () => { new SelectTool(tlstate) }) }) + +describe('When double clicking link controls', () => { + it.todo('selects all linked shapes when center is double clicked') + it.todo('selects all upstream linked shapes when left is double clicked') + it.todo('selects all downstream linked shapes when right is double clicked') +}) diff --git a/packages/tldraw/src/state/tool/SelectTool/SelectTool.ts b/packages/tldraw/src/state/tool/SelectTool/SelectTool.ts index d079c1c1c..507e4b9bf 100644 --- a/packages/tldraw/src/state/tool/SelectTool/SelectTool.ts +++ b/packages/tldraw/src/state/tool/SelectTool/SelectTool.ts @@ -23,7 +23,6 @@ enum Status { PointingBounds = 'pointingBounds', PointingClone = 'pointingClone', TranslatingClone = 'translatingClone', - PointingLinkHandle = 'pointingLinkHandle', PointingBoundsHandle = 'pointingBoundsHandle', TranslatingHandle = 'translatingHandle', Translating = 'translating', @@ -44,7 +43,9 @@ export class SelectTool extends BaseTool { pointedHandleId?: 'start' | 'end' | 'bend' - pointedBoundsHandle?: TLBoundsCorner | TLBoundsEdge | 'rotate' + pointedBoundsHandle?: TLBoundsCorner | TLBoundsEdge | 'rotate' | 'center' | 'left' | 'right' + + pointedLinkHandleId?: 'left' | 'center' | 'right' /* --------------------- Methods -------------------- */ @@ -248,6 +249,14 @@ export class SelectTool extends BaseTool { this.setStatus(Status.Rotating) this.state.startSession(SessionType.Rotate, point) + } else if ( + this.pointedBoundsHandle === 'center' || + this.pointedBoundsHandle === 'left' || + this.pointedBoundsHandle === 'right' + ) { + this.setStatus(Status.Translating) + const point = this.state.getPagePoint(info.origin) + this.state.startSession(SessionType.Translate, point, false, this.pointedBoundsHandle) } else { // Stat a transform session this.setStatus(Status.Transforming) @@ -277,15 +286,6 @@ export class SelectTool extends BaseTool { return } - if (this.status === Status.PointingLinkHandle) { - if (Vec.dist(info.origin, info.point) > 4) { - this.setStatus(Status.Translating) - const point = this.state.getPagePoint(info.origin) - this.state.startSession(SessionType.Translate, point, false, true) - } - return - } - if (this.status === Status.PointingClone) { if (Vec.dist(info.origin, info.point) > 4) { this.setStatus(Status.TranslatingClone) @@ -437,12 +437,6 @@ export class SelectTool extends BaseTool { // Shape - onPointLinkHandle: TLPointerEventHandler = () => { - if (this.status === Status.Idle) { - this.setStatus(Status.PointingLinkHandle) - } - } - onPointShape: TLPointerEventHandler = (info, e) => { if (info.spaceKey && e.buttons === 1) { return @@ -613,7 +607,18 @@ export class SelectTool extends BaseTool { this.setStatus(Status.PointingBoundsHandle) } - onDoubleClickBoundsHandle: TLBoundsHandleEventHandler = () => { + onDoubleClickBoundsHandle: TLBoundsHandleEventHandler = (info) => { + if (info.target === 'center' || info.target === 'left' || info.target === 'right') { + this.state.select( + ...TLDR.getLinkedShapes( + this.state.state, + this.state.currentPageId, + info.target, + info.shiftKey + ) + ) + } + if (this.state.selectedIds.length === 1) { this.state.resetBounds(this.state.selectedIds) }