Files
AFFiNE-Mirror/blocksuite/affine/model/src/elements/brush/brush.ts

223 lines
5.5 KiB
TypeScript

import type {
BaseElementProps,
PointTestOptions,
} from '@blocksuite/block-std/gfx';
import {
convert,
derive,
field,
GfxPrimitiveElementModel,
watch,
} from '@blocksuite/block-std/gfx';
import {
Bound,
getBoundFromPoints,
getPointsFromBoundWithRotation,
getQuadBoundWithRotation,
getSolidStrokePoints,
getSvgPathFromStroke,
inflateBound,
isPointOnlines,
type IVec,
type IVec3,
lineIntersects,
PointLocation,
polyLineNearestPoint,
type SerializedXYWH,
transformPointsToNewBound,
Vec,
} from '@blocksuite/global/gfx';
import { type Color, DefaultTheme } from '../../themes/index';
export type BrushProps = BaseElementProps & {
/**
* [[x0,y0,pressure0?],[x1,y1,pressure1?]...]
* pressure is optional and exsits when pressure sensitivity is supported, otherwise not.
*/
points: number[][];
color: Color;
lineWidth: number;
};
export class BrushElementModel extends GfxPrimitiveElementModel<BrushProps> {
/**
* The SVG path commands for the brush.
*/
get commands() {
if (!this._local.has('commands')) {
const stroke = getSolidStrokePoints(this.points ?? [], this.lineWidth);
const commands = getSvgPathFromStroke(stroke);
this._local.set('commands', commands);
}
return this._local.get('commands') as string;
}
override get connectable() {
return false;
}
override get type() {
return 'brush';
}
override containsBound(bounds: Bound) {
const points = getPointsFromBoundWithRotation(this);
return points.some(point => bounds.containsPoint(point));
}
override getLineIntersections(start: IVec, end: IVec) {
const tl = [this.x, this.y];
const points = getPointsFromBoundWithRotation(this, _ =>
this.points.map(point => Vec.add(point, tl))
);
const box = Bound.fromDOMRect(getQuadBoundWithRotation(this));
if (box.w < 8 && box.h < 8) {
return Vec.distanceToLineSegment(start, end, box.center) < 5 ? [] : null;
}
if (box.intersectLine(start, end, true)) {
const len = points.length;
for (let i = 1; i < len; i++) {
const result = lineIntersects(start, end, points[i - 1], points[i]);
if (result) {
return [
new PointLocation(
result,
Vec.normalize(Vec.sub(points[i], points[i - 1]))
),
];
}
}
}
return null;
}
override getNearestPoint(point: IVec): IVec {
const { x, y } = this;
return polyLineNearestPoint(
this.points.map(p => Vec.add(p, [x, y])),
point
) as IVec;
}
override getRelativePointLocation(position: IVec): PointLocation {
const point = Bound.deserialize(this.xywh).getRelativePoint(position);
return new PointLocation(point);
}
override includesPoint(
px: number,
py: number,
options?: PointTestOptions
): boolean {
const hit = isPointOnlines(
Bound.deserialize(this.xywh),
this.points as [number, number][],
this.rotate,
[px, py],
(options?.hitThreshold ?? 10) / Math.min(options?.zoom ?? 1, 1)
);
return hit;
}
@field()
accessor color: Color = DefaultTheme.black;
@watch((_, instance) => {
instance['_local'].delete('commands');
})
@derive((lineWidth: number, instance: Instance) => {
const oldBound = instance.elementBound;
if (
lineWidth === instance.lineWidth ||
oldBound.w === 0 ||
oldBound.h === 0
)
return {};
const points = instance.points;
const transformed = transformPointsToNewBound(
points.map(([x, y]) => ({ x, y })),
oldBound,
instance.lineWidth / 2,
inflateBound(oldBound, lineWidth - instance.lineWidth),
lineWidth / 2
);
return {
points: transformed.points.map((p, i) => [
p.x,
p.y,
...(points[i][2] !== undefined ? [points[i][2]] : []),
]),
xywh: transformed.bound.serialize(),
};
})
@field()
accessor lineWidth: number = 4;
@watch((_, instance) => {
instance['_local'].delete('commands');
})
@derive((points: IVec[], instance: Instance) => {
const lineWidth = instance.lineWidth;
const bound = getBoundFromPoints(points);
const boundWidthLineWidth = inflateBound(bound, lineWidth);
return {
xywh: boundWidthLineWidth.serialize(),
};
})
@convert((points: (IVec | IVec3)[], instance) => {
const lineWidth = instance.lineWidth;
const bound = getBoundFromPoints(points as IVec[]);
const boundWidthLineWidth = inflateBound(bound, lineWidth);
const relativePoints = points.map(([x, y, pressure]) => [
x - boundWidthLineWidth.x,
y - boundWidthLineWidth.y,
...(pressure !== undefined ? [pressure] : []),
]);
return relativePoints;
})
@field()
accessor points: (IVec | IVec3)[] = [];
@field(0)
accessor rotate: number = 0;
@derive((xywh: SerializedXYWH, instance: Instance) => {
const bound = Bound.deserialize(xywh);
if (bound.w === instance.w && bound.h === instance.h) return {};
const { lineWidth } = instance;
const transformed = transformPointsToNewBound(
instance.points.map(([x, y]) => ({ x, y })),
instance,
instance.lineWidth / 2,
bound,
lineWidth / 2
);
return {
points: transformed.points.map((p, i) => [
p.x,
p.y,
...(instance.points[i][2] !== undefined ? [instance.points[i][2]] : []),
]),
};
})
@field()
accessor xywh: SerializedXYWH = '[0,0,0,0]';
}
type Instance = GfxPrimitiveElementModel<BrushProps> & BrushProps;