mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-29 08:09:52 +08:00
init: the first public commit for AFFiNE
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
import * as React from 'react';
|
||||
import { Utils, SVGContainer, TLBounds } from '@tldraw/core';
|
||||
import { Vec } from '@tldraw/vec';
|
||||
import { defaultStyle, getShapeStyle } from '../shared/shape-styles';
|
||||
import {
|
||||
DrawShape,
|
||||
DashStyle,
|
||||
TDShapeType,
|
||||
TransformInfo,
|
||||
TDMeta,
|
||||
GHOSTED_OPACITY,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TDShapeUtil } from '../TDShapeUtil';
|
||||
import {
|
||||
intersectBoundsBounds,
|
||||
intersectBoundsPolyline,
|
||||
intersectLineSegmentBounds,
|
||||
intersectLineSegmentLineSegment,
|
||||
} from '@tldraw/intersect';
|
||||
import {
|
||||
getDrawStrokePathTDSnapshot,
|
||||
getFillPath,
|
||||
getSolidStrokePathTDSnapshot,
|
||||
} from './draw-helpers';
|
||||
|
||||
type T = DrawShape;
|
||||
type E = SVGSVGElement;
|
||||
|
||||
export class DrawUtil extends TDShapeUtil<T, E> {
|
||||
type = TDShapeType.Draw as const;
|
||||
|
||||
pointsBoundsCache = new WeakMap<T['points'], TLBounds>([]);
|
||||
|
||||
shapeBoundsCache = new Map<string, TLBounds>();
|
||||
|
||||
rotatedCache = new WeakMap<T, number[][]>([]);
|
||||
|
||||
pointCache: Record<string, number[]> = {};
|
||||
|
||||
override canClone = true;
|
||||
|
||||
getShape = (props: Partial<T>): T => {
|
||||
return Utils.deepMerge<T>(
|
||||
{
|
||||
id: 'id',
|
||||
type: TDShapeType.Draw,
|
||||
name: 'Draw',
|
||||
parentId: 'page',
|
||||
childIndex: 1,
|
||||
point: [0, 0],
|
||||
rotation: 0,
|
||||
style: defaultStyle,
|
||||
points: [],
|
||||
isComplete: false,
|
||||
workspace: props.workspace,
|
||||
},
|
||||
props
|
||||
);
|
||||
};
|
||||
|
||||
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
||||
({ shape, meta, isSelected, isGhost, events }, ref) => {
|
||||
const { points, style, isComplete } = shape;
|
||||
|
||||
const polygon_path_td_snapshot = React.useMemo(() => {
|
||||
return getFillPath(shape);
|
||||
}, [points, style.strokeWidth]);
|
||||
|
||||
const path_td_snapshot = React.useMemo(() => {
|
||||
return style.dash === DashStyle.Draw
|
||||
? getDrawStrokePathTDSnapshot(shape)
|
||||
: getSolidStrokePathTDSnapshot(shape);
|
||||
}, [points, style.strokeWidth, style.dash, isComplete]);
|
||||
|
||||
const styles = getShapeStyle(style, meta.isDarkMode);
|
||||
const { stroke, fill, strokeWidth } = styles;
|
||||
|
||||
// For very short lines, draw a point instead of a line
|
||||
const bounds = this.getBounds(shape);
|
||||
|
||||
const verySmall =
|
||||
bounds.width <= strokeWidth / 2 &&
|
||||
bounds.height <= strokeWidth / 2;
|
||||
|
||||
if (verySmall) {
|
||||
const sw = 1 + strokeWidth;
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
|
||||
<circle
|
||||
r={sw}
|
||||
fill={stroke}
|
||||
stroke={stroke}
|
||||
pointerEvents="all"
|
||||
opacity={isGhost ? GHOSTED_OPACITY : 1}
|
||||
/>
|
||||
</SVGContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const shouldFill =
|
||||
style.isFilled &&
|
||||
points.length > 3 &&
|
||||
Vec.dist(points[0], points[points.length - 1]) <
|
||||
strokeWidth * 2;
|
||||
|
||||
if (shape.style.dash === DashStyle.Draw) {
|
||||
return (
|
||||
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
|
||||
<g opacity={isGhost ? GHOSTED_OPACITY : 1}>
|
||||
<path
|
||||
className={
|
||||
shouldFill || isSelected
|
||||
? 'tl-fill-hitarea'
|
||||
: 'tl-stroke-hitarea'
|
||||
}
|
||||
d={path_td_snapshot}
|
||||
/>
|
||||
{shouldFill && (
|
||||
<path
|
||||
d={polygon_path_td_snapshot}
|
||||
stroke="none"
|
||||
fill={fill}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
d={path_td_snapshot}
|
||||
fill={stroke}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth / 2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</g>
|
||||
</SVGContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// For solid, dash and dotted lines, draw a regular stroke path
|
||||
|
||||
const strokeDasharray = {
|
||||
[DashStyle.None]: 'none',
|
||||
[DashStyle.Draw]: 'none',
|
||||
[DashStyle.Solid]: `none`,
|
||||
[DashStyle.Dotted]: `0.1 ${strokeWidth * 4}`,
|
||||
[DashStyle.Dashed]: `${strokeWidth * 4} ${strokeWidth * 4}`,
|
||||
}[style.dash];
|
||||
|
||||
const strokeDashoffset = {
|
||||
[DashStyle.None]: 'none',
|
||||
[DashStyle.Draw]: 'none',
|
||||
[DashStyle.Solid]: `none`,
|
||||
[DashStyle.Dotted]: `0`,
|
||||
[DashStyle.Dashed]: `0`,
|
||||
}[style.dash];
|
||||
|
||||
const sw = 1 + strokeWidth * 1.5;
|
||||
|
||||
return (
|
||||
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
|
||||
<g opacity={isGhost ? GHOSTED_OPACITY : 1}>
|
||||
<path
|
||||
className={
|
||||
shouldFill && isSelected
|
||||
? 'tl-fill-hitarea'
|
||||
: 'tl-stroke-hitarea'
|
||||
}
|
||||
d={path_td_snapshot}
|
||||
/>
|
||||
<path
|
||||
d={path_td_snapshot}
|
||||
fill={shouldFill ? fill : 'none'}
|
||||
stroke="none"
|
||||
strokeWidth={Math.min(4, strokeWidth * 2)}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<path
|
||||
d={path_td_snapshot}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={sw}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
</g>
|
||||
</SVGContainer>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
|
||||
const { points } = shape;
|
||||
|
||||
const path_td_snapshot = React.useMemo(() => {
|
||||
return getSolidStrokePathTDSnapshot(shape);
|
||||
}, [points]);
|
||||
|
||||
const bounds = this.getBounds(shape);
|
||||
|
||||
const verySmall = bounds.width < 4 && bounds.height < 4;
|
||||
|
||||
if (verySmall) {
|
||||
return <circle x={bounds.width / 2} y={bounds.height / 2} r={1} />;
|
||||
}
|
||||
|
||||
return <path d={path_td_snapshot} />;
|
||||
});
|
||||
|
||||
override transform = (
|
||||
shape: T,
|
||||
bounds: TLBounds,
|
||||
{ initialShape, scaleX, scaleY }: TransformInfo<T>
|
||||
): Partial<T> => {
|
||||
const initialShapeBounds = Utils.getFromCache(
|
||||
this.boundsCache,
|
||||
initialShape,
|
||||
() => Utils.getBoundsFromPoints(initialShape.points)
|
||||
);
|
||||
|
||||
const points = initialShape.points.map(([x, y, r]) => {
|
||||
return [
|
||||
bounds.width *
|
||||
(scaleX < 0 // * sin?
|
||||
? 1 - x / initialShapeBounds.width
|
||||
: x / initialShapeBounds.width),
|
||||
bounds.height *
|
||||
(scaleY < 0 // * cos?
|
||||
? 1 - y / initialShapeBounds.height
|
||||
: y / initialShapeBounds.height),
|
||||
r,
|
||||
];
|
||||
});
|
||||
|
||||
const newBounds = Utils.getBoundsFromPoints(shape.points);
|
||||
|
||||
const point = Vec.sub(
|
||||
[bounds.minX, bounds.minY],
|
||||
[newBounds.minX, newBounds.minY]
|
||||
);
|
||||
|
||||
return {
|
||||
points,
|
||||
point,
|
||||
};
|
||||
};
|
||||
|
||||
getBounds = (shape: T) => {
|
||||
// The goal here is to avoid recalculating the bounds from the
|
||||
// points array, which is expensive. However, we still need a
|
||||
// new bounds if the point has changed, but we will reuse the
|
||||
// previous bounds-from-points result if we can.
|
||||
|
||||
const pointsHaveChanged = !this.pointsBoundsCache.has(shape.points);
|
||||
const pointHasChanged = !(this.pointCache[shape.id] === shape.point);
|
||||
|
||||
if (pointsHaveChanged) {
|
||||
// If the points have changed, then bust the points cache
|
||||
const bounds = Utils.getBoundsFromPoints(shape.points);
|
||||
this.pointsBoundsCache.set(shape.points, bounds);
|
||||
this.shapeBoundsCache.set(
|
||||
shape.id,
|
||||
Utils.translateBounds(bounds, shape.point)
|
||||
);
|
||||
this.pointCache[shape.id] = shape.point;
|
||||
} else if (pointHasChanged && !pointsHaveChanged) {
|
||||
// If the point have has changed, then bust the point cache
|
||||
this.pointCache[shape.id] = shape.point;
|
||||
this.shapeBoundsCache.set(
|
||||
shape.id,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
Utils.translateBounds(
|
||||
this.pointsBoundsCache.get(shape.points)!,
|
||||
shape.point
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return this.shapeBoundsCache.get(shape.id)!;
|
||||
};
|
||||
|
||||
override shouldRender = (prev: T, next: T) => {
|
||||
return (
|
||||
next.points !== prev.points ||
|
||||
next.style !== prev.style ||
|
||||
next.isComplete !== prev.isComplete
|
||||
);
|
||||
};
|
||||
|
||||
override hitTestPoint = (shape: T, point: number[]) => {
|
||||
const ptA = Vec.sub(point, shape.point);
|
||||
return Utils.pointInPolyline(ptA, shape.points);
|
||||
};
|
||||
|
||||
override hitTestLineSegment = (
|
||||
shape: T,
|
||||
A: number[],
|
||||
B: number[]
|
||||
): boolean => {
|
||||
const { points, point } = shape;
|
||||
const ptA = Vec.sub(A, point);
|
||||
const ptB = Vec.sub(B, point);
|
||||
const bounds = this.getBounds(shape);
|
||||
|
||||
if (points.length <= 2) {
|
||||
return Vec.distanceToLineSegment(A, B, shape.point) < 4;
|
||||
}
|
||||
|
||||
if (intersectLineSegmentBounds(ptA, ptB, bounds)) {
|
||||
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) => {
|
||||
// Test axis-aligned shape
|
||||
if (!shape.rotation) {
|
||||
const shapeBounds = this.getBounds(shape);
|
||||
|
||||
return (
|
||||
Utils.boundsContain(bounds, shapeBounds) ||
|
||||
((Utils.boundsContain(shapeBounds, bounds) ||
|
||||
intersectBoundsBounds(shapeBounds, bounds).length > 0) &&
|
||||
intersectBoundsPolyline(
|
||||
Utils.translateBounds(bounds, Vec.neg(shape.point)),
|
||||
shape.points
|
||||
).length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
// Test rotated shape
|
||||
const rBounds = this.getRotatedBounds(shape);
|
||||
|
||||
const rotatedBounds = Utils.getFromCache(
|
||||
this.rotatedCache,
|
||||
shape,
|
||||
() => {
|
||||
const c = Utils.getBoundsCenter(
|
||||
Utils.getBoundsFromPoints(shape.points)
|
||||
);
|
||||
return shape.points.map(pt =>
|
||||
Vec.rotWith(pt, c, shape.rotation || 0)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
Utils.boundsContain(bounds, rBounds) ||
|
||||
intersectBoundsPolyline(
|
||||
Utils.translateBounds(bounds, Vec.neg(shape.point)),
|
||||
rotatedBounds
|
||||
).length > 0
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Utils } from '@tldraw/core';
|
||||
import Vec from '@tldraw/vec';
|
||||
import {
|
||||
getStrokeOutlinePoints,
|
||||
getStrokePoints,
|
||||
StrokeOptions,
|
||||
} from 'perfect-freehand';
|
||||
import type { DrawShape } from '@toeverything/components/board-types';
|
||||
import { getShapeStyle } from '../shared/shape-styles';
|
||||
|
||||
const simulatePressureSettings: StrokeOptions = {
|
||||
easing: t => Math.sin((t * Math.PI) / 2),
|
||||
simulatePressure: true,
|
||||
};
|
||||
|
||||
const realPressureSettings: StrokeOptions = {
|
||||
easing: t => t * t,
|
||||
simulatePressure: false,
|
||||
};
|
||||
|
||||
export function getFreehandOptions(shape: DrawShape) {
|
||||
const styles = getShapeStyle(shape.style);
|
||||
|
||||
const options: StrokeOptions = {
|
||||
size: 1 + styles.strokeWidth * 1.5,
|
||||
thinning: 0.65,
|
||||
streamline: 0.65,
|
||||
smoothing: 0.65,
|
||||
...(shape.points[1][2] === 0.5
|
||||
? simulatePressureSettings
|
||||
: realPressureSettings),
|
||||
last: shape.isComplete,
|
||||
};
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function getFillPath(shape: DrawShape) {
|
||||
if (shape.points.length < 2) return '';
|
||||
|
||||
return Utils.getSvgPathFromStroke(
|
||||
getStrokePoints(shape.points, getFreehandOptions(shape)).map(
|
||||
pt => pt.point
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function getDrawStrokePoints(shape: DrawShape, options: StrokeOptions) {
|
||||
return getStrokePoints(shape.points, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path data for a stroke with the DashStyle.Draw dash style.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export function getDrawStrokePathTDSnapshot(shape: DrawShape) {
|
||||
if (shape.points.length < 2) return '';
|
||||
const options = getFreehandOptions(shape);
|
||||
const strokePoints = getDrawStrokePoints(shape, options);
|
||||
const path = Utils.getSvgPathFromStroke(
|
||||
getStrokeOutlinePoints(strokePoints, options)
|
||||
);
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SVG path data for a shape that has a DashStyle other than DashStyles.Draw.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export function getSolidStrokePathTDSnapshot(shape: DrawShape) {
|
||||
const { points } = shape;
|
||||
if (points.length < 2) return 'M 0 0 L 0 0';
|
||||
const options = getFreehandOptions(shape);
|
||||
const strokePoints = getDrawStrokePoints(shape, options).map(pt =>
|
||||
pt.point.slice(0, 2)
|
||||
);
|
||||
const last = points[points.length - 1].slice(0, 2);
|
||||
if (!Vec.isEqual(strokePoints[0], last)) strokePoints.push(last);
|
||||
const path = Utils.getSvgPathFromStroke(strokePoints, false);
|
||||
return path;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './DrawUtil';
|
||||
Reference in New Issue
Block a user