Adds up and downstream links (#171)

This commit is contained in:
Steve Ruiz 2021-10-19 12:19:56 +01:00 committed by GitHub
parent a73cffb139
commit a7e8fafb96
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 218 additions and 115 deletions

View file

@ -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>
)
}

View file

@ -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(

View file

@ -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

View file

@ -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}

View file

@ -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 },
},
},
},

View file

@ -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')
})

View file

@ -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,

View file

@ -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]

View file

@ -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) {

View file

@ -192,5 +192,4 @@ export abstract class BaseTool<T extends string = any> {
// Misc
onShapeBlur?: TLShapeBlurHandler
onShapeClone?: TLShapeCloneHandler
onPointLinkHandle?: TLPointerEventHandler
}

View file

@ -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')
})

View file

@ -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)
}