Initial implementation.

mitja/org-chart
Mitja Bezenšek 2024-05-09 09:18:53 +02:00
rodzic c6ba621c11
commit e54b7ce1a7
7 zmienionych plików z 453 dodań i 0 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1,3 @@
import { TLBaseShape } from 'tldraw'
export type OrgArrowShape = TLBaseShape<'org-arrow', {}>

Wyświetl plik

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

Wyświetl plik

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