kopia lustrzana https://github.com/Tldraw/Tldraw
Initial implementation.
rodzic
c6ba621c11
commit
e54b7ce1a7
|
@ -0,0 +1,152 @@
|
|||
import { Box, TLGeoShape, Vec, createShapeId, track, useEditor } from 'tldraw'
|
||||
import { OrgArrowShape } from './OrgChartArrowShape'
|
||||
|
||||
type ButtonProps = {
|
||||
position: 'left' | 'right' | 'top' | 'bottom'
|
||||
screenBounds: Box
|
||||
pageBounds: Box
|
||||
shape: TLGeoShape
|
||||
}
|
||||
|
||||
const BUTTON_DIMENSION = 24
|
||||
const MARGIN = 10
|
||||
|
||||
function getPosition(
|
||||
position: ButtonProps['position'],
|
||||
bounds: Box,
|
||||
width: number,
|
||||
height: number,
|
||||
margin: number
|
||||
) {
|
||||
switch (position) {
|
||||
case 'left':
|
||||
return {
|
||||
x: bounds.x - width - margin,
|
||||
y: bounds.y + bounds.height / 2 - height / 2,
|
||||
}
|
||||
case 'right':
|
||||
return {
|
||||
x: bounds.maxX + margin,
|
||||
y: bounds.y + bounds.height / 2 - height / 2,
|
||||
}
|
||||
case 'top': {
|
||||
return {
|
||||
x: bounds.x + bounds.width / 2 - width / 2,
|
||||
y: bounds.y - margin - height,
|
||||
}
|
||||
}
|
||||
case 'bottom': {
|
||||
return {
|
||||
x: bounds.x + bounds.width / 2 - width / 2,
|
||||
y: bounds.y + bounds.height + margin,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ExtendButton({ screenBounds, pageBounds, position, shape }: ButtonProps) {
|
||||
const editor = useEditor()
|
||||
const { x, y } = getPosition(position, screenBounds, BUTTON_DIMENSION, BUTTON_DIMENSION, MARGIN)
|
||||
|
||||
function handleClick(position: ButtonProps['position']) {
|
||||
const id = createShapeId()
|
||||
const { x, y } = getPosition(position, pageBounds, pageBounds.width, pageBounds.height, 200)
|
||||
editor.batch(() => {
|
||||
editor.createShape({
|
||||
id,
|
||||
type: shape.type,
|
||||
x,
|
||||
y,
|
||||
props: {
|
||||
...shape.props,
|
||||
},
|
||||
})
|
||||
|
||||
const arrowId = createShapeId()
|
||||
editor.createShape<OrgArrowShape>({
|
||||
id: arrowId,
|
||||
type: 'org-arrow',
|
||||
isLocked: true,
|
||||
})
|
||||
editor.sendToBack([arrowId])
|
||||
editor.createBinding({
|
||||
type: 'org-arrow',
|
||||
fromId: arrowId,
|
||||
toId: shape.id,
|
||||
})
|
||||
editor.createBinding({
|
||||
type: 'org-arrow',
|
||||
fromId: arrowId,
|
||||
toId: id,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => handleClick(position)}
|
||||
style={{
|
||||
pointerEvents: 'all',
|
||||
position: 'absolute',
|
||||
background: 'white',
|
||||
left: x,
|
||||
top: y,
|
||||
width: BUTTON_DIMENSION,
|
||||
height: BUTTON_DIMENSION,
|
||||
border: '1px solid black',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
+
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const InFrontOfTheCanvas = track(function InFrontOfTheCanvas() {
|
||||
const editor = useEditor()
|
||||
const onlySelectedShape = editor.getOnlySelectedShape()
|
||||
if (!onlySelectedShape || onlySelectedShape.type !== 'geo') return null
|
||||
const geoShape = onlySelectedShape as TLGeoShape
|
||||
const bounds = editor.getShapePageBounds(onlySelectedShape)
|
||||
if (!bounds) return null
|
||||
const openMenus = editor.getOpenMenus()
|
||||
if (openMenus.length) return null
|
||||
const isTranslating = editor.isIn('select.translating')
|
||||
if (isTranslating) return null
|
||||
|
||||
const topLeft = editor.pageToViewport(new Vec(bounds.minX, bounds.minY))
|
||||
const bottomRight = editor.pageToViewport(new Vec(bounds.maxX, bounds.maxY))
|
||||
const screenBounds = Box.FromPoints([topLeft, bottomRight])
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExtendButton
|
||||
position="left"
|
||||
screenBounds={screenBounds}
|
||||
pageBounds={bounds}
|
||||
shape={geoShape}
|
||||
/>
|
||||
<ExtendButton
|
||||
position="right"
|
||||
screenBounds={screenBounds}
|
||||
pageBounds={bounds}
|
||||
shape={geoShape}
|
||||
/>
|
||||
<ExtendButton
|
||||
position="top"
|
||||
screenBounds={screenBounds}
|
||||
pageBounds={bounds}
|
||||
shape={geoShape}
|
||||
/>
|
||||
<ExtendButton
|
||||
position="bottom"
|
||||
screenBounds={screenBounds}
|
||||
pageBounds={bounds}
|
||||
shape={geoShape}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
|
@ -0,0 +1,26 @@
|
|||
import { BindingOnShapeDeleteOptions, BindingUtil, TLBaseBinding } from 'tldraw'
|
||||
|
||||
export type OrgArrowBinding = TLBaseBinding<'org-arrow', {}>
|
||||
export class OrgArrowBindingUtil extends BindingUtil<OrgArrowBinding> {
|
||||
static override type = 'org-arrow' as const
|
||||
|
||||
override getDefaultProps() {
|
||||
return {}
|
||||
}
|
||||
|
||||
override onBeforeDeleteToShape(options: BindingOnShapeDeleteOptions<OrgArrowBinding>): void {
|
||||
const arrowId = options.binding.fromId
|
||||
this.editor.batch(() => {
|
||||
const otherBinding = this.editor
|
||||
.getBindingsFromShape(arrowId, 'org-arrow')
|
||||
.filter((b) => b.id !== options.binding.id)
|
||||
this.editor.updateShape({
|
||||
id: arrowId,
|
||||
type: 'org-arrow',
|
||||
isLocked: false,
|
||||
})
|
||||
this.editor.deleteShape(arrowId)
|
||||
this.editor.deleteBindings(otherBinding)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
import { StateNode, TLEventHandlers, TLShapeId, createShapeId } from 'tldraw'
|
||||
import { OrgArrowShape } from './OrgChartArrowShape'
|
||||
|
||||
export class OrgArrowtool extends StateNode {
|
||||
static override id = 'org-arrow'
|
||||
fromId: TLShapeId | undefined = undefined
|
||||
|
||||
override onEnter = () => {
|
||||
this.fromId = undefined
|
||||
this.editor.setCursor({ type: 'cross', rotation: 0 })
|
||||
}
|
||||
|
||||
override onPointerDown: TLEventHandlers['onPointerDown'] = (_info) => {
|
||||
const { currentPagePoint } = this.editor.inputs
|
||||
this.fromId = this.editor.getShapeAtPoint(currentPagePoint)?.id
|
||||
}
|
||||
|
||||
override onPointerUp: TLEventHandlers['onPointerDown'] = (_info) => {
|
||||
if (!this.fromId) return
|
||||
const { currentPagePoint } = this.editor.inputs
|
||||
const shapeUnderPoint = this.editor.getShapeAtPoint(currentPagePoint)
|
||||
|
||||
if (!shapeUnderPoint || shapeUnderPoint.id === this.fromId) return
|
||||
|
||||
const boundsFrom = this.editor.getShapePageBounds(this.fromId)
|
||||
const boundsTo = this.editor.getShapePageBounds(shapeUnderPoint.id)
|
||||
if (!boundsFrom || !boundsTo) return
|
||||
|
||||
const arrowId = createShapeId()
|
||||
this.editor.batch(() => {
|
||||
this.editor.createShape<OrgArrowShape>({
|
||||
id: arrowId,
|
||||
type: 'org-arrow',
|
||||
isLocked: true,
|
||||
})
|
||||
this.editor.sendToBack([arrowId])
|
||||
this.editor.createBinding({
|
||||
type: 'org-arrow',
|
||||
fromId: arrowId,
|
||||
toId: this.fromId,
|
||||
})
|
||||
this.editor.createBinding({
|
||||
type: 'org-arrow',
|
||||
fromId: arrowId,
|
||||
toId: shapeUnderPoint.id,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
import {
|
||||
DefaultToolbar,
|
||||
DefaultToolbarContent,
|
||||
TLComponents,
|
||||
TLUiOverrides,
|
||||
Tldraw,
|
||||
TldrawUiMenuItem,
|
||||
useIsToolSelected,
|
||||
useTools,
|
||||
} from 'tldraw'
|
||||
import { InFrontOfTheCanvas } from './InFrontOfCanvas'
|
||||
import { OrgArrowBindingUtil } from './OrgArrowBinding'
|
||||
import { OrgArrowtool } from './OrgArrowTool'
|
||||
import { OrgArrowUtil } from './OrgChartArrowUtil'
|
||||
|
||||
const overrides: TLUiOverrides = {
|
||||
tools(editor, schema) {
|
||||
schema['org-arrow'] = {
|
||||
id: 'org-arrow',
|
||||
label: 'Org arrow',
|
||||
icon: 'tool-arrow',
|
||||
kbd: 'o',
|
||||
onSelect: () => {
|
||||
editor.setCurrentTool('org-arrow')
|
||||
},
|
||||
}
|
||||
return schema
|
||||
},
|
||||
}
|
||||
|
||||
const components: TLComponents = {
|
||||
Toolbar: (...props) => {
|
||||
const orgArrow = useTools()['org-arrow']
|
||||
const isOrgArrowSelected = useIsToolSelected(orgArrow)
|
||||
return (
|
||||
<DefaultToolbar {...props}>
|
||||
<TldrawUiMenuItem {...orgArrow} isSelected={isOrgArrowSelected} />
|
||||
<DefaultToolbarContent />
|
||||
</DefaultToolbar>
|
||||
)
|
||||
},
|
||||
InFrontOfTheCanvas: InFrontOfTheCanvas,
|
||||
}
|
||||
|
||||
export default function OrgArrowExample() {
|
||||
return (
|
||||
<div className="tldraw__editor">
|
||||
<Tldraw
|
||||
onMount={(editor) => {
|
||||
;(window as any).editor = editor
|
||||
}}
|
||||
shapeUtils={[OrgArrowUtil]}
|
||||
bindingUtils={[OrgArrowBindingUtil]}
|
||||
tools={[OrgArrowtool]}
|
||||
overrides={overrides}
|
||||
components={components}
|
||||
persistenceKey="org-arrow"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
import { TLBaseShape } from 'tldraw'
|
||||
|
||||
export type OrgArrowShape = TLBaseShape<'org-arrow', {}>
|
|
@ -0,0 +1,153 @@
|
|||
import {
|
||||
Box,
|
||||
HTMLContainer,
|
||||
RecordProps,
|
||||
Rectangle2d,
|
||||
SVGContainer,
|
||||
ShapeUtil,
|
||||
T,
|
||||
Vec,
|
||||
} from 'tldraw'
|
||||
import { OrgArrowShape } from './OrgChartArrowShape'
|
||||
|
||||
export class OrgArrowUtil extends ShapeUtil<OrgArrowShape> {
|
||||
static override type = 'org-arrow' as const
|
||||
static override props: RecordProps<OrgArrowShape> = {
|
||||
position: T.literalEnum('left', 'right', 'top', 'bottom'),
|
||||
}
|
||||
|
||||
override getDefaultProps() {
|
||||
return {
|
||||
position: 'left' as const,
|
||||
}
|
||||
}
|
||||
|
||||
override canBind = () => false
|
||||
override canEdit = () => false
|
||||
override canResize = () => false
|
||||
|
||||
override hideRotateHandle = () => true
|
||||
override isAspectRatioLocked = () => true
|
||||
|
||||
override getGeometry(shape: OrgArrowShape) {
|
||||
const r = new Rectangle2d({ x: 0, y: 0, width: 0, height: 0, isFilled: true })
|
||||
const bindings = this.editor.getBindingsFromShape(shape, 'org-arrow')
|
||||
if (bindings.length < 2) return r
|
||||
|
||||
const from = this.editor.getShapePageBounds(bindings[0].toId)
|
||||
const to = this.editor.getShapePageBounds(bindings[1].toId)
|
||||
if (!from || !to) return r
|
||||
const bounds = Box.Common([from, to])
|
||||
return new Rectangle2d({
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
isFilled: true,
|
||||
})
|
||||
}
|
||||
getArrowPath(x1: number, y1: number, x2: number, y2: number) {
|
||||
return `M${x1},${y1}L${x2},${y2}`
|
||||
}
|
||||
|
||||
override component(shape: OrgArrowShape) {
|
||||
const bindings = this.editor.getBindingsFromShape(shape, 'org-arrow')
|
||||
if (bindings.length < 2) return
|
||||
|
||||
const from = this.editor.getShapePageBounds(bindings[0].toId)
|
||||
const to = this.editor.getShapePageBounds(bindings[1].toId)
|
||||
|
||||
let firstPath: string
|
||||
let secondPath: string
|
||||
let thirdPath: string | null = null
|
||||
if (!from || !to) return null
|
||||
|
||||
const breakBeforeCenterX = from.center.x > to.minX && from.center.x < to.maxX
|
||||
const breakBeforeCenterY = from.center.y > to.minY && from.center.y < to.maxY
|
||||
const diffX = Math.abs(from.center.x - to.center.x)
|
||||
const diffY = Math.abs(from.center.y - to.center.y)
|
||||
const preferVertical = diffY > diffX
|
||||
const verticalFirst = (preferVertical || breakBeforeCenterX) && !breakBeforeCenterY
|
||||
if (verticalFirst) {
|
||||
const startBelow = from.center.y < to.center.y
|
||||
const endRight = from.center.x > to.center.x
|
||||
if (breakBeforeCenterX) {
|
||||
const firstElbow = new Vec(from.center.x, from.minY + (to.maxY - from.minY) / 2)
|
||||
firstPath = this.getArrowPath(
|
||||
from.center.x,
|
||||
startBelow ? from.maxY : from.y,
|
||||
firstElbow.x,
|
||||
firstElbow.y
|
||||
)
|
||||
secondPath = this.getArrowPath(firstElbow.x, firstElbow.y, to.center.x, firstElbow.y)
|
||||
thirdPath = this.getArrowPath(
|
||||
to.center.x,
|
||||
firstElbow.y,
|
||||
to.center.x,
|
||||
startBelow ? to.minY : to.maxY
|
||||
)
|
||||
} else {
|
||||
const halfWay = new Vec(from.center.x, to.y + to.height / 2)
|
||||
firstPath = this.getArrowPath(
|
||||
from.center.x,
|
||||
startBelow ? from.maxY : from.y,
|
||||
halfWay.x,
|
||||
halfWay.y
|
||||
)
|
||||
secondPath = this.getArrowPath(
|
||||
halfWay.x,
|
||||
halfWay.y,
|
||||
endRight ? to.maxX : to.minX,
|
||||
halfWay.y
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const startRight = from.center.x < to.center.x
|
||||
const endEndBelow = from.center.y > to.center.y
|
||||
if (breakBeforeCenterY) {
|
||||
const firstElbow = new Vec(from.minX + (to.maxX - from.minX) / 2, from.y + from.height / 2)
|
||||
firstPath = this.getArrowPath(
|
||||
startRight ? from.maxX : from.minX,
|
||||
from.y + from.height / 2,
|
||||
firstElbow.x,
|
||||
firstElbow.y
|
||||
)
|
||||
secondPath = this.getArrowPath(firstElbow.x, firstElbow.y, firstElbow.x, to.center.y)
|
||||
thirdPath = this.getArrowPath(
|
||||
firstElbow.x,
|
||||
to.center.y,
|
||||
startRight ? to.minX : to.maxX,
|
||||
to.center.y
|
||||
)
|
||||
} else {
|
||||
const halfWay = new Vec(to.x + to.width / 2, from.y + from.height / 2)
|
||||
firstPath = this.getArrowPath(
|
||||
startRight ? from.maxX : from.minX,
|
||||
from.y + from.height / 2,
|
||||
halfWay.x,
|
||||
halfWay.y
|
||||
)
|
||||
secondPath = this.getArrowPath(
|
||||
halfWay.x,
|
||||
halfWay.y,
|
||||
halfWay.x,
|
||||
endEndBelow ? to.maxY : to.minY
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<HTMLContainer>
|
||||
<SVGContainer>
|
||||
{firstPath && <path d={firstPath} stroke="#3182ED" strokeWidth={3} />}
|
||||
<path d={secondPath} stroke="#3182ED" strokeWidth={3} />
|
||||
{thirdPath && <path d={thirdPath} stroke="#3182ED" strokeWidth={3} />}
|
||||
</SVGContainer>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
||||
override indicator() {
|
||||
return null
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
title: Org chart (bindings)
|
||||
component: ./OrgChart.tsx
|
||||
category: shapes/tools
|
||||
---
|
||||
|
||||
An org chart arrow tool, using bindings to attach shapes to one another.
|
||||
|
||||
---
|
Ładowanie…
Reference in New Issue