add and remove multiple control points per arrows

pull/82/head
maxime.batista 1 year ago
parent df52d758ae
commit 727ab33644

@ -1,19 +1,16 @@
import { import {CSSProperties, ReactElement, RefObject, useCallback, useEffect, useRef, useState,} from "react"
CSSProperties,
ReactElement,
RefObject,
useCallback,
useEffect,
useRef,
useState,
} from "react"
import { import {
add, add,
angle, angle,
between,
distance,
middlePos, middlePos,
minus,
mul,
Pos, Pos,
posWithinBase, posWithinBase,
ratioWithinBase, ratioWithinBase,
relativeTo,
} from "./Pos" } from "./Pos"
import "../../style/bendable_arrows.css" import "../../style/bendable_arrows.css"
@ -57,10 +54,10 @@ function constraintInCircle(pos: Pos, from: Pos, radius: number): Pos {
} }
} }
function Triangle({ fill }: { fill: string }) { function Triangle({fill}: { fill: string }) {
return ( return (
<svg viewBox={"0 0 50 50"} width={20} height={20}> <svg viewBox={"0 0 50 50"} width={20} height={20}>
<polygon points={"50 0, 0 0, 25 40"} fill={fill} /> <polygon points={"50 0, 0 0, 25 40"} fill={fill}/>
</svg> </svg>
) )
} }
@ -75,8 +72,9 @@ export default function BendableArrow({
style, style,
startRadius = 0, startRadius = 0,
endRadius = 0, endRadius = 0,
onDeleteRequested = () => {}, onDeleteRequested = () => {
}: BendableArrowProps) { },
}: BendableArrowProps) {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null) const svgRef = useRef<SVGSVGElement>(null)
const pathRef = useRef<SVGPathElement>(null) const pathRef = useRef<SVGPathElement>(null)
@ -95,7 +93,7 @@ export default function BendableArrow({
const tailRef = useRef<HTMLDivElement>(null) const tailRef = useRef<HTMLDivElement>(null)
function computeControlPoints(parentBase: DOMRect) { function computeControlPoints(parentBase: DOMRect) {
return segments.map(({ next, controlPoint }, i) => { return segments.flatMap(({next, controlPoint}, i) => {
const prev = i == 0 ? startPos : segments[i - 1].next const prev = i == 0 ? startPos : segments[i - 1].next
const prevRelative = posWithinBase(prev, parentBase) const prevRelative = posWithinBase(prev, parentBase)
@ -104,29 +102,28 @@ export default function BendableArrow({
const cpPos = const cpPos =
controlPoint || controlPoint ||
ratioWithinBase( ratioWithinBase(
add( add(between(prevRelative, nextRelative), parentBase),
{
x: prevRelative.x / 2 + nextRelative.x / 2,
y: prevRelative.y / 2 + nextRelative.y / 2,
},
parentBase,
),
parentBase, parentBase,
) )
return ( const setControlPointPos = (newPos: Pos | undefined) => {
<ControlPoint
key={i}
posRatio={cpPos}
parentBase={parentBase}
onPosValidated={(controlPoint) => {
const segment = segments[i] const segment = segments[i]
const newSegments = segments.toSpliced(i, 1, { const newSegments = segments.toSpliced(i, 1, {
...segment, ...segment,
controlPoint, controlPoint: newPos,
}) })
onSegmentsChanges(newSegments) onSegmentsChanges(newSegments)
}} }
return [
// curve control point
<ArrowPoint
key={i}
className={"arrow-point-control"}
posRatio={cpPos}
parentBase={parentBase}
onPosValidated={setControlPointPos}
onRemove={() => setControlPointPos(undefined)}
onMoves={(controlPoint) => { onMoves={(controlPoint) => {
setInternalSegments((is) => { setInternalSegments((is) => {
return is.toSpliced(i, 1, { return is.toSpliced(i, 1, {
@ -135,40 +132,69 @@ export default function BendableArrow({
}) })
}) })
}} }}
/> />,
//next pos point (only if this is not the last segment)
i != segments.length - 1 && <ArrowPoint
className={"arrow-point-next"}
posRatio={next}
parentBase={parentBase}
onPosValidated={next => {
const currentSegment = segments[i]
const newSegments = segments.toSpliced(i, 1, {
...currentSegment,
next,
})
onSegmentsChanges(newSegments)
}}
onRemove={() => {
onSegmentsChanges(segments.toSpliced(
Math.max(i - 1, 0),
1,
)
) )
}}
onMoves={next => {
setInternalSegments((is) => {
return is.toSpliced(i, 1, {
...is[i],
next,
})
})
}}
/>
]
}) })
} }
const update = useCallback(() => { const update = useCallback(() => {
const parentBase = area.current!.getBoundingClientRect() const parentBase = area.current!.getBoundingClientRect()
// only one segment is supported for now, which is the first. const firstSegment = internalSegments[0] ?? null
// any other segments will be ignored if (firstSegment == null) throw new Error("segments might not be empty.")
const segment = internalSegments[0] ?? null
if (segment == null) throw new Error("segments might not be empty.") const lastSegment = internalSegments[internalSegments.length - 1]
const endPos = segment.next
const startRelative = posWithinBase(startPos, parentBase) const startRelative = posWithinBase(startPos, parentBase)
const endRelative = posWithinBase(endPos, parentBase) const endRelative = posWithinBase(lastSegment.next, parentBase)
const controlPoint = segment.controlPoint const startNext = firstSegment.controlPoint
? posWithinBase(segment.controlPoint, parentBase) ? posWithinBase(firstSegment.controlPoint, parentBase)
: { : posWithinBase(firstSegment.next, parentBase)
x: startRelative.x / 2 + endRelative.x / 2,
y: startRelative.y / 2 + endRelative.y / 2, const endPrevious = lastSegment.controlPoint
} ? posWithinBase(lastSegment.controlPoint, parentBase)
: internalSegments[internalSegments.length - 2]
? posWithinBase(internalSegments[internalSegments.length - 2].next, parentBase)
: startRelative
const tailPos = constraintInCircle( const tailPos = constraintInCircle(
startRelative, startRelative,
controlPoint, startNext,
startRadius!, startRadius!,
) )
const headPos = constraintInCircle( const headPos = constraintInCircle(
endRelative, endRelative,
controlPoint, endPrevious,
endRadius!, endRadius!,
) )
@ -180,7 +206,7 @@ export default function BendableArrow({
top: tailPos.y + "px", top: tailPos.y + "px",
transformOrigin: "top center", transformOrigin: "top center",
transform: `translateX(-50%) rotate(${ transform: `translateX(-50%) rotate(${
-angle(tailPos, controlPoint) * (180 / Math.PI) -angle(tailPos, startNext) * (180 / Math.PI)
}deg)`, }deg)`,
} as CSSProperties) } as CSSProperties)
@ -189,7 +215,7 @@ export default function BendableArrow({
top: headPos.y + "px", top: headPos.y + "px",
transformOrigin: "top center", transformOrigin: "top center",
transform: `translateX(-50%) rotate(${ transform: `translateX(-50%) rotate(${
-angle(headPos, controlPoint) * (180 / Math.PI) -angle(headPos, endPrevious) * (180 / Math.PI)
}deg)`, }deg)`,
} as CSSProperties) } as CSSProperties)
@ -199,21 +225,24 @@ export default function BendableArrow({
} }
const segmentsRelatives = internalSegments.map( const segmentsRelatives = internalSegments.map(
({ next, controlPoint }) => { ({next, controlPoint}, idx) => {
const nextPos = posWithinBase(next, parentBase)
return { return {
next: posWithinBase(next, parentBase), next: nextPos,
cp: controlPoint cp: controlPoint
? posWithinBase(controlPoint, parentBase) ? posWithinBase(controlPoint, parentBase)
: { : between(
x: startRelative.x / 2 + endRelative.x / 2, idx == 0
y: startRelative.y / 2 + endRelative.y / 2, ? startRelative
}, : posWithinBase(internalSegments[idx - 1].next, parentBase),
nextPos
),
} }
}, },
) )
const computedSegments = segmentsRelatives const computedSegments = segmentsRelatives
.map(({ next: n, cp }, idx) => { .map(({next: n, cp}, idx) => {
let next = n let next = n
if (idx == internalSegments.length - 1) { if (idx == internalSegments.length - 1) {
@ -251,10 +280,68 @@ export default function BendableArrow({
} }
}, [update, containerRef]) }, [update, containerRef])
useEffect(() => {
const addSegment = (e: MouseEvent) => {
const parentBase = area.current!.getBoundingClientRect()
const clickAbsolutePos: Pos = {x: e.x, y: e.y}
const clickPosBaseRatio = ratioWithinBase(clickAbsolutePos, parentBase)
let segmentInsertionIndex = -1
let segmentInsertionIsOnRightOfCP = false
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]
let currentPos = i == 0 ? startPos : segments[i - 1].next
let nextPos = segment.next
let controlPointPos = segment.controlPoint ? segment.controlPoint : between(currentPos, nextPos)
const result = searchOnSegment(currentPos, controlPointPos, nextPos, clickPosBaseRatio, 0.05)
if (result == PointSegmentSearchResult.NOT_FOUND)
continue
segmentInsertionIndex = i
segmentInsertionIsOnRightOfCP = result == PointSegmentSearchResult.RIGHT_TO_CONTROL_POINT
break
}
if (segmentInsertionIndex == -1)
return
const splicedSegment: Segment = segments[segmentInsertionIndex]
let newSegments: Segment[]
if (segmentInsertionIsOnRightOfCP) {
newSegments = segments.toSpliced(
segmentInsertionIndex,
1,
{next: clickPosBaseRatio, controlPoint: splicedSegment.controlPoint},
{next: splicedSegment.next, controlPoint: undefined}
)
} else {
newSegments = segments.toSpliced(
segmentInsertionIndex,
1,
{next: clickPosBaseRatio, controlPoint: undefined},
{next: splicedSegment.next, controlPoint: splicedSegment.controlPoint}
)
}
onSegmentsChanges(newSegments)
}
pathRef?.current?.addEventListener('dblclick', addSegment)
return () => {
pathRef?.current?.removeEventListener('dblclick', addSegment)
}
}, [pathRef, segments]);
return ( return (
<div <div
ref={containerRef} ref={containerRef}
style={{ position: "absolute", top: 0, left: 0 }}> style={{position: "absolute", top: 0, left: 0}}>
<svg <svg
ref={svgRef} ref={svgRef}
style={{ style={{
@ -277,39 +364,93 @@ export default function BendableArrow({
<div <div
className={"arrow-head"} className={"arrow-head"}
style={{ position: "absolute", transformOrigin: "center" }} style={{position: "absolute", transformOrigin: "center"}}
ref={headRef}> ref={headRef}>
{style?.head?.call(style) ?? <Triangle fill={"red"} />} {style?.head?.call(style) ?? <Triangle fill={"red"}/>}
</div> </div>
<div <div
className={"arrow-tail"} className={"arrow-tail"}
style={{ position: "absolute", transformOrigin: "center" }} style={{position: "absolute", transformOrigin: "center"}}
ref={tailRef}> ref={tailRef}>
{style?.tail?.call(style) ?? <Triangle fill={"blue"} />} {style?.tail?.call(style) ?? <Triangle fill={"blue"}/>}
</div> </div>
{isSelected && {isSelected && computeControlPoints(area.current!.getBoundingClientRect())}
computeControlPoints(area.current!.getBoundingClientRect())}
</div> </div>
) )
} }
interface ControlPointProps { interface ControlPointProps {
className: string
posRatio: Pos posRatio: Pos
parentBase: DOMRect parentBase: DOMRect
onMoves: (currentPos: Pos) => void onMoves: (currentPos: Pos) => void
onPosValidated: (newPos: Pos | undefined) => void onPosValidated: (newPos: Pos) => void
onRemove: () => void
radius?: number radius?: number
} }
function ControlPoint({ enum PointSegmentSearchResult {
LEFT_TO_CONTROL_POINT,
RIGHT_TO_CONTROL_POINT,
NOT_FOUND
}
function searchOnSegment(startPos: Pos, controlPoint: Pos, endPos: Pos, point: Pos, minDistance: number): PointSegmentSearchResult {
const step = 1 / ((distance(startPos, controlPoint) + distance(controlPoint, endPos)) / minDistance)
const p0MinusP1 = minus(startPos, controlPoint)
const p2MinusP1 = minus(endPos, controlPoint)
function getDistanceAt(t: number): number {
// apply the bezier function
const pos = add(
add(
controlPoint,
mul(
p0MinusP1,
(1 - t) ** 2
)
),
mul(
p2MinusP1,
t ** 2
)
)
return distance(pos, point)
}
for (let t = 0; t < 1; t += step) {
if (getDistanceAt(t) <= minDistance)
return t >= 0.5
? PointSegmentSearchResult.RIGHT_TO_CONTROL_POINT
: PointSegmentSearchResult.LEFT_TO_CONTROL_POINT
}
return PointSegmentSearchResult.NOT_FOUND
}
let t = 0
let slice = 0.5
for (let i = 0; i < 100; i++) {
t += slice
slice /= 2
// console.log(t)
}
function ArrowPoint({
className,
posRatio, posRatio,
parentBase, parentBase,
onMoves, onMoves,
onPosValidated, onPosValidated,
onRemove,
radius = 7, radius = 7,
}: ControlPointProps) { }: ControlPointProps) {
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const pos = posWithinBase(posRatio, parentBase) const pos = posWithinBase(posRatio, parentBase)
@ -325,10 +466,10 @@ function ControlPoint({
const pointPos = middlePos(ref.current!.getBoundingClientRect()) const pointPos = middlePos(ref.current!.getBoundingClientRect())
onMoves(ratioWithinBase(pointPos, parentBase)) onMoves(ratioWithinBase(pointPos, parentBase))
}} }}
position={{ x: pos.x - radius, y: pos.y - radius }}> position={{x: pos.x - radius, y: pos.y - radius}}>
<div <div
ref={ref} ref={ref}
className={"arrow-edge-control-point"} className={`arrow-point ${className}`}
style={{ style={{
position: "absolute", position: "absolute",
width: radius * 2, width: radius * 2,
@ -336,7 +477,7 @@ function ControlPoint({
}} }}
onKeyUp={(e) => { onKeyUp={(e) => {
if (e.key == "Delete") { if (e.key == "Delete") {
onPosValidated(undefined) onRemove()
} }
}} }}
tabIndex={0} tabIndex={0}

@ -0,0 +1,35 @@
import {Pos} from "./Pos";
export interface Box {
x: number,
y: number,
width: number,
height: number
}
export function boundsOf(...positions: Pos[]): Box {
const allPosX = positions.map(p => p.x)
const allPosY = positions.map(p => p.y)
const x = Math.min(...allPosX)
const y = Math.min(...allPosY)
const width = Math.max(...allPosX) - x
const height = Math.max(...allPosY) - y
return {x, y, width, height}
}
export function surrounds(pos: Pos, width: number, height: number): Box {
return {
x: pos.x + (width / 2),
y: pos.y + (height / 2),
width,
height
}
}
export function contains(box: Box, pos: Pos): boolean {
return (pos.x >= box.x && pos.x <= box.x + box.width && pos.y >= box.y && pos.y <= box.y + box.height)
}

@ -3,7 +3,7 @@ export interface Pos {
y: number y: number
} }
export const NULL_POS: Pos = { x: 0, y: 0 } export const NULL_POS: Pos = {x: 0, y: 0}
/** /**
* Returns position of a relative to b * Returns position of a relative to b
@ -11,7 +11,7 @@ export const NULL_POS: Pos = { x: 0, y: 0 }
* @param b * @param b
*/ */
export function relativeTo(a: Pos, b: Pos): Pos { export function relativeTo(a: Pos, b: Pos): Pos {
return { x: a.x - b.x, y: a.y - b.y } return {x: a.x - b.x, y: a.y - b.y}
} }
/** /**
@ -19,20 +19,24 @@ export function relativeTo(a: Pos, b: Pos): Pos {
* @param rect * @param rect
*/ */
export function middlePos(rect: DOMRect): Pos { export function middlePos(rect: DOMRect): Pos {
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 } return {x: rect.x + rect.width / 2, y: rect.y + rect.height / 2}
} }
/**
* Returns x and y distance between given two pos
* @param a
* @param b
*/
export function size(a: Pos, b: Pos): Pos {
return { x: Math.abs(a.x - b.x), y: Math.abs(a.y - b.y) }
}
export function add(a: Pos, b: Pos): Pos { export function add(a: Pos, b: Pos): Pos {
return { x: a.x + b.x, y: a.y + b.y } return {x: a.x + b.x, y: a.y + b.y}
}
export function minus(a: Pos, b: Pos): Pos {
return {x: a.x - b.x, y: a.y - b.y}
}
export function mul(a: Pos, t: number): Pos {
return {x: a.x * t, y: a.y * t}
}
export function distance(a: Pos, b: Pos): number {
return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2)
} }
export function angle(a: Pos, b: Pos): number { export function angle(a: Pos, b: Pos): number {
@ -53,3 +57,10 @@ export function posWithinBase(ratio: Pos, base: DOMRect): Pos {
y: ratio.y * base.height, y: ratio.y * base.height,
} }
} }
export function between(a: Pos, b: Pos): Pos {
return {
x: a.x / 2 + b.x / 2,
y: a.y / 2 + b.y / 2
}
}

@ -1,4 +1,4 @@
.arrow-edge-control-point { .arrow-point {
cursor: pointer; cursor: pointer;
border-radius: 100px; border-radius: 100px;
@ -6,7 +6,7 @@
outline: none; outline: none;
} }
.arrow-edge-control-point:hover { .arrow-point:hover {
background-color: var(--selection-color); background-color: var(--selection-color);
} }

@ -453,7 +453,6 @@ function EditorView({
onActionDeleted={() => { onActionDeleted={() => {
setContent((content) => ({ setContent((content) => ({
...content, ...content,
players: content.players,
actions: content.actions.toSpliced( actions: content.actions.toSpliced(
i, i,
1, 1,

Loading…
Cancel
Save