mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-30 00:29:46 +08:00
feat(editor): add highlighter (#10573)
Closes: [BS-2909](https://linear.app/affine-design/issue/BS-2909/新增highlighter) ### What's Changed! Currently the highlighter tool is very similar to brush, but for the future, it's a standalone module. * Added `Highlighter` element model * Added `Highlighter` tool * Added `Highlighter` entry to the global toolbar
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
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 { DEFAULT_HIGHLIGHTER_LINE_WIDTH } from '../../consts';
|
||||
import { type Color, DefaultTheme } from '../../themes/index';
|
||||
|
||||
export type HighlighterProps = 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 HighlighterElementModel extends GfxPrimitiveElementModel<HighlighterProps> {
|
||||
/**
|
||||
* 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 'highlighter';
|
||||
}
|
||||
|
||||
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.hightlighterColor;
|
||||
|
||||
@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 = DEFAULT_HIGHLIGHTER_LINE_WIDTH;
|
||||
|
||||
@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<HighlighterProps> & HighlighterProps;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './highlighter';
|
||||
@@ -2,6 +2,7 @@ import type { EdgelessTextBlockModel } from '../blocks/edgeless-text/edgeless-te
|
||||
import type { BrushElementModel } from './brush/index.js';
|
||||
import type { ConnectorElementModel } from './connector/index.js';
|
||||
import type { GroupElementModel } from './group/index.js';
|
||||
import type { HighlighterElementModel } from './highlighter/index.js';
|
||||
import type { MindmapElementModel } from './mindmap/index.js';
|
||||
import type { ShapeElementModel } from './shape/index.js';
|
||||
import type { TextElementModel } from './text/index.js';
|
||||
@@ -9,12 +10,14 @@ import type { TextElementModel } from './text/index.js';
|
||||
export * from './brush/index.js';
|
||||
export * from './connector/index.js';
|
||||
export * from './group/index.js';
|
||||
export * from './highlighter/index.js';
|
||||
export * from './mindmap/index.js';
|
||||
export * from './shape/index.js';
|
||||
export * from './text/index.js';
|
||||
|
||||
export type SurfaceElementModelMap = {
|
||||
brush: BrushElementModel;
|
||||
highlighter: HighlighterElementModel;
|
||||
connector: ConnectorElementModel;
|
||||
group: GroupElementModel;
|
||||
mindmap: MindmapElementModel;
|
||||
|
||||
Reference in New Issue
Block a user