Adds up and downstream links (#171)
This commit is contained in:
parent
a73cffb139
commit
a7e8fafb96
12 changed files with 218 additions and 115 deletions
|
@ -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<SVGCircleElement>) => {
|
||||
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 (
|
||||
<g cursor="grab">
|
||||
<circle
|
||||
className="tl-transparent"
|
||||
cx={bounds.width / 2}
|
||||
cy={bounds.height + size * 2}
|
||||
r={targetSize}
|
||||
pointerEvents={isHidden ? 'none' : 'all'}
|
||||
onPointerDown={handleClick}
|
||||
/>
|
||||
<path
|
||||
className="tl-rotate-handle"
|
||||
transform={`translate(${bounds.width / 2 - size / 2}, ${bounds.height + size * 1.7})`}
|
||||
d={`M 0,0 L ${size},0 ${size / 2},${size} Z`}
|
||||
pointerEvents="none"
|
||||
opacity={isHidden ? 0 : 1}
|
||||
/>
|
||||
<g
|
||||
cursor="grab"
|
||||
transform={`translate(${bounds.width / 2 - size * 4}, ${bounds.height + size * 2})`}
|
||||
>
|
||||
<g className="tl-transparent" pointerEvents={isHidden ? 'none' : 'all'}>
|
||||
<rect x={0} y={0} width={size * 2} height={size * 2} {...leftEvents} />
|
||||
<rect x={size * 3} y={0} width={size * 2} height={size * 2} {...centerEvents} />
|
||||
<rect x={size * 6} y={0} width={size * 2} height={size * 2} {...rightEvents} />
|
||||
</g>
|
||||
<g className="tl-rotate-handle" transform={`translate(${size / 2}, ${size / 2})`}>
|
||||
<path
|
||||
d={`M 0,${size / 2} L ${size},${size} ${size},0 Z`}
|
||||
pointerEvents="none"
|
||||
opacity={isHidden ? 0 : 1}
|
||||
/>
|
||||
<path
|
||||
transform={`translate(${size * 3}, 0)`}
|
||||
d={`M 0,0 L ${size},0 ${size / 2},${size} Z`}
|
||||
pointerEvents="none"
|
||||
opacity={isHidden ? 0 : 1}
|
||||
/>
|
||||
<path
|
||||
transform={`translate(${size * 6}, 0)`}
|
||||
d={`M ${size},${size / 2} L 0,0 0,${size} Z`}
|
||||
pointerEvents="none"
|
||||
opacity={isHidden ? 0 : 1}
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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<TLBoundsCorner | TLBoundsEdge | 'rotate'>,
|
||||
info: TLPointerInfo<TLBoundsCorner | TLBoundsEdge | 'rotate' | 'center' | 'left' | 'right'>,
|
||||
e: React.PointerEvent
|
||||
) => void
|
||||
|
||||
|
@ -231,7 +231,6 @@ export interface TLCallbacks<T extends TLShape> {
|
|||
onRenderCountChange: (ids: string[]) => void
|
||||
onError: (error: Error) => void
|
||||
onBoundsChange: (bounds: TLBounds) => void
|
||||
onPointLinkHandle: TLPointerEventHandler
|
||||
|
||||
// Keyboard event handlers
|
||||
onKeyDown: TLKeyboardEventHandler
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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<ArrowShape>(
|
||||
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<string, Patch<ArrowShape>> = Object.fromEntries(
|
||||
ids.map((id) => [
|
||||
id,
|
||||
{
|
||||
decorations: {
|
||||
[decorationId]: TLDR.getShape<ArrowShape>(data, id, currentPageId).decorations?.[
|
||||
decorationId
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
)
|
||||
|
||||
const afterShapes: Record<string, Patch<ArrowShape>> = Object.fromEntries(
|
||||
ids.map((id) => [
|
||||
id,
|
||||
{
|
||||
decorations: {
|
||||
[decorationId]: TLDR.getShape<ArrowShape>(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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
|
|
|
@ -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<string>()
|
||||
|
||||
const checkedIds = new Set<string>()
|
||||
|
||||
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,
|
||||
|
|
|
@ -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<string>(selectedIds)
|
||||
|
||||
const checkedIds = new Set<string>()
|
||||
|
||||
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]
|
||||
|
|
|
@ -2341,9 +2341,6 @@ export class TLDrawState extends StateManager<Data> {
|
|||
|
||||
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) {
|
||||
|
|
|
@ -192,5 +192,4 @@ export abstract class BaseTool<T extends string = any> {
|
|||
// Misc
|
||||
onShapeBlur?: TLShapeBlurHandler
|
||||
onShapeClone?: TLShapeCloneHandler
|
||||
onPointLinkHandle?: TLPointerEventHandler
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
|
|
|
@ -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<Status> {
|
|||
|
||||
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<Status> {
|
|||
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<Status> {
|
|||
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<Status> {
|
|||
|
||||
// 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<Status> {
|
|||
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)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue