mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-03 02:20:19 +08:00
init: the first public commit for AFFiNE
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
import { Utils } from '@tldraw/core';
|
||||
import {
|
||||
intersectCircleCircle,
|
||||
intersectCircleLineSegment,
|
||||
} from '@tldraw/intersect';
|
||||
import Vec from '@tldraw/vec';
|
||||
import getStroke from 'perfect-freehand';
|
||||
import { EASINGS } from '@toeverything/components/board-types';
|
||||
import { getShapeStyle } from '../shared/shape-styles';
|
||||
import type {
|
||||
ArrowShape,
|
||||
Decoration,
|
||||
ShapeStyles,
|
||||
} from '@toeverything/components/board-types';
|
||||
|
||||
export function getArrowArcPath(
|
||||
start: number[],
|
||||
end: number[],
|
||||
circle: number[],
|
||||
bend: number
|
||||
) {
|
||||
return [
|
||||
'M',
|
||||
start[0],
|
||||
start[1],
|
||||
'A',
|
||||
circle[2],
|
||||
circle[2],
|
||||
0,
|
||||
0,
|
||||
bend < 0 ? 0 : 1,
|
||||
end[0],
|
||||
end[1],
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
export function getBendPoint(handles: ArrowShape['handles'], bend: number) {
|
||||
const { start, end } = handles;
|
||||
|
||||
const dist = Vec.dist(start.point, end.point);
|
||||
|
||||
const midPoint = Vec.med(start.point, end.point);
|
||||
|
||||
const bendDist = (dist / 2) * bend;
|
||||
|
||||
const u = Vec.uni(Vec.vec(start.point, end.point));
|
||||
|
||||
const point = Vec.toFixed(
|
||||
Math.abs(bendDist) < 10
|
||||
? midPoint
|
||||
: Vec.add(midPoint, Vec.mul(Vec.per(u), bendDist))
|
||||
);
|
||||
|
||||
return point;
|
||||
}
|
||||
|
||||
export function renderFreehandArrowShaft(
|
||||
id: string,
|
||||
style: ShapeStyles,
|
||||
start: number[],
|
||||
end: number[],
|
||||
decorationStart: Decoration | undefined,
|
||||
decorationEnd: Decoration | undefined
|
||||
) {
|
||||
const getRandom = Utils.rng(id);
|
||||
const strokeWidth = getShapeStyle(style).strokeWidth;
|
||||
const startPoint = decorationStart
|
||||
? Vec.nudge(start, end, strokeWidth)
|
||||
: start;
|
||||
const endPoint = decorationEnd ? Vec.nudge(end, start, strokeWidth) : end;
|
||||
const stroke = getStroke([startPoint, endPoint], {
|
||||
size: strokeWidth,
|
||||
thinning: 0.618 + getRandom() * 0.2,
|
||||
easing: EASINGS.easeOutQuad,
|
||||
simulatePressure: true,
|
||||
streamline: 0,
|
||||
last: true,
|
||||
});
|
||||
return Utils.getSvgPathFromStroke(stroke);
|
||||
}
|
||||
|
||||
export function renderCurvedFreehandArrowShaft(
|
||||
id: string,
|
||||
style: ShapeStyles,
|
||||
start: number[],
|
||||
end: number[],
|
||||
decorationStart: Decoration | undefined,
|
||||
decorationEnd: Decoration | undefined,
|
||||
center: number[],
|
||||
radius: number,
|
||||
length: number,
|
||||
easing: (t: number) => number
|
||||
) {
|
||||
const getRandom = Utils.rng(id);
|
||||
const strokeWidth = getShapeStyle(style).strokeWidth;
|
||||
const startPoint = decorationStart
|
||||
? Vec.rotWith(start, center, strokeWidth / length)
|
||||
: start;
|
||||
const endPoint = decorationEnd
|
||||
? Vec.rotWith(end, center, -(strokeWidth / length))
|
||||
: end;
|
||||
const startAngle = Vec.angle(center, startPoint);
|
||||
const endAngle = Vec.angle(center, endPoint);
|
||||
const points: number[][] = [];
|
||||
const count = 8 + Math.floor((Math.abs(length) / 20) * 1 + getRandom() / 2);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = easing(i / count);
|
||||
const angle = Utils.lerpAngles(startAngle, endAngle, t);
|
||||
points.push(Vec.toFixed(Vec.nudgeAtAngle(center, angle, radius)));
|
||||
}
|
||||
const stroke = getStroke([startPoint, ...points, endPoint], {
|
||||
size: 1 + strokeWidth,
|
||||
thinning: 0.618 + getRandom() * 0.2,
|
||||
easing: EASINGS.easeOutQuad,
|
||||
simulatePressure: false,
|
||||
streamline: 0,
|
||||
last: true,
|
||||
});
|
||||
return Utils.getSvgPathFromStroke(stroke);
|
||||
}
|
||||
|
||||
export function getCtp(start: number[], bend: number[], end: number[]) {
|
||||
return Utils.circleFromThreePoints(start, end, bend);
|
||||
}
|
||||
|
||||
export function getCurvedArrowHeadPoints(
|
||||
A: number[],
|
||||
r1: number,
|
||||
C: number[],
|
||||
r2: number,
|
||||
sweep: boolean
|
||||
) {
|
||||
const ints = intersectCircleCircle(A, r1 * 0.618, C, r2).points;
|
||||
if (!ints) {
|
||||
console.warn('Could not find an intersection for the arrow head.');
|
||||
return { left: A, right: A };
|
||||
}
|
||||
const int = sweep ? ints[0] : ints[1];
|
||||
const left = int
|
||||
? Vec.nudge(Vec.rotWith(int, A, Math.PI / 6), A, r1 * -0.382)
|
||||
: A;
|
||||
const right = int
|
||||
? Vec.nudge(Vec.rotWith(int, A, -Math.PI / 6), A, r1 * -0.382)
|
||||
: A;
|
||||
return { left, right };
|
||||
}
|
||||
|
||||
export function getStraightArrowHeadPoints(
|
||||
A: number[],
|
||||
B: number[],
|
||||
r: number
|
||||
) {
|
||||
const ints = intersectCircleLineSegment(A, r, A, B).points;
|
||||
if (!ints) {
|
||||
console.warn('Could not find an intersection for the arrow head.');
|
||||
return { left: A, right: A };
|
||||
}
|
||||
const int = ints[0];
|
||||
const left = int ? Vec.rotWith(int, A, Math.PI / 6) : A;
|
||||
const right = int ? Vec.rotWith(int, A, -Math.PI / 6) : A;
|
||||
return { left, right };
|
||||
}
|
||||
|
||||
export function getCurvedArrowHeadPath(
|
||||
A: number[],
|
||||
r1: number,
|
||||
C: number[],
|
||||
r2: number,
|
||||
sweep: boolean
|
||||
) {
|
||||
const { left, right } = getCurvedArrowHeadPoints(A, r1, C, r2, sweep);
|
||||
return `M ${left} L ${A} ${right}`;
|
||||
}
|
||||
|
||||
export function getStraightArrowHeadPath(A: number[], B: number[], r: number) {
|
||||
const { left, right } = getStraightArrowHeadPoints(A, B, r);
|
||||
return `M ${left} L ${A} ${right}`;
|
||||
}
|
||||
|
||||
export function getArrowPath(
|
||||
style: ShapeStyles,
|
||||
start: number[],
|
||||
bend: number[],
|
||||
end: number[],
|
||||
decorationStart: Decoration | undefined,
|
||||
decorationEnd: Decoration | undefined
|
||||
) {
|
||||
const { strokeWidth } = getShapeStyle(style, false);
|
||||
const arrowDist = Vec.dist(start, end);
|
||||
const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8);
|
||||
const path: (string | number)[] = [];
|
||||
const isStraightLine = Vec.dist(bend, Vec.toFixed(Vec.med(start, end))) < 1;
|
||||
if (isStraightLine) {
|
||||
path.push(`M ${start} L ${end}`);
|
||||
if (decorationStart) {
|
||||
path.push(getStraightArrowHeadPath(start, end, arrowHeadLength));
|
||||
}
|
||||
if (decorationEnd) {
|
||||
path.push(getStraightArrowHeadPath(end, start, arrowHeadLength));
|
||||
}
|
||||
} else {
|
||||
const circle = getCtp(start, bend, end);
|
||||
const center = [circle[0], circle[1]];
|
||||
const radius = circle[2];
|
||||
const length = getArcLength(center, radius, start, end);
|
||||
path.push(
|
||||
`M ${start} A ${radius} ${radius} 0 0 ${
|
||||
length > 0 ? '1' : '0'
|
||||
} ${end}`
|
||||
);
|
||||
if (decorationStart)
|
||||
path.push(
|
||||
getCurvedArrowHeadPath(
|
||||
start,
|
||||
arrowHeadLength,
|
||||
center,
|
||||
radius,
|
||||
length < 0
|
||||
)
|
||||
);
|
||||
if (decorationEnd) {
|
||||
path.push(
|
||||
getCurvedArrowHeadPath(
|
||||
end,
|
||||
arrowHeadLength,
|
||||
center,
|
||||
radius,
|
||||
length >= 0
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
return path.join(' ');
|
||||
}
|
||||
|
||||
export function getArcPoints(start: number[], bend: number[], end: number[]) {
|
||||
if (Vec.dist2(bend, Vec.med(start, end)) <= 4) return [start, end];
|
||||
// The arc is curved; calculate twenty points along the arc
|
||||
const points: number[][] = [];
|
||||
const circle = getCtp(start, bend, end);
|
||||
const center = [circle[0], circle[1]];
|
||||
const radius = circle[2];
|
||||
const startAngle = Vec.angle(center, start);
|
||||
const endAngle = Vec.angle(center, end);
|
||||
for (let i = 1 / 20; i < 1; i += 1 / 20) {
|
||||
const angle = Utils.lerpAngles(startAngle, endAngle, i);
|
||||
points.push(Vec.nudgeAtAngle(center, angle, radius));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
export function isAngleBetween(a: number, b: number, c: number): boolean {
|
||||
if (c === a || c === b) return true;
|
||||
const PI2 = Math.PI * 2;
|
||||
const AB = (b - a + PI2) % PI2;
|
||||
const AC = (c - a + PI2) % PI2;
|
||||
return AB <= Math.PI !== AC > AB;
|
||||
}
|
||||
|
||||
export function getArcLength(
|
||||
C: number[],
|
||||
r: number,
|
||||
A: number[],
|
||||
B: number[]
|
||||
): number {
|
||||
const sweep = Utils.getSweep(C, A, B);
|
||||
return r * (2 * Math.PI) * (sweep / (2 * Math.PI));
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
import * as React from 'react';
|
||||
import { Utils, TLBounds, SVGContainer } from '@tldraw/core';
|
||||
import { Vec } from '@tldraw/vec';
|
||||
import { defaultStyle } from '../shared/shape-styles';
|
||||
import {
|
||||
ArrowShape,
|
||||
TransformInfo,
|
||||
Decoration,
|
||||
TDShapeType,
|
||||
DashStyle,
|
||||
TDMeta,
|
||||
GHOSTED_OPACITY,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TDShapeUtil } from '../TDShapeUtil';
|
||||
import {
|
||||
intersectArcBounds,
|
||||
intersectLineSegmentBounds,
|
||||
intersectLineSegmentLineSegment,
|
||||
} from '@tldraw/intersect';
|
||||
import {
|
||||
getArcLength,
|
||||
getArcPoints,
|
||||
getArrowPath,
|
||||
getBendPoint,
|
||||
getCtp,
|
||||
isAngleBetween,
|
||||
} from './arrow-helpers';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import {
|
||||
TextLabel,
|
||||
getFontStyle,
|
||||
getShapeStyle,
|
||||
getTextLabelSize,
|
||||
LabelMask,
|
||||
} from '../shared';
|
||||
import { StraightArrow } from './components/straight-arrow';
|
||||
import { CurvedArrow } from './components/curved-arrow';
|
||||
|
||||
type T = ArrowShape;
|
||||
type E = HTMLDivElement;
|
||||
|
||||
export class ArrowUtil extends TDShapeUtil<T, E> {
|
||||
type = TDShapeType.Arrow as const;
|
||||
|
||||
override hideBounds = true;
|
||||
|
||||
override canEdit = true;
|
||||
|
||||
pathCache = new WeakMap<T, string>();
|
||||
|
||||
getShape = (props: Partial<T>): T => {
|
||||
return {
|
||||
id: 'id',
|
||||
type: TDShapeType.Arrow,
|
||||
name: 'Arrow',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
point: [0, 0],
|
||||
rotation: 0,
|
||||
bend: 0,
|
||||
handles: {
|
||||
start: {
|
||||
id: 'start',
|
||||
index: 0,
|
||||
point: [0, 0],
|
||||
canBind: true,
|
||||
...props.handles?.start,
|
||||
},
|
||||
end: {
|
||||
id: 'end',
|
||||
index: 1,
|
||||
point: [1, 1],
|
||||
canBind: true,
|
||||
...props.handles?.end,
|
||||
},
|
||||
bend: {
|
||||
id: 'bend',
|
||||
index: 2,
|
||||
point: [0.5, 0.5],
|
||||
...props.handles?.bend,
|
||||
},
|
||||
},
|
||||
decorations: props.decorations ?? {
|
||||
end: Decoration.Arrow,
|
||||
},
|
||||
style: {
|
||||
...defaultStyle,
|
||||
isFilled: false,
|
||||
...props.style,
|
||||
},
|
||||
label: '',
|
||||
labelPoint: [0.5, 0.5],
|
||||
workspace: props.workspace,
|
||||
...props,
|
||||
};
|
||||
};
|
||||
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
||||
(
|
||||
{
|
||||
shape,
|
||||
isEditing,
|
||||
isGhost,
|
||||
meta,
|
||||
events,
|
||||
onShapeChange,
|
||||
onShapeBlur,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const {
|
||||
id,
|
||||
label = '',
|
||||
handles: { start, bend, end },
|
||||
decorations = {},
|
||||
style,
|
||||
} = shape;
|
||||
const isStraightLine =
|
||||
Vec.dist(
|
||||
bend.point,
|
||||
Vec.toFixed(Vec.med(start.point, end.point))
|
||||
) < 1;
|
||||
const font = getFontStyle(style);
|
||||
const styles = getShapeStyle(style, meta.isDarkMode);
|
||||
const labelSize =
|
||||
label || isEditing ? getTextLabelSize(label, font) : [0, 0];
|
||||
const bounds = this.getBounds(shape);
|
||||
const dist = React.useMemo(() => {
|
||||
const { start, bend, end } = shape.handles;
|
||||
if (isStraightLine) return Vec.dist(start.point, end.point);
|
||||
const circle = getCtp(start.point, bend.point, end.point);
|
||||
const center = circle.slice(0, 2);
|
||||
const radius = circle[2];
|
||||
const length = getArcLength(
|
||||
center,
|
||||
radius,
|
||||
start.point,
|
||||
end.point
|
||||
);
|
||||
return Math.abs(length);
|
||||
}, [shape.handles]);
|
||||
const scale = Math.max(
|
||||
0.5,
|
||||
Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
dist / (labelSize[1] + 128),
|
||||
dist / (labelSize[0] + 128)
|
||||
)
|
||||
)
|
||||
);
|
||||
const offset = React.useMemo(() => {
|
||||
const bounds = this.getBounds(shape);
|
||||
const offset = Vec.sub(
|
||||
shape.handles.bend.point,
|
||||
Vec.toFixed([bounds.width / 2, bounds.height / 2])
|
||||
);
|
||||
return offset;
|
||||
}, [shape, scale]);
|
||||
const handleLabelChange = React.useCallback(
|
||||
(label: string) => {
|
||||
onShapeChange?.({ id, label });
|
||||
},
|
||||
[onShapeChange]
|
||||
);
|
||||
const Component = isStraightLine ? StraightArrow : CurvedArrow;
|
||||
return (
|
||||
<FullWrapper ref={ref} {...events}>
|
||||
<TextLabel
|
||||
font={font}
|
||||
text={label}
|
||||
color={styles.stroke}
|
||||
offsetX={offset[0]}
|
||||
offsetY={offset[1]}
|
||||
scale={scale}
|
||||
isEditing={isEditing}
|
||||
onChange={handleLabelChange}
|
||||
onBlur={onShapeBlur}
|
||||
/>
|
||||
<SVGContainer id={shape.id + '_svg'}>
|
||||
<defs>
|
||||
<mask id={shape.id + '_clip'}>
|
||||
<rect
|
||||
x={-100}
|
||||
y={-100}
|
||||
width={bounds.width + 200}
|
||||
height={bounds.height + 200}
|
||||
fill="white"
|
||||
/>
|
||||
<rect
|
||||
x={
|
||||
bounds.width / 2 -
|
||||
(labelSize[0] / 2) * scale +
|
||||
offset[0]
|
||||
}
|
||||
y={
|
||||
bounds.height / 2 -
|
||||
(labelSize[1] / 2) * scale +
|
||||
offset[1]
|
||||
}
|
||||
width={labelSize[0] * scale}
|
||||
height={labelSize[1] * scale}
|
||||
rx={4 * scale}
|
||||
ry={4 * scale}
|
||||
fill="black"
|
||||
opacity={1}
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
<g
|
||||
pointerEvents="none"
|
||||
opacity={isGhost ? GHOSTED_OPACITY : 1}
|
||||
mask={
|
||||
label || isEditing
|
||||
? `url(#${shape.id}_clip)`
|
||||
: ``
|
||||
}
|
||||
>
|
||||
<Component
|
||||
id={id}
|
||||
style={style}
|
||||
start={start.point}
|
||||
end={end.point}
|
||||
bend={bend.point}
|
||||
arrowBend={shape.bend}
|
||||
decorationStart={decorations?.start}
|
||||
decorationEnd={decorations?.end}
|
||||
isDraw={style.dash === DashStyle.Draw}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
/>
|
||||
</g>
|
||||
</SVGContainer>
|
||||
</FullWrapper>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Indicator = TDShapeUtil.Indicator<ArrowShape>(({ shape, bounds }) => {
|
||||
const {
|
||||
style,
|
||||
decorations,
|
||||
label,
|
||||
handles: { start, bend, end },
|
||||
} = shape;
|
||||
const font = getFontStyle(style);
|
||||
const labelSize = label ? getTextLabelSize(label, font) : [0, 0];
|
||||
const isStraightLine =
|
||||
Vec.dist(bend.point, Vec.toFixed(Vec.med(start.point, end.point))) <
|
||||
1;
|
||||
const dist = React.useMemo(() => {
|
||||
const { start, bend, end } = shape.handles;
|
||||
if (isStraightLine) return Vec.dist(start.point, end.point);
|
||||
const circle = getCtp(start.point, bend.point, end.point);
|
||||
const center = circle.slice(0, 2);
|
||||
const radius = circle[2];
|
||||
const length = getArcLength(center, radius, start.point, end.point);
|
||||
return Math.abs(length);
|
||||
}, [shape.handles]);
|
||||
const scale = Math.max(
|
||||
0.5,
|
||||
Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
dist / (labelSize[1] + 128),
|
||||
dist / (labelSize[0] + 128)
|
||||
)
|
||||
)
|
||||
);
|
||||
const offset = React.useMemo(() => {
|
||||
const bounds = this.getBounds(shape);
|
||||
const offset = Vec.sub(shape.handles.bend.point, [
|
||||
bounds.width / 2,
|
||||
bounds.height / 2,
|
||||
]);
|
||||
return offset;
|
||||
}, [shape, scale]);
|
||||
return (
|
||||
<>
|
||||
<LabelMask
|
||||
id={shape.id}
|
||||
scale={scale}
|
||||
offset={offset}
|
||||
bounds={bounds}
|
||||
labelSize={labelSize}
|
||||
/>
|
||||
<path
|
||||
d={getArrowPath(
|
||||
style,
|
||||
start.point,
|
||||
bend.point,
|
||||
end.point,
|
||||
decorations?.start,
|
||||
decorations?.end
|
||||
)}
|
||||
mask={label ? `url(#${shape.id}_clip)` : ``}
|
||||
/>
|
||||
{label && (
|
||||
<rect
|
||||
x={
|
||||
bounds.width / 2 -
|
||||
(labelSize[0] / 2) * scale +
|
||||
offset[0]
|
||||
}
|
||||
y={
|
||||
bounds.height / 2 -
|
||||
(labelSize[1] / 2) * scale +
|
||||
offset[1]
|
||||
}
|
||||
width={labelSize[0] * scale}
|
||||
height={labelSize[1] * scale}
|
||||
rx={4 * scale}
|
||||
ry={4 * scale}
|
||||
fill="transparent"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
getBounds = (shape: T) => {
|
||||
const bounds = Utils.getFromCache(this.boundsCache, shape, () => {
|
||||
const {
|
||||
handles: { start, bend, end },
|
||||
} = shape;
|
||||
return Utils.getBoundsFromPoints(
|
||||
getArcPoints(start.point, bend.point, end.point)
|
||||
);
|
||||
});
|
||||
return Utils.translateBounds(bounds, shape.point);
|
||||
};
|
||||
|
||||
override getRotatedBounds = (shape: T) => {
|
||||
const {
|
||||
handles: { start, bend, end },
|
||||
} = shape;
|
||||
let points = getArcPoints(start.point, bend.point, end.point);
|
||||
const { minX, minY, maxX, maxY } = Utils.getBoundsFromPoints(points);
|
||||
if (shape.rotation !== 0) {
|
||||
points = points.map(pt =>
|
||||
Vec.rotWith(
|
||||
pt,
|
||||
[(minX + maxX) / 2, (minY + maxY) / 2],
|
||||
shape.rotation || 0
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return Utils.translateBounds(
|
||||
Utils.getBoundsFromPoints(points),
|
||||
shape.point
|
||||
);
|
||||
};
|
||||
|
||||
override getCenter = (shape: T) => {
|
||||
const { start, end } = shape.handles;
|
||||
return Vec.add(shape.point, Vec.med(start.point, end.point));
|
||||
};
|
||||
|
||||
override shouldRender = (prev: T, next: T) => {
|
||||
return (
|
||||
next.decorations !== prev.decorations ||
|
||||
next.handles !== prev.handles ||
|
||||
next.style !== prev.style ||
|
||||
next.label !== prev.label
|
||||
);
|
||||
};
|
||||
|
||||
override hitTestPoint = (shape: T, point: number[]): boolean => {
|
||||
const {
|
||||
handles: { start, bend, end },
|
||||
} = shape;
|
||||
const pt = Vec.sub(point, shape.point);
|
||||
const points = getArcPoints(start.point, bend.point, end.point);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
if (Vec.distanceToLineSegment(points[i - 1], points[i], pt) < 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
override hitTestLineSegment = (
|
||||
shape: T,
|
||||
A: number[],
|
||||
B: number[]
|
||||
): boolean => {
|
||||
const {
|
||||
handles: { start, bend, end },
|
||||
} = shape;
|
||||
const ptA = Vec.sub(A, shape.point);
|
||||
const ptB = Vec.sub(B, shape.point);
|
||||
const points = getArcPoints(start.point, bend.point, end.point);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
if (
|
||||
intersectLineSegmentLineSegment(
|
||||
points[i - 1],
|
||||
points[i],
|
||||
ptA,
|
||||
ptB
|
||||
).didIntersect
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
override hitTestBounds = (shape: T, bounds: TLBounds) => {
|
||||
const { start, end, bend } = shape.handles;
|
||||
const sp = Vec.add(shape.point, start.point);
|
||||
const ep = Vec.add(shape.point, end.point);
|
||||
if (
|
||||
Utils.pointInBounds(sp, bounds) ||
|
||||
Utils.pointInBounds(ep, bounds)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (Vec.isEqual(Vec.med(start.point, end.point), bend.point)) {
|
||||
return intersectLineSegmentBounds(sp, ep, bounds).length > 0;
|
||||
} else {
|
||||
const [cx, cy, r] = getCtp(start.point, bend.point, end.point);
|
||||
const cp = Vec.add(shape.point, [cx, cy]);
|
||||
return intersectArcBounds(cp, r, sp, ep, bounds).length > 0;
|
||||
}
|
||||
};
|
||||
|
||||
override transform = (
|
||||
shape: T,
|
||||
bounds: TLBounds,
|
||||
{ initialShape, scaleX, scaleY }: TransformInfo<T>
|
||||
): Partial<T> => {
|
||||
const initialShapeBounds = this.getBounds(initialShape);
|
||||
const handles: (keyof T['handles'])[] = ['start', 'end'];
|
||||
const nextHandles = { ...initialShape.handles };
|
||||
handles.forEach(handle => {
|
||||
const [x, y] = nextHandles[handle].point;
|
||||
const nw = x / initialShapeBounds.width;
|
||||
const nh = y / initialShapeBounds.height;
|
||||
nextHandles[handle] = {
|
||||
...nextHandles[handle],
|
||||
point: [
|
||||
bounds.width * (scaleX < 0 ? 1 - nw : nw),
|
||||
bounds.height * (scaleY < 0 ? 1 - nh : nh),
|
||||
],
|
||||
};
|
||||
});
|
||||
const { start, bend, end } = nextHandles;
|
||||
const dist = Vec.dist(start.point, end.point);
|
||||
const midPoint = Vec.med(start.point, end.point);
|
||||
const bendDist = (dist / 2) * initialShape.bend;
|
||||
const u = Vec.uni(Vec.vec(start.point, end.point));
|
||||
const point = Vec.add(midPoint, Vec.mul(Vec.per(u), bendDist));
|
||||
nextHandles['bend'] = {
|
||||
...bend,
|
||||
point: Vec.toFixed(Math.abs(bendDist) < 10 ? midPoint : point),
|
||||
};
|
||||
return {
|
||||
point: Vec.toFixed([bounds.minX, bounds.minY]),
|
||||
handles: nextHandles,
|
||||
};
|
||||
};
|
||||
|
||||
override onDoubleClickHandle = (
|
||||
shape: T,
|
||||
handle: Partial<T['handles']>
|
||||
): Partial<T> | void => {
|
||||
switch (handle) {
|
||||
case 'bend': {
|
||||
return {
|
||||
bend: 0,
|
||||
handles: {
|
||||
...shape.handles,
|
||||
bend: {
|
||||
...shape.handles.bend,
|
||||
point: getBendPoint(shape.handles, shape.bend),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'start': {
|
||||
return {
|
||||
decorations: {
|
||||
...shape.decorations,
|
||||
start: shape.decorations?.start
|
||||
? undefined
|
||||
: Decoration.Arrow,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'end': {
|
||||
return {
|
||||
decorations: {
|
||||
...shape.decorations,
|
||||
end: shape.decorations?.end
|
||||
? undefined
|
||||
: Decoration.Arrow,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
override onHandleChange = (
|
||||
shape: T,
|
||||
handles: Partial<T['handles']>
|
||||
): Partial<T> | void => {
|
||||
let nextHandles = Utils.deepMerge<ArrowShape['handles']>(
|
||||
shape.handles,
|
||||
handles
|
||||
);
|
||||
let nextBend = shape.bend;
|
||||
|
||||
nextHandles = Utils.deepMerge(nextHandles, {
|
||||
start: {
|
||||
point: Vec.toFixed(nextHandles.start.point),
|
||||
},
|
||||
end: {
|
||||
point: Vec.toFixed(nextHandles.end.point),
|
||||
},
|
||||
});
|
||||
|
||||
// This will produce NaN values
|
||||
if (Vec.isEqual(nextHandles.start.point, nextHandles.end.point)) return;
|
||||
|
||||
// If the user is moving the bend handle, we want to move the bend point
|
||||
if ('bend' in handles) {
|
||||
const { start, end, bend } = nextHandles;
|
||||
|
||||
const distance = Vec.dist(start.point, end.point);
|
||||
const midPoint = Vec.med(start.point, end.point);
|
||||
const angle = Vec.angle(start.point, end.point);
|
||||
const u = Vec.uni(Vec.vec(start.point, end.point));
|
||||
|
||||
// Create a line segment perendicular to the line between the start and end points
|
||||
const ap = Vec.add(midPoint, Vec.mul(Vec.per(u), distance));
|
||||
const bp = Vec.sub(midPoint, Vec.mul(Vec.per(u), distance));
|
||||
|
||||
const bendPoint = Vec.nearestPointOnLineSegment(
|
||||
ap,
|
||||
bp,
|
||||
bend.point,
|
||||
true
|
||||
);
|
||||
|
||||
// Find the distance between the midpoint and the nearest point on the
|
||||
// line segment to the bend handle's dragged point
|
||||
const bendDist = Vec.dist(midPoint, bendPoint);
|
||||
|
||||
// The shape's "bend" is the ratio of the bend to the distance between
|
||||
// the start and end points. If the bend is below a certain amount, the
|
||||
// bend should be zero.
|
||||
const realBend = bendDist / (distance / 2);
|
||||
|
||||
nextBend = Utils.clamp(realBend, -0.99, 0.99);
|
||||
|
||||
// If the point is to the left of the line segment, we make the bend
|
||||
// negative, otherwise it's positive.
|
||||
const angleToBend = Vec.angle(start.point, bendPoint);
|
||||
|
||||
// If resulting bend is low enough that the handle will snap to center,
|
||||
// then also snap the bend to center
|
||||
|
||||
if (Vec.isEqual(midPoint, getBendPoint(nextHandles, nextBend))) {
|
||||
nextBend = 0;
|
||||
} else if (isAngleBetween(angle, angle + Math.PI, angleToBend)) {
|
||||
// Otherwise, fix the bend direction
|
||||
nextBend *= -1;
|
||||
}
|
||||
}
|
||||
|
||||
const nextShape = {
|
||||
point: shape.point,
|
||||
bend: nextBend,
|
||||
handles: {
|
||||
...nextHandles,
|
||||
bend: {
|
||||
...nextHandles.bend,
|
||||
point: getBendPoint(nextHandles, nextBend),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Zero out the handles to prevent handles with negative points. If a handle's x or y
|
||||
// is below zero, we need to move the shape left or up to make it zero.
|
||||
const topLeft = shape.point;
|
||||
|
||||
const nextBounds = this.getBounds({ ...nextShape } as ArrowShape);
|
||||
|
||||
const offset = Vec.sub([nextBounds.minX, nextBounds.minY], topLeft);
|
||||
|
||||
if (!Vec.isEqual(offset, [0, 0])) {
|
||||
Object.values(nextShape.handles).forEach(handle => {
|
||||
handle.point = Vec.toFixed(Vec.sub(handle.point, offset));
|
||||
});
|
||||
nextShape.point = Vec.toFixed(Vec.add(nextShape.point, offset));
|
||||
}
|
||||
|
||||
return nextShape;
|
||||
};
|
||||
}
|
||||
|
||||
const FullWrapper = styled('div')({ width: '100%', height: '100%' });
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export interface ArrowheadProps {
|
||||
left: number[];
|
||||
middle: number[];
|
||||
right: number[];
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
}
|
||||
|
||||
export function Arrowhead({
|
||||
left,
|
||||
middle,
|
||||
right,
|
||||
stroke,
|
||||
strokeWidth,
|
||||
}: ArrowheadProps) {
|
||||
return (
|
||||
<g>
|
||||
<path
|
||||
className="tl-stroke-hitarea"
|
||||
d={`M ${left} L ${middle} ${right}`}
|
||||
/>
|
||||
<path
|
||||
d={`M ${left} L ${middle} ${right}`}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Utils } from '@tldraw/core';
|
||||
import Vec from '@tldraw/vec';
|
||||
import * as React from 'react';
|
||||
import { EASINGS } from '@toeverything/components/board-types';
|
||||
import { getShapeStyle } from '../../shared';
|
||||
import type {
|
||||
Decoration,
|
||||
ShapeStyles,
|
||||
} from '@toeverything/components/board-types';
|
||||
import {
|
||||
getArcLength,
|
||||
getArrowArcPath,
|
||||
getCtp,
|
||||
getCurvedArrowHeadPoints,
|
||||
renderCurvedFreehandArrowShaft,
|
||||
} from '../arrow-helpers';
|
||||
import { Arrowhead } from './arrow-head';
|
||||
|
||||
interface ArrowSvgProps {
|
||||
id: string;
|
||||
style: ShapeStyles;
|
||||
start: number[];
|
||||
bend: number[];
|
||||
end: number[];
|
||||
arrowBend: number;
|
||||
decorationStart: Decoration | undefined;
|
||||
decorationEnd: Decoration | undefined;
|
||||
isDarkMode: boolean;
|
||||
isDraw: boolean;
|
||||
}
|
||||
|
||||
export const CurvedArrow = React.memo(function CurvedArrow({
|
||||
id,
|
||||
style,
|
||||
start,
|
||||
bend,
|
||||
end,
|
||||
arrowBend,
|
||||
decorationStart,
|
||||
decorationEnd,
|
||||
isDraw,
|
||||
isDarkMode,
|
||||
}: ArrowSvgProps) {
|
||||
const arrowDist = Vec.dist(start, end);
|
||||
if (arrowDist < 2) return null;
|
||||
const styles = getShapeStyle(style, isDarkMode);
|
||||
const { strokeWidth } = styles;
|
||||
const sw = 1 + strokeWidth * 1.618;
|
||||
// Calculate a path as a segment of a circle passing through the three points start, bend, and end
|
||||
const circle = getCtp(start, bend, end);
|
||||
const center = [circle[0], circle[1]];
|
||||
const radius = circle[2];
|
||||
const length = getArcLength(center, radius, start, end);
|
||||
const getRandom = Utils.rng(id);
|
||||
const easing =
|
||||
EASINGS[getRandom() > 0 ? 'easeInOutSine' : 'easeInOutCubic'];
|
||||
const path = isDraw
|
||||
? renderCurvedFreehandArrowShaft(
|
||||
id,
|
||||
style,
|
||||
start,
|
||||
end,
|
||||
decorationStart,
|
||||
decorationEnd,
|
||||
center,
|
||||
radius,
|
||||
length,
|
||||
easing
|
||||
)
|
||||
: getArrowArcPath(start, end, circle, arrowBend);
|
||||
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
|
||||
Math.abs(length),
|
||||
sw,
|
||||
style.dash,
|
||||
2,
|
||||
false
|
||||
);
|
||||
// Arrowheads
|
||||
const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8);
|
||||
const startArrowHead = decorationStart
|
||||
? getCurvedArrowHeadPoints(
|
||||
start,
|
||||
arrowHeadLength,
|
||||
center,
|
||||
radius,
|
||||
length < 0
|
||||
)
|
||||
: null;
|
||||
const endArrowHead = decorationEnd
|
||||
? getCurvedArrowHeadPoints(
|
||||
end,
|
||||
arrowHeadLength,
|
||||
center,
|
||||
radius,
|
||||
length >= 0
|
||||
)
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
<path className="tl-stroke-hitarea" d={path} />
|
||||
<path
|
||||
d={path}
|
||||
fill={isDraw ? styles.stroke : 'none'}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={isDraw ? 0 : sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
{startArrowHead && (
|
||||
<Arrowhead
|
||||
left={startArrowHead.left}
|
||||
middle={start}
|
||||
right={startArrowHead.right}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
/>
|
||||
)}
|
||||
{endArrowHead && (
|
||||
<Arrowhead
|
||||
left={endArrowHead.left}
|
||||
middle={end}
|
||||
right={endArrowHead.right}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Utils } from '@tldraw/core';
|
||||
import Vec from '@tldraw/vec';
|
||||
import * as React from 'react';
|
||||
import { getShapeStyle } from '../../shared';
|
||||
import type {
|
||||
Decoration,
|
||||
ShapeStyles,
|
||||
} from '@toeverything/components/board-types';
|
||||
import {
|
||||
getStraightArrowHeadPoints,
|
||||
renderFreehandArrowShaft,
|
||||
} from '../arrow-helpers';
|
||||
import { Arrowhead } from './arrow-head';
|
||||
|
||||
interface ArrowSvgProps {
|
||||
id: string;
|
||||
style: ShapeStyles;
|
||||
start: number[];
|
||||
bend: number[];
|
||||
end: number[];
|
||||
arrowBend: number;
|
||||
decorationStart: Decoration | undefined;
|
||||
decorationEnd: Decoration | undefined;
|
||||
isDarkMode: boolean;
|
||||
isDraw: boolean;
|
||||
}
|
||||
|
||||
export const StraightArrow = React.memo(function StraightArrow({
|
||||
id,
|
||||
style,
|
||||
start,
|
||||
end,
|
||||
decorationStart,
|
||||
decorationEnd,
|
||||
isDraw,
|
||||
isDarkMode,
|
||||
}: ArrowSvgProps) {
|
||||
const arrowDist = Vec.dist(start, end);
|
||||
if (arrowDist < 2) return null;
|
||||
const styles = getShapeStyle(style, isDarkMode);
|
||||
const { strokeWidth } = styles;
|
||||
const sw = 1 + strokeWidth * 1.618;
|
||||
// Path between start and end points
|
||||
const path = isDraw
|
||||
? renderFreehandArrowShaft(
|
||||
id,
|
||||
style,
|
||||
start,
|
||||
end,
|
||||
decorationStart,
|
||||
decorationEnd
|
||||
)
|
||||
: 'M' + Vec.toFixed(start) + 'L' + Vec.toFixed(end);
|
||||
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
|
||||
arrowDist,
|
||||
strokeWidth * 1.618,
|
||||
style.dash,
|
||||
2,
|
||||
false
|
||||
);
|
||||
// Arrowheads
|
||||
const arrowHeadLength = Math.min(arrowDist / 3, strokeWidth * 8);
|
||||
const startArrowHead = decorationStart
|
||||
? getStraightArrowHeadPoints(start, end, arrowHeadLength)
|
||||
: null;
|
||||
const endArrowHead = decorationEnd
|
||||
? getStraightArrowHeadPoints(end, start, arrowHeadLength)
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
<path className="tl-stroke-hitarea" d={path} />
|
||||
<path
|
||||
d={path}
|
||||
fill={styles.stroke}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={isDraw ? sw / 2 : sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
pointerEvents="stroke"
|
||||
/>
|
||||
{startArrowHead && (
|
||||
<Arrowhead
|
||||
left={startArrowHead.left}
|
||||
middle={start}
|
||||
right={startArrowHead.right}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
/>
|
||||
)}
|
||||
{endArrowHead && (
|
||||
<Arrowhead
|
||||
left={endArrowHead.left}
|
||||
middle={end}
|
||||
right={endArrowHead.right}
|
||||
stroke={styles.stroke}
|
||||
strokeWidth={sw}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './arrow-util';
|
||||
Reference in New Issue
Block a user