mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 18:26:05 +08:00
init: the first public commit for AFFiNE
This commit is contained in:
363
libs/components/board-shapes/src/ellipse-util/EllipseUtil.tsx
Normal file
363
libs/components/board-shapes/src/ellipse-util/EllipseUtil.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import * as React from 'react';
|
||||
import { Utils, SVGContainer, TLBounds } from '@tldraw/core';
|
||||
import { Vec } from '@tldraw/vec';
|
||||
import {
|
||||
defaultStyle,
|
||||
getShapeStyle,
|
||||
getFontStyle,
|
||||
TextLabel,
|
||||
} from '../shared';
|
||||
import {
|
||||
EllipseShape,
|
||||
DashStyle,
|
||||
TDShapeType,
|
||||
TDShape,
|
||||
TransformInfo,
|
||||
TDMeta,
|
||||
GHOSTED_OPACITY,
|
||||
LABEL_POINT,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TDShapeUtil } from '../TDShapeUtil';
|
||||
import {
|
||||
intersectEllipseBounds,
|
||||
intersectLineSegmentEllipse,
|
||||
intersectRayEllipse,
|
||||
} from '@tldraw/intersect';
|
||||
import { getEllipseIndicatorPath } from './ellipse-helpers';
|
||||
import { DrawEllipse } from './components/DrawEllipse';
|
||||
import { DashedEllipse } from './components/DashedEllipse';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
type T = EllipseShape;
|
||||
type E = HTMLDivElement;
|
||||
type M = TDMeta;
|
||||
|
||||
export class EllipseUtil extends TDShapeUtil<T, E> {
|
||||
type = TDShapeType.Ellipse as const;
|
||||
|
||||
override canBind = true;
|
||||
|
||||
override canClone = true;
|
||||
|
||||
override canEdit = true;
|
||||
|
||||
getShape = (props: Partial<T>): T => {
|
||||
return Utils.deepMerge<T>(
|
||||
{
|
||||
id: 'id',
|
||||
type: TDShapeType.Ellipse,
|
||||
name: 'Ellipse',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
point: [0, 0],
|
||||
radius: [1, 1],
|
||||
rotation: 0,
|
||||
style: defaultStyle,
|
||||
label: '',
|
||||
labelPoint: [0.5, 0.5],
|
||||
workspace: props.workspace,
|
||||
},
|
||||
props
|
||||
);
|
||||
};
|
||||
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
||||
(
|
||||
{
|
||||
shape,
|
||||
isGhost,
|
||||
isSelected,
|
||||
isBinding,
|
||||
isEditing,
|
||||
meta,
|
||||
bounds,
|
||||
events,
|
||||
onShapeChange,
|
||||
onShapeBlur,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const {
|
||||
id,
|
||||
radius,
|
||||
style,
|
||||
label = '',
|
||||
labelPoint = LABEL_POINT,
|
||||
} = shape;
|
||||
const font = getFontStyle(shape.style);
|
||||
const styles = getShapeStyle(style, meta.isDarkMode);
|
||||
const strokeWidth = styles.strokeWidth;
|
||||
const sw = 1 + strokeWidth * 1.618;
|
||||
const rx = Math.max(0, radius[0] - sw / 2);
|
||||
const ry = Math.max(0, radius[1] - sw / 2);
|
||||
const Component =
|
||||
style.dash === DashStyle.Draw ? DrawEllipse : DashedEllipse;
|
||||
const handleLabelChange = React.useCallback(
|
||||
(label: string) => onShapeChange?.({ id, label }),
|
||||
[onShapeChange]
|
||||
);
|
||||
return (
|
||||
<FullWrapper ref={ref} {...events}>
|
||||
<TextLabel
|
||||
isEditing={isEditing}
|
||||
onChange={handleLabelChange}
|
||||
onBlur={onShapeBlur}
|
||||
font={font}
|
||||
text={label}
|
||||
color={styles.stroke}
|
||||
offsetX={(labelPoint[0] - 0.5) * bounds.width}
|
||||
offsetY={(labelPoint[1] - 0.5) * bounds.height}
|
||||
/>
|
||||
<SVGContainer
|
||||
id={shape.id + '_svg'}
|
||||
opacity={isGhost ? GHOSTED_OPACITY : 1}
|
||||
>
|
||||
{isBinding && (
|
||||
<ellipse
|
||||
className="tl-binding-indicator"
|
||||
cx={radius[0]}
|
||||
cy={radius[1]}
|
||||
rx={rx}
|
||||
ry={ry}
|
||||
strokeWidth={this.bindingDistance}
|
||||
/>
|
||||
)}
|
||||
<Component
|
||||
id={id}
|
||||
radius={radius}
|
||||
style={style}
|
||||
isSelected={isSelected}
|
||||
isDarkMode={meta.isDarkMode}
|
||||
/>
|
||||
</SVGContainer>
|
||||
</FullWrapper>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Indicator = TDShapeUtil.Indicator<T, M>(({ shape }) => {
|
||||
const { id, radius, style } = shape;
|
||||
const styles = getShapeStyle(style);
|
||||
const strokeWidth = styles.strokeWidth;
|
||||
const sw = 1 + strokeWidth * 1.618;
|
||||
const rx = Math.max(0, radius[0] - sw / 2);
|
||||
const ry = Math.max(0, radius[1] - sw / 2);
|
||||
return style.dash === DashStyle.Draw ? (
|
||||
<path d={getEllipseIndicatorPath(id, radius, style)} />
|
||||
) : (
|
||||
<ellipse cx={radius[0]} cy={radius[1]} rx={rx} ry={ry} />
|
||||
);
|
||||
});
|
||||
|
||||
override hitTestPoint = (shape: T, point: number[]): boolean => {
|
||||
return (
|
||||
Utils.pointInBounds(point, this.getRotatedBounds(shape)) &&
|
||||
Utils.pointInEllipse(
|
||||
point,
|
||||
this.getCenter(shape),
|
||||
shape.radius[0],
|
||||
shape.radius[1],
|
||||
shape.rotation || 0
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
override hitTestLineSegment = (
|
||||
shape: T,
|
||||
A: number[],
|
||||
B: number[]
|
||||
): boolean => {
|
||||
return intersectLineSegmentEllipse(
|
||||
A,
|
||||
B,
|
||||
this.getCenter(shape),
|
||||
shape.radius[0],
|
||||
shape.radius[1],
|
||||
shape.rotation || 0
|
||||
).didIntersect;
|
||||
};
|
||||
|
||||
getBounds = (shape: T) => {
|
||||
return Utils.getFromCache(this.boundsCache, shape, () => {
|
||||
return Utils.getRotatedEllipseBounds(
|
||||
shape.point[0],
|
||||
shape.point[1],
|
||||
shape.radius[0],
|
||||
shape.radius[1],
|
||||
0
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
override getRotatedBounds = (shape: T): TLBounds => {
|
||||
return Utils.getRotatedEllipseBounds(
|
||||
shape.point[0],
|
||||
shape.point[1],
|
||||
shape.radius[0],
|
||||
shape.radius[1],
|
||||
shape.rotation
|
||||
);
|
||||
};
|
||||
|
||||
override hitTestBounds = (shape: T, bounds: TLBounds): boolean => {
|
||||
const shapeBounds = this.getBounds(shape);
|
||||
|
||||
return (
|
||||
Utils.boundsContained(shapeBounds, bounds) ||
|
||||
intersectEllipseBounds(
|
||||
this.getCenter(shape),
|
||||
shape.radius[0],
|
||||
shape.radius[1],
|
||||
shape.rotation || 0,
|
||||
bounds
|
||||
).length > 0
|
||||
);
|
||||
};
|
||||
|
||||
override shouldRender = (prev: T, next: T): boolean => {
|
||||
return (
|
||||
next.radius !== prev.radius ||
|
||||
next.style !== prev.style ||
|
||||
next.label !== prev.label
|
||||
);
|
||||
};
|
||||
|
||||
override getCenter = (shape: T): number[] => {
|
||||
return Vec.add(shape.point, shape.radius);
|
||||
};
|
||||
|
||||
override getBindingPoint = <K extends TDShape>(
|
||||
shape: T,
|
||||
fromShape: K,
|
||||
point: number[],
|
||||
origin: number[],
|
||||
direction: number[],
|
||||
bindAnywhere: boolean
|
||||
) => {
|
||||
const expandedBounds = this.getExpandedBounds(shape);
|
||||
const center = this.getCenter(shape);
|
||||
let bindingPoint: number[];
|
||||
let distance: number;
|
||||
if (
|
||||
!Utils.pointInEllipse(
|
||||
point,
|
||||
center,
|
||||
shape.radius[0] + this.bindingDistance,
|
||||
shape.radius[1] + this.bindingDistance
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (bindAnywhere) {
|
||||
if (Vec.dist(point, this.getCenter(shape)) < 12) {
|
||||
bindingPoint = [0.5, 0.5];
|
||||
} else {
|
||||
bindingPoint = Vec.divV(
|
||||
Vec.sub(point, [expandedBounds.minX, expandedBounds.minY]),
|
||||
[expandedBounds.width, expandedBounds.height]
|
||||
);
|
||||
}
|
||||
distance = 0;
|
||||
} else {
|
||||
let intersection = intersectRayEllipse(
|
||||
origin,
|
||||
direction,
|
||||
center,
|
||||
shape.radius[0],
|
||||
shape.radius[1],
|
||||
shape.rotation || 0
|
||||
).points.sort(
|
||||
(a, b) => Vec.dist(a, origin) - Vec.dist(b, origin)
|
||||
)[0];
|
||||
if (!intersection) {
|
||||
intersection = intersectLineSegmentEllipse(
|
||||
point,
|
||||
center,
|
||||
center,
|
||||
shape.radius[0],
|
||||
shape.radius[1],
|
||||
shape.rotation || 0
|
||||
).points.sort(
|
||||
(a, b) => Vec.dist(a, point) - Vec.dist(b, point)
|
||||
)[0];
|
||||
}
|
||||
if (!intersection) {
|
||||
return undefined;
|
||||
}
|
||||
// The anchor is a point between the handle and the intersection
|
||||
const anchor = Vec.med(point, intersection);
|
||||
if (
|
||||
Vec.distanceToLineSegment(
|
||||
point,
|
||||
anchor,
|
||||
this.getCenter(shape)
|
||||
) < 12
|
||||
) {
|
||||
// If we're close to the center, snap to the center
|
||||
bindingPoint = [0.5, 0.5];
|
||||
} else {
|
||||
// Or else calculate a normalized point
|
||||
bindingPoint = Vec.divV(
|
||||
Vec.sub(anchor, [expandedBounds.minX, expandedBounds.minY]),
|
||||
[expandedBounds.width, expandedBounds.height]
|
||||
);
|
||||
}
|
||||
if (
|
||||
Utils.pointInEllipse(
|
||||
point,
|
||||
center,
|
||||
shape.radius[0],
|
||||
shape.radius[1],
|
||||
shape.rotation || 0
|
||||
)
|
||||
) {
|
||||
// Pad the arrow out by 16 points
|
||||
distance = this.bindingDistance / 2;
|
||||
} else {
|
||||
// Find the distance between the point and the ellipse
|
||||
const innerIntersection = intersectLineSegmentEllipse(
|
||||
point,
|
||||
center,
|
||||
center,
|
||||
shape.radius[0],
|
||||
shape.radius[1],
|
||||
shape.rotation || 0
|
||||
).points[0];
|
||||
if (!innerIntersection) return undefined;
|
||||
distance = Math.max(
|
||||
this.bindingDistance / 2,
|
||||
Vec.dist(point, innerIntersection)
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
point: bindingPoint,
|
||||
distance,
|
||||
};
|
||||
};
|
||||
|
||||
override transform = (
|
||||
shape: T,
|
||||
bounds: TLBounds,
|
||||
{ scaleX, scaleY, initialShape }: TransformInfo<T>
|
||||
): Partial<T> => {
|
||||
const { rotation = 0 } = initialShape;
|
||||
return {
|
||||
point: [bounds.minX, bounds.minY],
|
||||
radius: [bounds.width / 2, bounds.height / 2],
|
||||
rotation:
|
||||
(scaleX < 0 && scaleY >= 0) || (scaleY < 0 && scaleX >= 0)
|
||||
? -(rotation || 0)
|
||||
: rotation || 0,
|
||||
};
|
||||
};
|
||||
|
||||
override transformSingle = (shape: T, bounds: TLBounds): Partial<T> => {
|
||||
return {
|
||||
point: Vec.toFixed([bounds.minX, bounds.minY]),
|
||||
radius: Vec.div([bounds.width, bounds.height], 2),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const FullWrapper = styled('div')({ width: '100%', height: '100%' });
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as React from 'react';
|
||||
import { Utils } from '@tldraw/core';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import { getShapeStyle } from '../../shared';
|
||||
|
||||
interface EllipseSvgProps {
|
||||
radius: number[];
|
||||
style: ShapeStyles;
|
||||
isSelected: boolean;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const DashedEllipse = React.memo(function DashedEllipse({
|
||||
radius,
|
||||
style,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
}: EllipseSvgProps) {
|
||||
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode);
|
||||
const sw = 1 + strokeWidth * 1.618;
|
||||
const rx = Math.max(0, radius[0] - sw / 2);
|
||||
const ry = Math.max(0, radius[1] - sw / 2);
|
||||
const perimeter = Utils.perimeterOfEllipse(rx, ry);
|
||||
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
|
||||
perimeter < 64 ? perimeter * 2 : perimeter,
|
||||
strokeWidth * 1.618,
|
||||
style.dash,
|
||||
4
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ellipse
|
||||
className={
|
||||
style.isFilled || isSelected
|
||||
? 'tl-fill-hitarea'
|
||||
: 'tl-stroke-hitarea'
|
||||
}
|
||||
cx={radius[0]}
|
||||
cy={radius[1]}
|
||||
rx={radius[0]}
|
||||
ry={radius[1]}
|
||||
/>
|
||||
<ellipse
|
||||
cx={radius[0]}
|
||||
cy={radius[1]}
|
||||
rx={rx}
|
||||
ry={ry}
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
strokeWidth={sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
pointerEvents="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from 'react';
|
||||
import { getShapeStyle } from '../../shared';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import { getEllipseIndicatorPath, getEllipsePath } from '../ellipse-helpers';
|
||||
|
||||
interface EllipseSvgProps {
|
||||
id: string;
|
||||
radius: number[];
|
||||
style: ShapeStyles;
|
||||
isSelected: boolean;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
export const DrawEllipse = React.memo(function DrawEllipse({
|
||||
id,
|
||||
radius,
|
||||
style,
|
||||
isSelected,
|
||||
isDarkMode,
|
||||
}: EllipseSvgProps) {
|
||||
const { stroke, strokeWidth, fill } = getShapeStyle(style, isDarkMode);
|
||||
const innerPath = getEllipsePath(id, radius, style);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ellipse
|
||||
className={
|
||||
style.isFilled || isSelected
|
||||
? 'tl-fill-hitarea'
|
||||
: 'tl-stroke-hitarea'
|
||||
}
|
||||
cx={radius[0]}
|
||||
cy={radius[1]}
|
||||
rx={radius[0]}
|
||||
ry={radius[1]}
|
||||
/>
|
||||
{style.isFilled && (
|
||||
<path
|
||||
d={getEllipseIndicatorPath(id, radius, style)}
|
||||
stroke="none"
|
||||
fill={fill}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
d={innerPath}
|
||||
fill={stroke}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
pointerEvents="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Utils } from '@tldraw/core';
|
||||
import { getStrokeOutlinePoints, getStrokePoints } from 'perfect-freehand';
|
||||
import { EASINGS } from '@toeverything/components/board-types';
|
||||
import type { ShapeStyles } from '@toeverything/components/board-types';
|
||||
import { getShapeStyle } from '../shared/shape-styles';
|
||||
|
||||
export function getEllipseStrokePoints(
|
||||
id: string,
|
||||
radius: number[],
|
||||
style: ShapeStyles
|
||||
) {
|
||||
const { strokeWidth } = getShapeStyle(style);
|
||||
const getRandom = Utils.rng(id);
|
||||
const rx = radius[0] + getRandom() * strokeWidth * 2;
|
||||
const ry = radius[1] + getRandom() * strokeWidth * 2;
|
||||
const perimeter = Utils.perimeterOfEllipse(rx, ry);
|
||||
const points: number[][] = [];
|
||||
const start = Math.PI + Math.PI * getRandom();
|
||||
const extra = Math.abs(getRandom());
|
||||
const count = Math.max(16, perimeter / 10);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = EASINGS.easeInOutSine(i / (count + 1));
|
||||
const rads = start * 2 + Math.PI * (2 + extra) * t;
|
||||
const c = Math.cos(rads);
|
||||
const s = Math.sin(rads);
|
||||
points.push([
|
||||
rx * c + radius[0],
|
||||
ry * s + radius[1],
|
||||
t + 0.5 + getRandom() / 2,
|
||||
]);
|
||||
}
|
||||
return getStrokePoints(points, {
|
||||
size: 1 + strokeWidth * 2,
|
||||
thinning: 0.618,
|
||||
end: { taper: perimeter / 8 },
|
||||
start: { taper: perimeter / 12 },
|
||||
streamline: 0,
|
||||
simulatePressure: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function getEllipsePath(
|
||||
id: string,
|
||||
radius: number[],
|
||||
style: ShapeStyles
|
||||
) {
|
||||
const { strokeWidth } = getShapeStyle(style);
|
||||
const getRandom = Utils.rng(id);
|
||||
const rx = radius[0] + getRandom() * strokeWidth * 2;
|
||||
const ry = radius[1] + getRandom() * strokeWidth * 2;
|
||||
const perimeter = Utils.perimeterOfEllipse(rx, ry);
|
||||
return Utils.getSvgPathFromStroke(
|
||||
getStrokeOutlinePoints(getEllipseStrokePoints(id, radius, style), {
|
||||
size: 2 + strokeWidth * 2,
|
||||
thinning: 0.618,
|
||||
end: { taper: perimeter / 8 },
|
||||
start: { taper: perimeter / 12 },
|
||||
streamline: 0,
|
||||
simulatePressure: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function getEllipseIndicatorPath(
|
||||
id: string,
|
||||
radius: number[],
|
||||
style: ShapeStyles
|
||||
) {
|
||||
return Utils.getSvgPathFromStroke(
|
||||
getEllipseStrokePoints(id, radius, style).map(pt =>
|
||||
pt.point.slice(0, 2)
|
||||
),
|
||||
false
|
||||
);
|
||||
}
|
||||
1
libs/components/board-shapes/src/ellipse-util/index.ts
Normal file
1
libs/components/board-shapes/src/ellipse-util/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './EllipseUtil';
|
||||
Reference in New Issue
Block a user