mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-30 00:29:46 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
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/utils';
|
||||
|
||||
import type { Color } from '../../consts/index.js';
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
static override propsToY(props: BrushProps) {
|
||||
return props;
|
||||
}
|
||||
|
||||
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 = '#000000';
|
||||
|
||||
@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;
|
||||
|
||||
declare global {
|
||||
namespace BlockSuite {
|
||||
interface SurfaceElementModelMap {
|
||||
brush: BrushElementModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './brush.js';
|
||||
@@ -0,0 +1,521 @@
|
||||
import type {
|
||||
BaseElementProps,
|
||||
PointTestOptions,
|
||||
SerializedElement,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import {
|
||||
derive,
|
||||
field,
|
||||
GfxPrimitiveElementModel,
|
||||
local,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import type { IVec, SerializedXYWH, XYWH } from '@blocksuite/global/utils';
|
||||
import {
|
||||
Bound,
|
||||
curveIntersects,
|
||||
getBezierNearestPoint,
|
||||
getBezierNearestTime,
|
||||
getBezierParameters,
|
||||
getBezierPoint,
|
||||
linePolylineIntersects,
|
||||
PointLocation,
|
||||
Polyline,
|
||||
polyLineNearestPoint,
|
||||
Vec,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { DocCollection, type Y } from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
type Color,
|
||||
CONNECTOR_LABEL_MAX_WIDTH,
|
||||
ConnectorLabelOffsetAnchor,
|
||||
ConnectorMode,
|
||||
DEFAULT_ROUGHNESS,
|
||||
FontFamily,
|
||||
FontStyle,
|
||||
FontWeight,
|
||||
type PointStyle,
|
||||
StrokeStyle,
|
||||
TextAlign,
|
||||
type TextStyleProps,
|
||||
} from '../../consts/index.js';
|
||||
|
||||
export type SerializedConnection = {
|
||||
id?: string;
|
||||
position?: `[${number},${number}]` | PointLocation;
|
||||
};
|
||||
|
||||
// at least one of id and position is not null
|
||||
// both exists means the position is relative to the element
|
||||
export type Connection = {
|
||||
id?: string;
|
||||
position?: [number, number];
|
||||
};
|
||||
|
||||
export const getConnectorModeName = (mode: ConnectorMode) => {
|
||||
return {
|
||||
[ConnectorMode.Straight]: 'Straight',
|
||||
[ConnectorMode.Orthogonal]: 'Elbowed',
|
||||
[ConnectorMode.Curve]: 'Curve',
|
||||
}[mode];
|
||||
};
|
||||
|
||||
export type ConnectorLabelOffsetProps = {
|
||||
// [0, 1], `0.5` by default
|
||||
distance: number;
|
||||
// `center` by default
|
||||
anchor?: ConnectorLabelOffsetAnchor;
|
||||
};
|
||||
|
||||
export type ConnectorLabelConstraintsProps = {
|
||||
hasMaxWidth: boolean;
|
||||
maxWidth: number;
|
||||
};
|
||||
|
||||
export type ConnectorLabelProps = {
|
||||
// Label's content
|
||||
text?: Y.Text;
|
||||
labelEditing?: boolean;
|
||||
labelDisplay?: boolean;
|
||||
labelXYWH?: XYWH;
|
||||
labelOffset?: ConnectorLabelOffsetProps;
|
||||
labelStyle?: TextStyleProps;
|
||||
labelConstraints?: ConnectorLabelConstraintsProps;
|
||||
};
|
||||
|
||||
export type SerializedConnectorElement = SerializedElement & {
|
||||
source: SerializedConnection;
|
||||
target: SerializedConnection;
|
||||
};
|
||||
|
||||
export type ConnectorElementProps = BaseElementProps & {
|
||||
mode: ConnectorMode;
|
||||
stroke: Color;
|
||||
strokeWidth: number;
|
||||
strokeStyle: StrokeStyle;
|
||||
roughness?: number;
|
||||
rough?: boolean;
|
||||
source: Connection;
|
||||
target: Connection;
|
||||
|
||||
frontEndpointStyle?: PointStyle;
|
||||
rearEndpointStyle?: PointStyle;
|
||||
} & ConnectorLabelProps;
|
||||
|
||||
export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorElementProps> {
|
||||
updatingPath = false;
|
||||
|
||||
override get connectable() {
|
||||
return false as const;
|
||||
}
|
||||
|
||||
get connected() {
|
||||
return !!(this.source.id || this.target.id);
|
||||
}
|
||||
|
||||
override get elementBound() {
|
||||
let bounds = super.elementBound;
|
||||
if (this.hasLabel()) {
|
||||
bounds = bounds.unite(Bound.fromXYWH(this.labelXYWH!));
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
|
||||
get type() {
|
||||
return 'connector';
|
||||
}
|
||||
|
||||
static override propsToY(props: ConnectorElementProps) {
|
||||
if (props.text && !(props.text instanceof DocCollection.Y.Text)) {
|
||||
props.text = new DocCollection.Y.Text(props.text);
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
override containsBound(bounds: Bound) {
|
||||
return (
|
||||
this.absolutePath.some(point => bounds.containsPoint(point)) ||
|
||||
(this.hasLabel() &&
|
||||
Bound.fromXYWH(this.labelXYWH!).points.some(p =>
|
||||
bounds.containsPoint(p)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
override getLineIntersections(start: IVec, end: IVec) {
|
||||
const { mode, absolutePath: path } = this;
|
||||
|
||||
let intersected = null;
|
||||
|
||||
if (mode === ConnectorMode.Curve && path.length > 1) {
|
||||
intersected = curveIntersects(path, [start, end]);
|
||||
} else {
|
||||
intersected = linePolylineIntersects(start, end, path);
|
||||
}
|
||||
|
||||
if (!intersected && this.hasLabel()) {
|
||||
intersected = linePolylineIntersects(
|
||||
start,
|
||||
end,
|
||||
Bound.fromXYWH(this.labelXYWH!).points
|
||||
);
|
||||
}
|
||||
|
||||
return intersected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the closest point on the curve via a point.
|
||||
*/
|
||||
override getNearestPoint(point: IVec): IVec {
|
||||
const { mode, absolutePath: path } = this;
|
||||
|
||||
if (mode === ConnectorMode.Straight) {
|
||||
const first = path[0];
|
||||
const last = path[path.length - 1];
|
||||
return Vec.nearestPointOnLineSegment(first, last, point, true);
|
||||
}
|
||||
|
||||
if (mode === ConnectorMode.Orthogonal) {
|
||||
const points = path.map<IVec>(p => [p[0], p[1]]);
|
||||
return Polyline.nearestPoint(points, point);
|
||||
}
|
||||
|
||||
const b = getBezierParameters(path);
|
||||
const t = getBezierNearestTime(b, point);
|
||||
const p = getBezierPoint(b, t);
|
||||
if (p) return p;
|
||||
|
||||
const { x, y } = this;
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculating the computed distance along a path via a point.
|
||||
*
|
||||
* The point is relative to the viewport.
|
||||
*/
|
||||
getOffsetDistanceByPoint(point: IVec, bounds?: Bound) {
|
||||
const { mode, absolutePath: path } = this;
|
||||
|
||||
let { x, y, w, h } = this;
|
||||
if (bounds) {
|
||||
x = bounds.x;
|
||||
y = bounds.y;
|
||||
w = bounds.w;
|
||||
h = bounds.h;
|
||||
}
|
||||
|
||||
point[0] = Vec.clamp(point[0], x, x + w);
|
||||
point[1] = Vec.clamp(point[1], y, y + h);
|
||||
|
||||
if (mode === ConnectorMode.Straight) {
|
||||
const s = path[0];
|
||||
const e = path[path.length - 1];
|
||||
const pl = Vec.dist(s, point);
|
||||
const fl = Vec.dist(s, e);
|
||||
return pl / fl;
|
||||
}
|
||||
|
||||
if (mode === ConnectorMode.Orthogonal) {
|
||||
const points = path.map<IVec>(p => [p[0], p[1]]);
|
||||
const p = Polyline.nearestPoint(points, point);
|
||||
const pl = Polyline.lenAtPoint(points, p);
|
||||
const fl = Polyline.len(points);
|
||||
return pl / fl;
|
||||
}
|
||||
|
||||
const b = getBezierParameters(path);
|
||||
return getBezierNearestTime(b, point);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculating the computed point along a path via a offset distance.
|
||||
*
|
||||
* Returns a point relative to the viewport.
|
||||
*/
|
||||
getPointByOffsetDistance(offsetDistance = 0.5, bounds?: Bound): IVec {
|
||||
const { mode, absolutePath: path } = this;
|
||||
|
||||
if (mode === ConnectorMode.Straight) {
|
||||
const first = path[0];
|
||||
const last = path[path.length - 1];
|
||||
return Vec.lrp(first, last, offsetDistance);
|
||||
}
|
||||
|
||||
let { x, y, w, h } = this;
|
||||
if (bounds) {
|
||||
x = bounds.x;
|
||||
y = bounds.y;
|
||||
w = bounds.w;
|
||||
h = bounds.h;
|
||||
}
|
||||
|
||||
if (mode === ConnectorMode.Orthogonal) {
|
||||
const points = path.map<IVec>(p => [p[0], p[1]]);
|
||||
const point = Polyline.pointAt(points, offsetDistance);
|
||||
if (point) return point;
|
||||
return [x + w / 2, y + h / 2];
|
||||
}
|
||||
|
||||
const b = getBezierParameters(path);
|
||||
const point = getBezierPoint(b, offsetDistance);
|
||||
if (point) return point;
|
||||
return [x + w / 2, y + h / 2];
|
||||
}
|
||||
|
||||
override getRelativePointLocation(point: IVec): PointLocation {
|
||||
return new PointLocation(
|
||||
Bound.deserialize(this.xywh).getRelativePoint(point)
|
||||
);
|
||||
}
|
||||
|
||||
hasLabel() {
|
||||
return Boolean(!this.lableEditing && this.labelDisplay && this.labelXYWH);
|
||||
}
|
||||
|
||||
override includesPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
options?: PointTestOptions | undefined
|
||||
): boolean {
|
||||
const currentPoint: IVec = [x, y];
|
||||
|
||||
if (this.labelIncludesPoint(currentPoint as IVec)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { mode, strokeWidth, absolutePath: path } = this;
|
||||
|
||||
const point =
|
||||
mode === ConnectorMode.Curve
|
||||
? getBezierNearestPoint(getBezierParameters(path), currentPoint)
|
||||
: polyLineNearestPoint(path, currentPoint);
|
||||
|
||||
return (
|
||||
Vec.dist(point, currentPoint) <
|
||||
(options?.hitThreshold ? strokeWidth / 2 : 0) + 8
|
||||
);
|
||||
}
|
||||
|
||||
labelIncludesPoint(point: IVec) {
|
||||
return (
|
||||
this.hasLabel() && Bound.fromXYWH(this.labelXYWH!).isPointInBound(point)
|
||||
);
|
||||
}
|
||||
|
||||
moveTo(bound: Bound) {
|
||||
const oldBound = Bound.deserialize(this.xywh);
|
||||
const offset = Vec.sub([bound.x, bound.y], [oldBound.x, oldBound.y]);
|
||||
const { source, target } = this;
|
||||
|
||||
if (!source.id && source.position) {
|
||||
this.source = {
|
||||
position: Vec.add(source.position, offset) as [number, number],
|
||||
};
|
||||
}
|
||||
|
||||
if (!target.id && target.position) {
|
||||
this.target = {
|
||||
position: Vec.add(target.position, offset) as [number, number],
|
||||
};
|
||||
}
|
||||
|
||||
// Updates Connector's Label position.
|
||||
if (this.hasLabel()) {
|
||||
const [x, y, w, h] = this.labelXYWH!;
|
||||
this.labelXYWH = [x + offset[0], y + offset[1], w, h];
|
||||
}
|
||||
}
|
||||
|
||||
resize(bounds: Bound, originalPath: PointLocation[], matrix: DOMMatrix) {
|
||||
this.updatingPath = false;
|
||||
|
||||
const path = this.resizePath(originalPath, matrix);
|
||||
|
||||
// the property assignment order matters
|
||||
this.xywh = bounds.serialize();
|
||||
this.path = path.map(p => p.clone().setVec(Vec.sub(p, bounds.tl)));
|
||||
|
||||
const props: {
|
||||
labelXYWH?: XYWH;
|
||||
source?: Connection;
|
||||
target?: Connection;
|
||||
} = {};
|
||||
|
||||
// Updates Connector's Label position.
|
||||
if (this.hasLabel()) {
|
||||
const [cx, cy] = this.getPointByOffsetDistance(this.labelOffset.distance);
|
||||
const [, , w, h] = this.labelXYWH!;
|
||||
props.labelXYWH = [cx - w / 2, cy - h / 2, w, h];
|
||||
}
|
||||
|
||||
if (!this.source.id) {
|
||||
props.source = {
|
||||
...this.source,
|
||||
position: path[0].toVec() as [number, number],
|
||||
};
|
||||
}
|
||||
if (!this.target.id) {
|
||||
props.target = {
|
||||
...this.target,
|
||||
position: path[path.length - 1].toVec() as [number, number],
|
||||
};
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
resizePath(originalPath: PointLocation[], matrix: DOMMatrix) {
|
||||
if (this.mode === ConnectorMode.Curve) {
|
||||
return originalPath.map(point => {
|
||||
const [p, t, absIn, absOut] = [
|
||||
point,
|
||||
point.tangent,
|
||||
point.absIn,
|
||||
point.absOut,
|
||||
]
|
||||
.map(p => new DOMPoint(...p).matrixTransform(matrix))
|
||||
.map(p => [p.x, p.y] as IVec);
|
||||
const ip = Vec.sub(absIn, p);
|
||||
const op = Vec.sub(absOut, p);
|
||||
return new PointLocation(p, t, ip, op);
|
||||
});
|
||||
}
|
||||
|
||||
return originalPath.map(point => {
|
||||
const { x, y } = new DOMPoint(...point).matrixTransform(matrix);
|
||||
const p: IVec = [x, y];
|
||||
return PointLocation.fromVec(p);
|
||||
});
|
||||
}
|
||||
|
||||
override serialize() {
|
||||
const result = super.serialize();
|
||||
result.xywh = this.xywh;
|
||||
result.source = structuredClone(this.source);
|
||||
result.target = structuredClone(this.target);
|
||||
return result as SerializedConnectorElement;
|
||||
}
|
||||
|
||||
@local()
|
||||
accessor absolutePath: PointLocation[] = [];
|
||||
|
||||
@field('None' as PointStyle)
|
||||
accessor frontEndpointStyle!: PointStyle;
|
||||
|
||||
/**
|
||||
* Defines the size constraints of the label.
|
||||
*/
|
||||
@field({
|
||||
hasMaxWidth: true,
|
||||
maxWidth: CONNECTOR_LABEL_MAX_WIDTH,
|
||||
} as ConnectorLabelConstraintsProps)
|
||||
accessor labelConstraints!: ConnectorLabelConstraintsProps;
|
||||
|
||||
/**
|
||||
* Control display and hide.
|
||||
*/
|
||||
@field(true)
|
||||
accessor labelDisplay!: boolean;
|
||||
|
||||
/**
|
||||
* The offset property specifies the label along the connector path.
|
||||
*/
|
||||
@field({
|
||||
distance: 0.5,
|
||||
anchor: ConnectorLabelOffsetAnchor.Center,
|
||||
} as ConnectorLabelOffsetProps)
|
||||
accessor labelOffset!: ConnectorLabelOffsetProps;
|
||||
|
||||
/**
|
||||
* Defines the style of the label.
|
||||
*/
|
||||
@field({
|
||||
color: '#000000',
|
||||
fontFamily: FontFamily.Inter,
|
||||
fontSize: 16,
|
||||
fontStyle: FontStyle.Normal,
|
||||
fontWeight: FontWeight.Regular,
|
||||
textAlign: TextAlign.Center,
|
||||
} as TextStyleProps)
|
||||
accessor labelStyle!: TextStyleProps;
|
||||
|
||||
/**
|
||||
* Returns a `XYWH` array providing information about the size of a label
|
||||
* and its position relative to the viewport.
|
||||
*/
|
||||
@field()
|
||||
accessor labelXYWH: XYWH | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Local control display and hide, mainly used in editing scenarios.
|
||||
*/
|
||||
@local()
|
||||
accessor lableEditing: boolean = false;
|
||||
|
||||
@field()
|
||||
accessor mode: ConnectorMode = ConnectorMode.Orthogonal;
|
||||
|
||||
@derive((path: PointLocation[], instance) => {
|
||||
const { x, y } = instance;
|
||||
|
||||
return {
|
||||
absolutePath: path.map(p => p.clone().setVec(Vec.add(p, [x, y]))),
|
||||
};
|
||||
})
|
||||
@local()
|
||||
accessor path: PointLocation[] = [];
|
||||
|
||||
@field('Arrow' as PointStyle)
|
||||
accessor rearEndpointStyle!: PointStyle;
|
||||
|
||||
@local()
|
||||
accessor rotate: number = 0;
|
||||
|
||||
@field()
|
||||
accessor rough: boolean | undefined = undefined;
|
||||
|
||||
@field()
|
||||
accessor roughness: number = DEFAULT_ROUGHNESS;
|
||||
|
||||
@field()
|
||||
accessor source: Connection = {
|
||||
position: [0, 0],
|
||||
};
|
||||
|
||||
@field()
|
||||
accessor stroke: Color = '#000000';
|
||||
|
||||
@field()
|
||||
accessor strokeStyle: StrokeStyle = StrokeStyle.Solid;
|
||||
|
||||
@field()
|
||||
accessor strokeWidth: number = 4;
|
||||
|
||||
@field()
|
||||
accessor target: Connection = {
|
||||
position: [0, 0],
|
||||
};
|
||||
|
||||
/**
|
||||
* The content of the label.
|
||||
*/
|
||||
@field()
|
||||
accessor text: Y.Text | undefined = undefined;
|
||||
|
||||
@local()
|
||||
accessor xywh: SerializedXYWH = '[0,0,0,0]';
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace BlockSuite {
|
||||
interface SurfaceElementModelMap {
|
||||
connector: ConnectorElementModel;
|
||||
}
|
||||
interface EdgelessTextModelMap {
|
||||
connector: ConnectorElementModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './connector.js';
|
||||
export * from './local-connector.js';
|
||||
@@ -0,0 +1,66 @@
|
||||
import { GfxLocalElementModel } from '@blocksuite/block-std/gfx';
|
||||
import type { PointLocation } from '@blocksuite/global/utils';
|
||||
|
||||
import {
|
||||
type Color,
|
||||
ConnectorMode,
|
||||
DEFAULT_ROUGHNESS,
|
||||
type PointStyle,
|
||||
StrokeStyle,
|
||||
} from '../../consts/index.js';
|
||||
import type { Connection } from './connector.js';
|
||||
|
||||
export class LocalConnectorElementModel extends GfxLocalElementModel {
|
||||
private _path: PointLocation[] = [];
|
||||
|
||||
absolutePath: PointLocation[] = [];
|
||||
|
||||
frontEndpointStyle!: PointStyle;
|
||||
|
||||
mode: ConnectorMode = ConnectorMode.Orthogonal;
|
||||
|
||||
rearEndpointStyle!: PointStyle;
|
||||
|
||||
rough?: boolean;
|
||||
|
||||
roughness: number = DEFAULT_ROUGHNESS;
|
||||
|
||||
source: Connection = {
|
||||
position: [0, 0],
|
||||
};
|
||||
|
||||
stroke: Color = '#000000';
|
||||
|
||||
strokeStyle: StrokeStyle = StrokeStyle.Solid;
|
||||
|
||||
strokeWidth: number = 4;
|
||||
|
||||
target: Connection = {
|
||||
position: [0, 0],
|
||||
};
|
||||
|
||||
updatingPath = false;
|
||||
|
||||
get path(): PointLocation[] {
|
||||
return this._path;
|
||||
}
|
||||
|
||||
set path(value: PointLocation[]) {
|
||||
const { x, y } = this;
|
||||
|
||||
this._path = value;
|
||||
this.absolutePath = value.map(p => p.clone().setVec([p[0] + x, p[1] + y]));
|
||||
}
|
||||
|
||||
get type() {
|
||||
return 'connector';
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace BlockSuite {
|
||||
interface SurfaceLocalModelMap {
|
||||
connector: LocalConnectorElementModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type {
|
||||
BaseElementProps,
|
||||
GfxModel,
|
||||
SerializedElement,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import {
|
||||
canSafeAddToContainer,
|
||||
field,
|
||||
GfxGroupLikeElementModel,
|
||||
local,
|
||||
observe,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import type { IVec, PointLocation } from '@blocksuite/global/utils';
|
||||
import { Bound, keys, linePolygonIntersects } from '@blocksuite/global/utils';
|
||||
import type { Y } from '@blocksuite/store';
|
||||
import { DocCollection } from '@blocksuite/store';
|
||||
|
||||
type GroupElementProps = BaseElementProps & {
|
||||
children: Y.Map<boolean>;
|
||||
title: Y.Text;
|
||||
};
|
||||
|
||||
export type SerializedGroupElement = SerializedElement & {
|
||||
title: string;
|
||||
children: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export class GroupElementModel extends GfxGroupLikeElementModel<GroupElementProps> {
|
||||
get rotate() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
set rotate(_: number) {}
|
||||
|
||||
get type() {
|
||||
return 'group';
|
||||
}
|
||||
|
||||
static override propsToY(props: Record<string, unknown>) {
|
||||
if ('title' in props && !(props.title instanceof DocCollection.Y.Text)) {
|
||||
props.title = new DocCollection.Y.Text(props.title as string);
|
||||
}
|
||||
|
||||
if (props.children && !(props.children instanceof DocCollection.Y.Map)) {
|
||||
const children = new DocCollection.Y.Map() as Y.Map<boolean>;
|
||||
|
||||
keys(props.children).forEach(key => {
|
||||
children.set(key as string, true);
|
||||
});
|
||||
|
||||
props.children = children;
|
||||
}
|
||||
|
||||
return props as GroupElementProps;
|
||||
}
|
||||
|
||||
override addChild(element: GfxModel) {
|
||||
if (!canSafeAddToContainer(this, element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.surface.doc.transact(() => {
|
||||
this.children.set(element.id, true);
|
||||
});
|
||||
}
|
||||
|
||||
override containsBound(bound: Bound): boolean {
|
||||
return bound.contains(Bound.deserialize(this.xywh));
|
||||
}
|
||||
|
||||
override getLineIntersections(
|
||||
start: IVec,
|
||||
end: IVec
|
||||
): PointLocation[] | null {
|
||||
const bound = Bound.deserialize(this.xywh);
|
||||
return linePolygonIntersects(start, end, bound.points);
|
||||
}
|
||||
|
||||
removeChild(element: GfxModel) {
|
||||
if (!this.children) {
|
||||
return;
|
||||
}
|
||||
this.surface.doc.transact(() => {
|
||||
this.children.delete(element.id);
|
||||
});
|
||||
}
|
||||
|
||||
override serialize() {
|
||||
const result = super.serialize();
|
||||
return result as SerializedGroupElement;
|
||||
}
|
||||
|
||||
@observe(
|
||||
// use `GroupElementModel` type in decorator will cause playwright error
|
||||
(_, instance: GfxGroupLikeElementModel<GroupElementProps>, transaction) => {
|
||||
if (instance.children.doc) {
|
||||
instance.setChildIds(
|
||||
Array.from(instance.children.keys()),
|
||||
transaction?.local ?? false
|
||||
);
|
||||
}
|
||||
}
|
||||
)
|
||||
@field()
|
||||
accessor children: Y.Map<boolean> = new DocCollection.Y.Map<boolean>();
|
||||
|
||||
@local()
|
||||
accessor showTitle: boolean = true;
|
||||
|
||||
@field()
|
||||
accessor title: Y.Text = new DocCollection.Y.Text();
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace BlockSuite {
|
||||
interface SurfaceGroupLikeModelMap {
|
||||
group: GroupElementModel;
|
||||
}
|
||||
|
||||
interface SurfaceElementModelMap {
|
||||
group: GroupElementModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './group.js';
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './brush/index.js';
|
||||
export * from './connector/index.js';
|
||||
export * from './group/index.js';
|
||||
export * from './mindmap/index.js';
|
||||
export * from './shape/index.js';
|
||||
export * from './text/index.js';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './mindmap.js';
|
||||
export * from './style.js';
|
||||
@@ -0,0 +1,975 @@
|
||||
import type {
|
||||
BaseElementProps,
|
||||
GfxModel,
|
||||
PointTestOptions,
|
||||
SerializedElement,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import {
|
||||
convert,
|
||||
field,
|
||||
GfxGroupLikeElementModel,
|
||||
observe,
|
||||
watch,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import type { Bound, SerializedXYWH, XYWH } from '@blocksuite/global/utils';
|
||||
import {
|
||||
assertType,
|
||||
deserializeXYWH,
|
||||
keys,
|
||||
last,
|
||||
noop,
|
||||
pick,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { DocCollection, type Y } from '@blocksuite/store';
|
||||
import { generateKeyBetween } from 'fractional-indexing';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ConnectorMode } from '../../consts/connector.js';
|
||||
import { LayoutType, MindmapStyle } from '../../consts/mindmap.js';
|
||||
import { LocalConnectorElementModel } from '../connector/local-connector.js';
|
||||
import type { MindmapStyleGetter } from './style.js';
|
||||
import { mindmapStyleGetters } from './style.js';
|
||||
import { findInfiniteLoop } from './utils.js';
|
||||
|
||||
export type NodeDetail = {
|
||||
/**
|
||||
* The index of the node, it decides the layout order of the node
|
||||
*/
|
||||
index: string;
|
||||
parent?: string;
|
||||
collapsed?: boolean;
|
||||
};
|
||||
|
||||
export type MindmapNode = {
|
||||
id: string;
|
||||
detail: NodeDetail;
|
||||
|
||||
element: BlockSuite.SurfaceElementModel;
|
||||
children: MindmapNode[];
|
||||
|
||||
parent: MindmapNode | null;
|
||||
|
||||
/**
|
||||
* This area is used to determine where to place the dragged node.
|
||||
*
|
||||
* When dragging another node into this area, it will become a sibling of the this node.
|
||||
* But if it is dragged into the small area located right after the this node, it will become a child of the this node.
|
||||
*/
|
||||
responseArea?: Bound;
|
||||
|
||||
/**
|
||||
* This property override the preferredDir or default layout direction.
|
||||
* It is used during dragging that would temporary change the layout direction
|
||||
*/
|
||||
overriddenDir?: LayoutType;
|
||||
};
|
||||
|
||||
export type MindmapRoot = MindmapNode & {
|
||||
left: MindmapNode[];
|
||||
right: MindmapNode[];
|
||||
};
|
||||
|
||||
const baseNodeSchema = z.object({
|
||||
text: z.string(),
|
||||
xywh: z.optional(z.string()),
|
||||
});
|
||||
|
||||
type Node = z.infer<typeof baseNodeSchema> & {
|
||||
children?: Node[];
|
||||
};
|
||||
|
||||
const nodeSchema: z.ZodType<Node> = baseNodeSchema.extend({
|
||||
children: z.lazy(() => nodeSchema.array()).optional(),
|
||||
});
|
||||
|
||||
export type NodeType = z.infer<typeof nodeSchema>;
|
||||
|
||||
function isNodeType(node: Record<string, unknown>): node is NodeType {
|
||||
return typeof node.text === 'string' && Array.isArray(node.children);
|
||||
}
|
||||
|
||||
export type SerializedMindmapElement = SerializedElement & {
|
||||
children: Record<string, NodeDetail>;
|
||||
};
|
||||
|
||||
type MindmapElementProps = BaseElementProps & {
|
||||
children: Y.Map<NodeDetail>;
|
||||
};
|
||||
|
||||
function observeChildren(
|
||||
_: unknown,
|
||||
instance: MindmapElementModel,
|
||||
transaction: Y.Transaction | null
|
||||
) {
|
||||
if (instance.children.doc) {
|
||||
instance.setChildIds(
|
||||
Array.from(instance.children.keys()),
|
||||
transaction?.local ?? true
|
||||
);
|
||||
|
||||
instance.buildTree();
|
||||
instance.connectors.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function watchLayoutType(
|
||||
_: unknown,
|
||||
instance: MindmapElementModel,
|
||||
local: boolean
|
||||
) {
|
||||
if (!local) {
|
||||
return;
|
||||
}
|
||||
|
||||
instance.surface.doc.transact(() => {
|
||||
instance['_tree']?.children.forEach(child => {
|
||||
if (!instance.children.has(child.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
instance.children.set(child.id, {
|
||||
index: child.detail.index,
|
||||
parent: child.detail.parent,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
instance.buildTree();
|
||||
}
|
||||
|
||||
function watchStyle(_: unknown, instance: MindmapElementModel, local: boolean) {
|
||||
if (!local) return;
|
||||
instance.layout();
|
||||
}
|
||||
|
||||
export class MindmapElementModel extends GfxGroupLikeElementModel<MindmapElementProps> {
|
||||
private _layout: MindmapElementModel['layout'] | null = null;
|
||||
|
||||
private _nodeMap = new Map<string, MindmapNode>();
|
||||
|
||||
private _queueBuildTree = false;
|
||||
|
||||
private _queuedLayout = false;
|
||||
|
||||
private _stashedNode = new Set<string>();
|
||||
|
||||
private _tree!: MindmapRoot;
|
||||
|
||||
connectors = new Map<string, LocalConnectorElementModel>();
|
||||
|
||||
get nodeMap() {
|
||||
return this._nodeMap;
|
||||
}
|
||||
|
||||
override get rotate() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
override set rotate(_: number) {}
|
||||
|
||||
get styleGetter(): MindmapStyleGetter {
|
||||
return mindmapStyleGetters[this.style];
|
||||
}
|
||||
|
||||
get tree() {
|
||||
return this._tree;
|
||||
}
|
||||
|
||||
get type() {
|
||||
return 'mindmap';
|
||||
}
|
||||
|
||||
static override propsToY(props: Record<string, unknown>) {
|
||||
if (
|
||||
props.children &&
|
||||
!isNodeType(props.children as Record<string, unknown>) &&
|
||||
!(props.children instanceof DocCollection.Y.Map)
|
||||
) {
|
||||
const children: Y.Map<NodeDetail> = new DocCollection.Y.Map();
|
||||
|
||||
keys(props.children).forEach(key => {
|
||||
const detail = pick<Record<string, unknown>, keyof NodeDetail>(
|
||||
props.children![key],
|
||||
['index', 'parent']
|
||||
);
|
||||
children.set(key as string, detail as NodeDetail);
|
||||
});
|
||||
|
||||
props.children = children;
|
||||
}
|
||||
|
||||
return props as MindmapElementProps;
|
||||
}
|
||||
|
||||
private _cfgBalanceLayoutDir() {
|
||||
if (this.layoutType !== LayoutType.BALANCE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tree = this._tree;
|
||||
const splitPoint = Math.ceil(tree.children.length / 2);
|
||||
|
||||
tree.right.push(...tree.children.slice(0, splitPoint));
|
||||
tree.left.push(...tree.children.slice(splitPoint));
|
||||
tree.left.reverse();
|
||||
}
|
||||
|
||||
private _isConnectorOutdated(
|
||||
options:
|
||||
| {
|
||||
connector: LocalConnectorElementModel;
|
||||
from: MindmapNode;
|
||||
to: MindmapNode;
|
||||
layout: LayoutType;
|
||||
}
|
||||
| {
|
||||
connector: LocalConnectorElementModel;
|
||||
from: MindmapNode;
|
||||
layout: LayoutType;
|
||||
collapsed: boolean;
|
||||
},
|
||||
updateKey: boolean = true
|
||||
) {
|
||||
const collapsed = 'collapsed' in options;
|
||||
const { connector, from, layout } = options;
|
||||
|
||||
if (!from.element || (!collapsed && !options.to.element)) {
|
||||
return { outdated: true, cacheKey: '' };
|
||||
}
|
||||
|
||||
const cacheKey = collapsed
|
||||
? `${from.element.xywh}-collapsed-${layout}-${this.style}`
|
||||
: `${from.element.xywh}-${options.to.element.xywh}-${layout}-${this.style}`;
|
||||
|
||||
if (connector.cache.get('MINDMAP_CONNECTOR') === cacheKey) {
|
||||
return false;
|
||||
} else if (updateKey) {
|
||||
connector.cache.set('MINDMAP_CONNECTOR', cacheKey);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override _getXYWH(): Bound {
|
||||
return super._getXYWH();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* you should not call this method directly
|
||||
*/
|
||||
addChild(_element: GfxModel) {
|
||||
noop();
|
||||
}
|
||||
|
||||
addNode(
|
||||
/**
|
||||
* The parent node id of the new node. If it's null, the node will be the root node
|
||||
*/
|
||||
parent: string | MindmapNode | null,
|
||||
sibling?: string | number,
|
||||
position: 'before' | 'after' = 'after',
|
||||
props: Record<string, unknown> = {}
|
||||
) {
|
||||
if (parent && typeof parent !== 'string') {
|
||||
parent = parent.id;
|
||||
}
|
||||
|
||||
assertType<string | null>(parent);
|
||||
|
||||
if (parent && !this._nodeMap.has(parent)) {
|
||||
throw new Error(`Parent node ${parent} not found`);
|
||||
}
|
||||
|
||||
props['text'] = new DocCollection.Y.Text(
|
||||
(props['text'] as string) ?? 'New node'
|
||||
);
|
||||
|
||||
const type = (props.type as string) ?? 'shape';
|
||||
let id: string;
|
||||
this.surface.doc.transact(() => {
|
||||
const parentNode = parent ? this._nodeMap.get(parent)! : null;
|
||||
|
||||
if (parentNode) {
|
||||
let index = last(parentNode.children)
|
||||
? generateKeyBetween(last(parentNode.children)!.detail.index, null)
|
||||
: 'a0';
|
||||
|
||||
sibling = sibling ?? last(parentNode.children)?.id;
|
||||
const siblingNode =
|
||||
typeof sibling === 'number'
|
||||
? parentNode.children[sibling]
|
||||
: sibling
|
||||
? this._nodeMap.get(sibling)
|
||||
: undefined;
|
||||
const path = siblingNode
|
||||
? this.getPath(siblingNode)
|
||||
: this.getPath(parentNode).concat([0]);
|
||||
const style = this.styleGetter.getNodeStyle(
|
||||
siblingNode ?? parentNode,
|
||||
path
|
||||
);
|
||||
|
||||
id = this.surface.addElement({
|
||||
type,
|
||||
xywh: '[0,0,100,30]',
|
||||
maxWidth: false,
|
||||
...props,
|
||||
...style.node,
|
||||
});
|
||||
|
||||
if (siblingNode) {
|
||||
const siblingIndex = parentNode.children.findIndex(
|
||||
val => val.id === sibling
|
||||
);
|
||||
|
||||
index =
|
||||
position === 'after'
|
||||
? generateKeyBetween(
|
||||
siblingNode.detail.index,
|
||||
parentNode.children[siblingIndex + 1]?.detail.index ?? null
|
||||
)
|
||||
: generateKeyBetween(
|
||||
parentNode.children[siblingIndex - 1]?.detail.index ?? null,
|
||||
siblingNode.detail.index
|
||||
);
|
||||
}
|
||||
|
||||
const nodeDetail: NodeDetail = {
|
||||
index,
|
||||
parent: parent!,
|
||||
};
|
||||
|
||||
this.children.set(id, nodeDetail);
|
||||
} else {
|
||||
const rootStyle = this.styleGetter.root;
|
||||
|
||||
id = this.surface.addElement({
|
||||
type,
|
||||
xywh: '[0,0,113,41]',
|
||||
maxWidth: false,
|
||||
...props,
|
||||
...rootStyle,
|
||||
});
|
||||
|
||||
this.children.clear();
|
||||
this.children.set(id, {
|
||||
index: 'a0',
|
||||
});
|
||||
}
|
||||
});
|
||||
this.layout();
|
||||
|
||||
return id!;
|
||||
}
|
||||
|
||||
buildTree() {
|
||||
const mindmapNodeMap = new Map<string, MindmapNode>();
|
||||
const nodesMap = this.children;
|
||||
|
||||
// The element may be removed
|
||||
if (!nodesMap || nodesMap.size === 0) {
|
||||
this._nodeMap = mindmapNodeMap;
|
||||
// @ts-expect-error FIXME: ts error
|
||||
this._tree = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let rootNode: MindmapRoot | undefined;
|
||||
|
||||
nodesMap.forEach((val, id) => {
|
||||
if (!mindmapNodeMap.has(id)) {
|
||||
mindmapNodeMap.set(id, {
|
||||
id,
|
||||
index: val.index,
|
||||
detail: val,
|
||||
element: this.surface.getElementById(id)!,
|
||||
children: [],
|
||||
parent: null,
|
||||
} as MindmapNode);
|
||||
}
|
||||
|
||||
const node = mindmapNodeMap.get(id)!;
|
||||
|
||||
// some node may be already created during
|
||||
// iterating its children
|
||||
if (!node.detail) {
|
||||
node.detail = val;
|
||||
}
|
||||
|
||||
if (!val.parent) {
|
||||
rootNode = node as MindmapRoot;
|
||||
rootNode.left = [];
|
||||
rootNode.right = [];
|
||||
} else {
|
||||
if (!mindmapNodeMap.has(val.parent)) {
|
||||
mindmapNodeMap.set(val.parent, {
|
||||
id: val.parent,
|
||||
detail: nodesMap.get(val.parent)!,
|
||||
parent: null,
|
||||
children: [],
|
||||
element: this.surface.getElementById(val.parent)!,
|
||||
} as MindmapNode);
|
||||
}
|
||||
|
||||
const parent = mindmapNodeMap.get(val.parent)!;
|
||||
parent.children.push(node);
|
||||
node.parent = parent;
|
||||
}
|
||||
});
|
||||
|
||||
mindmapNodeMap.forEach(node => {
|
||||
node.children.sort((a, b) =>
|
||||
a.detail.index === b.detail.index
|
||||
? 0
|
||||
: a.detail.index > b.detail.index
|
||||
? 1
|
||||
: -1
|
||||
);
|
||||
});
|
||||
|
||||
if (!rootNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loops = findInfiniteLoop(rootNode, mindmapNodeMap);
|
||||
|
||||
if (loops.length) {
|
||||
this.surface.doc.withoutTransact(() => {
|
||||
loops.forEach(loop => {
|
||||
if (loop.detached) {
|
||||
loop.chain.forEach(node => {
|
||||
this.children.delete(node.id);
|
||||
});
|
||||
} else {
|
||||
const child = last(loop.chain);
|
||||
|
||||
if (child) {
|
||||
this.children.set(child.id, {
|
||||
index: child.detail.index,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this._nodeMap = mindmapNodeMap;
|
||||
this._tree = rootNode;
|
||||
|
||||
if (this.layoutType === LayoutType.BALANCE) {
|
||||
this._cfgBalanceLayoutDir();
|
||||
} else {
|
||||
this._tree[this.layoutType === LayoutType.RIGHT ? 'right' : 'left'] =
|
||||
this._tree.children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param subtree The subtree of root, this only take effects when the layout type is BALANCED.
|
||||
* @returns
|
||||
*/
|
||||
getChildNodes(id: string, subtree?: 'left' | 'right') {
|
||||
const node = this._nodeMap.get(id);
|
||||
|
||||
if (!node) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (subtree && id === this._tree.id) {
|
||||
return this._tree[subtree];
|
||||
}
|
||||
|
||||
return node.children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the connectors start from the given node
|
||||
* @param node
|
||||
* @returns
|
||||
*/
|
||||
getConnectors(node: MindmapNode) {
|
||||
if (!this._nodeMap.has(node.id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.detail.collapsed) {
|
||||
const id = `#${node.id}-collapsed`;
|
||||
const layout = this.getLayoutDir(node)!;
|
||||
const connector =
|
||||
this.connectors.get(id) ?? new LocalConnectorElementModel(this.surface);
|
||||
const connectorExist = this.connectors.has(id);
|
||||
const connectorStyle = this.styleGetter.getNodeStyle(
|
||||
node,
|
||||
this.getPath(node).concat([0])
|
||||
).connector;
|
||||
const outdated = this._isConnectorOutdated({
|
||||
connector,
|
||||
from: node,
|
||||
collapsed: true,
|
||||
layout,
|
||||
});
|
||||
|
||||
if (!connectorExist) {
|
||||
connector.id = id;
|
||||
this.connectors.set(id, connector);
|
||||
}
|
||||
|
||||
if (outdated) {
|
||||
const nodeBound = node.element.elementBound;
|
||||
connector.id = id;
|
||||
connector.source = {
|
||||
id: node.id,
|
||||
position: layout === LayoutType.LEFT ? [0, 0.5] : [1, 0.5],
|
||||
};
|
||||
connector.target = {
|
||||
position:
|
||||
layout === LayoutType.LEFT
|
||||
? [nodeBound.x - 6, nodeBound.y + nodeBound.h / 2]
|
||||
: [nodeBound.x + nodeBound.w + 6, nodeBound.y + nodeBound.h / 2],
|
||||
};
|
||||
|
||||
Object.entries(connectorStyle).forEach(([key, value]) => {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
connector[key as unknown] = value;
|
||||
});
|
||||
|
||||
connector.mode = ConnectorMode.Straight;
|
||||
}
|
||||
|
||||
return [{ outdated, connector }];
|
||||
} else {
|
||||
const from = node;
|
||||
return from.children.map(to => {
|
||||
const layout = this.getLayoutDir(to)!;
|
||||
const id = `#${from.id}-${to.id}`;
|
||||
const connectorExist = this.connectors.has(id);
|
||||
const connectorStyle = this.styleGetter.getNodeStyle(
|
||||
to,
|
||||
this.getPath(to)
|
||||
).connector;
|
||||
const connector =
|
||||
this.connectors.get(id) ??
|
||||
new LocalConnectorElementModel(this.surface);
|
||||
const outdated = this._isConnectorOutdated({
|
||||
connector,
|
||||
from,
|
||||
to,
|
||||
layout,
|
||||
});
|
||||
|
||||
if (!connectorExist) {
|
||||
connector.id = id;
|
||||
this.connectors.set(id, connector);
|
||||
}
|
||||
|
||||
if (outdated) {
|
||||
connector.source = {
|
||||
id: from.id,
|
||||
position: layout === LayoutType.RIGHT ? [1, 0.5] : [0, 0.5],
|
||||
};
|
||||
connector.target = {
|
||||
id: to.id,
|
||||
position: layout === LayoutType.RIGHT ? [0, 0.5] : [1, 0.5],
|
||||
};
|
||||
|
||||
Object.entries(connectorStyle).forEach(([key, value]) => {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
connector[key as unknown] = value;
|
||||
});
|
||||
}
|
||||
|
||||
return { outdated, connector };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getLayoutDir(node: string | MindmapNode): LayoutType {
|
||||
node = typeof node === 'string' ? this._nodeMap.get(node)! : node;
|
||||
|
||||
assertType<MindmapNode>(node);
|
||||
|
||||
let current: MindmapNode | null = node;
|
||||
const root = this._tree;
|
||||
|
||||
while (current) {
|
||||
if (current.overriddenDir !== undefined) {
|
||||
return current.overriddenDir;
|
||||
}
|
||||
|
||||
const parent: MindmapNode | null = current.detail.parent
|
||||
? (this._nodeMap.get(current.detail.parent) ?? null)
|
||||
: null;
|
||||
|
||||
if (parent === root) {
|
||||
return (
|
||||
parent.overriddenDir ??
|
||||
(root.left.includes(current)
|
||||
? LayoutType.LEFT
|
||||
: root.right.includes(current)
|
||||
? LayoutType.RIGHT
|
||||
: this.layoutType)
|
||||
);
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return this.layoutType;
|
||||
}
|
||||
|
||||
getNode(id: string) {
|
||||
return this._nodeMap.get(id) ?? null;
|
||||
}
|
||||
|
||||
getParentNode(id: string) {
|
||||
const node = this.children.get(id);
|
||||
|
||||
return node?.parent ? (this._nodeMap.get(node.parent) ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Path is an array of indexes that represent the path from the root node to the target node.
|
||||
* The first element of the array is always 0, which represents the root node.
|
||||
* @param element
|
||||
* @returns
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const path = mindmap.getPath('nodeId');
|
||||
* // [0, 1, 2]
|
||||
* ```
|
||||
*/
|
||||
getPath(element: string | MindmapNode) {
|
||||
let node = this._nodeMap.get(
|
||||
typeof element === 'string' ? element : element.id
|
||||
);
|
||||
|
||||
if (!node) {
|
||||
throw new Error('Node not found');
|
||||
}
|
||||
|
||||
const path: number[] = [];
|
||||
|
||||
while (node && node !== this._tree) {
|
||||
const parent = this._nodeMap.get(node!.detail.parent!);
|
||||
|
||||
path.unshift(parent!.children.indexOf(node!));
|
||||
node = parent;
|
||||
}
|
||||
|
||||
path.unshift(0);
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
getSiblingNode(
|
||||
id: string,
|
||||
direction: 'prev' | 'next' = 'next',
|
||||
/**
|
||||
* The subtree of which that the sibling node belongs to,
|
||||
* this is used when the layout type is BALANCED.
|
||||
*/
|
||||
subtree?: 'left' | 'right'
|
||||
) {
|
||||
const node = this._nodeMap.get(id);
|
||||
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parent = this._nodeMap.get(node.detail.parent!);
|
||||
|
||||
if (!parent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const childrenTree =
|
||||
subtree && parent.id === this._tree.id
|
||||
? this._tree[subtree]
|
||||
: parent.children;
|
||||
const idx = childrenTree.indexOf(node);
|
||||
if (idx === -1) {
|
||||
return null;
|
||||
}
|
||||
const siblingIndex = direction === 'next' ? idx + 1 : idx - 1;
|
||||
const sibling = childrenTree[siblingIndex] ?? null;
|
||||
|
||||
return sibling;
|
||||
}
|
||||
|
||||
override includesPoint(x: number, y: number, options: PointTestOptions) {
|
||||
const bound = this.elementBound;
|
||||
|
||||
bound.x -= options.responsePadding?.[0] ?? 0;
|
||||
bound.w += (options.responsePadding?.[0] ?? 0) * 2;
|
||||
bound.y -= options.responsePadding?.[1] ?? 0;
|
||||
bound.h += (options.responsePadding?.[1] ?? 0) * 2;
|
||||
|
||||
return bound.containsPoint([x, y]);
|
||||
}
|
||||
|
||||
layout(
|
||||
_tree: MindmapNode | MindmapRoot = this.tree,
|
||||
_options: {
|
||||
applyStyle?: boolean;
|
||||
layoutType?: LayoutType;
|
||||
calculateTreeBound?: boolean;
|
||||
stashed?: boolean;
|
||||
} = {
|
||||
applyStyle: true,
|
||||
calculateTreeBound: true,
|
||||
stashed: true,
|
||||
}
|
||||
) {
|
||||
// should be implemented by the view
|
||||
// otherwise, it would be just an empty function
|
||||
if (this._layout) {
|
||||
this._layout(_tree, _options);
|
||||
}
|
||||
}
|
||||
|
||||
moveTo(targetXYWH: SerializedXYWH | XYWH) {
|
||||
const { x, y } = this;
|
||||
const targetPos =
|
||||
typeof targetXYWH === 'string' ? deserializeXYWH(targetXYWH) : targetXYWH;
|
||||
const offsetX = targetPos[0] - x;
|
||||
const offsetY = targetPos[1] - y + targetPos[3];
|
||||
|
||||
this.surface.doc.transact(() => {
|
||||
this.childElements.forEach(el => {
|
||||
const deserializedXYWH = deserializeXYWH(el.xywh);
|
||||
|
||||
el.xywh =
|
||||
`[${deserializedXYWH[0] + offsetX},${deserializedXYWH[1] + offsetY},${deserializedXYWH[2]},${deserializedXYWH[3]}]` as SerializedXYWH;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
override onCreated(): void {
|
||||
this.buildTree();
|
||||
}
|
||||
|
||||
removeChild(element: GfxModel) {
|
||||
if (!this._nodeMap.has(element.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const surface = this.surface;
|
||||
const removedDescendants: string[] = [];
|
||||
const remove = (node: MindmapNode) => {
|
||||
node.children?.forEach(child => {
|
||||
remove(child);
|
||||
});
|
||||
|
||||
this.children?.delete(node.id);
|
||||
removedDescendants.push(node.id);
|
||||
};
|
||||
|
||||
surface.doc.transact(() => {
|
||||
remove(this._nodeMap.get(element.id)!);
|
||||
});
|
||||
|
||||
queueMicrotask(() => {
|
||||
removedDescendants.forEach(id => surface.deleteElement(id));
|
||||
});
|
||||
|
||||
// This transaction may not end
|
||||
// force to build the elements
|
||||
this.buildTree();
|
||||
this.requestLayout();
|
||||
}
|
||||
|
||||
protected requestBuildTree() {
|
||||
if (this._queueBuildTree) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._queueBuildTree = true;
|
||||
queueMicrotask(() => {
|
||||
this.buildTree();
|
||||
this._queueBuildTree = false;
|
||||
});
|
||||
}
|
||||
|
||||
requestLayout() {
|
||||
if (!this._queuedLayout) {
|
||||
this._queuedLayout = true;
|
||||
|
||||
queueMicrotask(() => {
|
||||
this.layout();
|
||||
this._queuedLayout = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
override serialize() {
|
||||
const result = super.serialize();
|
||||
return result as SerializedMindmapElement;
|
||||
}
|
||||
|
||||
setLayoutMethod(layoutMethod: MindmapElementModel['layout']) {
|
||||
this._layout = layoutMethod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stash mind map node and its children's xywh property
|
||||
* @param node
|
||||
* @returns a function that write back the stashed xywh into yjs
|
||||
*/
|
||||
stashTree(node: MindmapNode | string) {
|
||||
const mindNode = typeof node === 'string' ? this.getNode(node) : node;
|
||||
|
||||
if (!mindNode || this._stashedNode.has(mindNode.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stashed = new Set<BlockSuite.SurfaceElementModel>();
|
||||
const traverse = (node: MindmapNode) => {
|
||||
node.element.stash('xywh');
|
||||
stashed.add(node.element);
|
||||
|
||||
if (node.children.length) {
|
||||
node.children.forEach(child => traverse(child));
|
||||
}
|
||||
};
|
||||
|
||||
traverse(mindNode);
|
||||
|
||||
return () => {
|
||||
this._stashedNode.delete(mindNode.id);
|
||||
stashed.forEach(el => {
|
||||
el.pop('xywh');
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
toggleCollapse(node: MindmapNode, options: { layout?: boolean } = {}) {
|
||||
if (!this._nodeMap.has(node.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { layout = false } = options;
|
||||
|
||||
if (node && node.children.length > 0) {
|
||||
const collapsed = node.detail.collapsed ? false : true;
|
||||
const isExpand = !collapsed;
|
||||
|
||||
const changeNodesVisibility = (node: MindmapNode) => {
|
||||
node.element.hidden = collapsed;
|
||||
|
||||
if (isExpand && node.detail.collapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
node.children.forEach(child => {
|
||||
changeNodesVisibility(child);
|
||||
});
|
||||
};
|
||||
|
||||
node.children.forEach(changeNodesVisibility);
|
||||
this.surface.doc.transact(() => {
|
||||
this.children.set(node.id, {
|
||||
...node.detail,
|
||||
collapsed,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (layout) {
|
||||
this.requestLayout();
|
||||
}
|
||||
}
|
||||
|
||||
traverse(
|
||||
callback: (node: MindmapNode, parent: MindmapNode | null) => void,
|
||||
root: MindmapNode = this._tree,
|
||||
options: { stopOnCollapse?: boolean } = {}
|
||||
) {
|
||||
const { stopOnCollapse = false } = options;
|
||||
const traverse = (node: MindmapNode, parent: MindmapNode | null) => {
|
||||
callback(node, parent);
|
||||
|
||||
if (stopOnCollapse && node.detail.collapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
node?.children.forEach(child => {
|
||||
traverse(child, node);
|
||||
});
|
||||
};
|
||||
|
||||
if (root) {
|
||||
traverse(root, null);
|
||||
}
|
||||
}
|
||||
|
||||
@convert((initialValue, instance) => {
|
||||
if (!(initialValue instanceof DocCollection.Y.Map)) {
|
||||
nodeSchema.parse(initialValue);
|
||||
|
||||
assertType<NodeType>(initialValue);
|
||||
|
||||
const map: Y.Map<NodeDetail> = new DocCollection.Y.Map();
|
||||
const surface = instance.surface;
|
||||
const doc = surface.doc;
|
||||
const recursive = (
|
||||
node: NodeType,
|
||||
parent: string | null = null,
|
||||
index: string = 'a0'
|
||||
) => {
|
||||
const id = surface.addElement({
|
||||
type: 'shape',
|
||||
text: node.text,
|
||||
xywh: node.xywh ? node.xywh : `[0, 0, 100, 30]`,
|
||||
});
|
||||
|
||||
map.set(id, {
|
||||
index,
|
||||
parent: parent ?? undefined,
|
||||
});
|
||||
|
||||
let curIdx = 'a0';
|
||||
node.children?.forEach(childNode => {
|
||||
recursive(childNode, id, curIdx);
|
||||
curIdx = generateKeyBetween(curIdx, null);
|
||||
});
|
||||
};
|
||||
|
||||
doc.transact(() => {
|
||||
recursive(initialValue);
|
||||
});
|
||||
|
||||
instance.requestBuildTree();
|
||||
instance.requestLayout();
|
||||
return map;
|
||||
} else {
|
||||
instance.requestBuildTree();
|
||||
instance.requestLayout();
|
||||
return initialValue;
|
||||
}
|
||||
})
|
||||
// Use extracted function to avoid playwright test failure
|
||||
// since this model package is imported by playwright
|
||||
@observe(observeChildren)
|
||||
@field()
|
||||
accessor children: Y.Map<NodeDetail> = new DocCollection.Y.Map();
|
||||
|
||||
@watch(watchLayoutType)
|
||||
@field()
|
||||
accessor layoutType: LayoutType = LayoutType.RIGHT;
|
||||
|
||||
@watch(watchStyle)
|
||||
@field()
|
||||
accessor style: MindmapStyle = MindmapStyle.ONE;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace BlockSuite {
|
||||
interface SurfaceGroupLikeModelMap {
|
||||
mindmap: MindmapElementModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
import { isEqual, last } from '@blocksuite/global/utils';
|
||||
|
||||
import { ConnectorMode } from '../../consts/connector.js';
|
||||
import { LineColor } from '../../consts/line.js';
|
||||
import { MindmapStyle } from '../../consts/mindmap.js';
|
||||
import { StrokeStyle } from '../../consts/note.js';
|
||||
import { ShapeFillColor } from '../../consts/shape.js';
|
||||
import { FontFamily, FontWeight, TextResizing } from '../../consts/text.js';
|
||||
import type { MindmapNode } from './mindmap.js';
|
||||
|
||||
export type CollapseButton = {
|
||||
width: number;
|
||||
height: number;
|
||||
radius: number;
|
||||
|
||||
filled: boolean;
|
||||
fillColor: string;
|
||||
|
||||
strokeColor: string;
|
||||
strokeWidth: number;
|
||||
};
|
||||
|
||||
export type ExpandButton = CollapseButton & {
|
||||
fontFamily: FontFamily;
|
||||
fontSize: number;
|
||||
fontWeight: FontWeight;
|
||||
|
||||
color: string;
|
||||
};
|
||||
|
||||
export type NodeStyle = {
|
||||
radius: number;
|
||||
|
||||
strokeWidth: number;
|
||||
strokeColor: string;
|
||||
|
||||
textResizing: TextResizing;
|
||||
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
fontWeight: FontWeight;
|
||||
color: string;
|
||||
|
||||
filled: boolean;
|
||||
fillColor: string;
|
||||
|
||||
padding: [number, number];
|
||||
|
||||
shadow?: {
|
||||
blur: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
color: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ConnectorStyle = {
|
||||
strokeStyle: StrokeStyle;
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
|
||||
mode: ConnectorMode;
|
||||
};
|
||||
|
||||
export abstract class MindmapStyleGetter {
|
||||
abstract readonly root: NodeStyle;
|
||||
|
||||
abstract getNodeStyle(
|
||||
node: MindmapNode,
|
||||
path: number[]
|
||||
): {
|
||||
connector: ConnectorStyle;
|
||||
collapseButton: CollapseButton;
|
||||
expandButton: ExpandButton;
|
||||
node: NodeStyle;
|
||||
};
|
||||
}
|
||||
|
||||
export class StyleOne extends MindmapStyleGetter {
|
||||
private _colorOrders = [
|
||||
LineColor.Purple,
|
||||
LineColor.Magenta,
|
||||
LineColor.Orange,
|
||||
LineColor.Yellow,
|
||||
LineColor.Green,
|
||||
'#7ae2d5',
|
||||
];
|
||||
|
||||
readonly root = {
|
||||
radius: 8,
|
||||
|
||||
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
|
||||
|
||||
strokeWidth: 4,
|
||||
strokeColor: '#84CFFF',
|
||||
|
||||
fontFamily: FontFamily.Poppins,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.SemiBold,
|
||||
color: '--affine-black',
|
||||
|
||||
filled: true,
|
||||
fillColor: '--affine-white',
|
||||
|
||||
padding: [11, 22] as [number, number],
|
||||
|
||||
shadow: {
|
||||
offsetX: 0,
|
||||
offsetY: 6,
|
||||
blur: 12,
|
||||
color: 'rgba(0, 0, 0, 0.14)',
|
||||
},
|
||||
};
|
||||
|
||||
private _getColor(number: number) {
|
||||
return this._colorOrders[number % this._colorOrders.length];
|
||||
}
|
||||
|
||||
getNodeStyle(_: MindmapNode, path: number[]) {
|
||||
const color = this._getColor(path[1] ?? 0);
|
||||
|
||||
return {
|
||||
connector: {
|
||||
strokeStyle: StrokeStyle.Solid,
|
||||
stroke: color,
|
||||
strokeWidth: 3,
|
||||
|
||||
mode: ConnectorMode.Curve,
|
||||
},
|
||||
collapseButton: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
radius: 0.5,
|
||||
|
||||
filled: true,
|
||||
fillColor: '--affine-white',
|
||||
|
||||
strokeColor: color,
|
||||
strokeWidth: 3,
|
||||
},
|
||||
expandButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
radius: 8,
|
||||
|
||||
filled: true,
|
||||
fillColor: color,
|
||||
|
||||
strokeColor: color,
|
||||
strokeWidth: 0,
|
||||
|
||||
padding: [4, 0],
|
||||
|
||||
color: '--affine-white',
|
||||
|
||||
fontFamily: FontFamily.Inter,
|
||||
fontWeight: FontWeight.Bold,
|
||||
fontSize: 15,
|
||||
},
|
||||
node: {
|
||||
radius: 8,
|
||||
|
||||
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
|
||||
|
||||
strokeWidth: 3,
|
||||
strokeColor: color,
|
||||
|
||||
fontFamily: FontFamily.Poppins,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.Medium,
|
||||
color: '--affine-black',
|
||||
|
||||
filled: true,
|
||||
fillColor: '--affine-white',
|
||||
|
||||
padding: [6, 22] as [number, number],
|
||||
|
||||
shadow: {
|
||||
offsetX: 0,
|
||||
offsetY: 6,
|
||||
blur: 12,
|
||||
color: 'rgba(0, 0, 0, 0.14)',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
export const styleOne = new StyleOne();
|
||||
|
||||
export class StyleTwo extends MindmapStyleGetter {
|
||||
private _colorOrders = [
|
||||
ShapeFillColor.Blue,
|
||||
'#7ae2d5',
|
||||
ShapeFillColor.Yellow,
|
||||
];
|
||||
|
||||
readonly root = {
|
||||
radius: 3,
|
||||
|
||||
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
|
||||
|
||||
strokeWidth: 3,
|
||||
strokeColor: '--affine-black',
|
||||
|
||||
fontFamily: FontFamily.Poppins,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.SemiBold,
|
||||
color: ShapeFillColor.Black,
|
||||
|
||||
filled: true,
|
||||
fillColor: ShapeFillColor.Orange,
|
||||
|
||||
padding: [11, 22] as [number, number],
|
||||
|
||||
shadow: {
|
||||
blur: 0,
|
||||
offsetX: 3,
|
||||
offsetY: 3,
|
||||
color: '--affine-black',
|
||||
},
|
||||
};
|
||||
|
||||
private _getColor(number: number) {
|
||||
return number >= this._colorOrders.length
|
||||
? last(this._colorOrders)!
|
||||
: this._colorOrders[number];
|
||||
}
|
||||
|
||||
getNodeStyle(_: MindmapNode, path: number[]) {
|
||||
const color = this._getColor(path.length - 2);
|
||||
|
||||
return {
|
||||
connector: {
|
||||
strokeStyle: StrokeStyle.Solid,
|
||||
stroke: '--affine-black',
|
||||
strokeWidth: 3,
|
||||
|
||||
mode: ConnectorMode.Orthogonal,
|
||||
},
|
||||
collapseButton: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
radius: 0.5,
|
||||
|
||||
filled: true,
|
||||
fillColor: '--affine-white',
|
||||
|
||||
strokeColor: '--affine-black',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
expandButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
radius: 2,
|
||||
|
||||
filled: true,
|
||||
fillColor: '--affine-black',
|
||||
|
||||
padding: [4, 0],
|
||||
|
||||
strokeColor: '--affine-black',
|
||||
strokeWidth: 0,
|
||||
|
||||
color: '--affine-white',
|
||||
|
||||
fontFamily: FontFamily.Inter,
|
||||
fontWeight: FontWeight.Bold,
|
||||
fontSize: 15,
|
||||
},
|
||||
node: {
|
||||
radius: 3,
|
||||
|
||||
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
|
||||
|
||||
strokeWidth: 3,
|
||||
strokeColor: '--affine-black',
|
||||
|
||||
fontFamily: FontFamily.Poppins,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.SemiBold,
|
||||
color: ShapeFillColor.Black,
|
||||
|
||||
filled: true,
|
||||
fillColor: color,
|
||||
|
||||
padding: [6, 22] as [number, number],
|
||||
|
||||
shadow: {
|
||||
blur: 0,
|
||||
offsetX: 3,
|
||||
offsetY: 3,
|
||||
color: '--affine-black',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
export const styleTwo = new StyleTwo();
|
||||
|
||||
export class StyleThree extends MindmapStyleGetter {
|
||||
private _strokeColor = [LineColor.Yellow, LineColor.Green, LineColor.Teal];
|
||||
|
||||
readonly root = {
|
||||
radius: 10,
|
||||
|
||||
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
|
||||
|
||||
strokeWidth: 0,
|
||||
strokeColor: 'transparent',
|
||||
|
||||
fontFamily: FontFamily.Poppins,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.Medium,
|
||||
color: ShapeFillColor.Black,
|
||||
|
||||
filled: true,
|
||||
fillColor: ShapeFillColor.Yellow,
|
||||
|
||||
padding: [10, 22] as [number, number],
|
||||
|
||||
shadow: {
|
||||
blur: 12,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
color: 'rgba(66, 65, 73, 0.18)',
|
||||
},
|
||||
};
|
||||
|
||||
private _getColor(number: number) {
|
||||
return this._strokeColor[number % this._strokeColor.length];
|
||||
}
|
||||
|
||||
override getNodeStyle(_: MindmapNode, path: number[]) {
|
||||
const strokeColor = this._getColor(path.length - 2);
|
||||
|
||||
return {
|
||||
node: {
|
||||
radius: 10,
|
||||
|
||||
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
|
||||
|
||||
strokeWidth: 2,
|
||||
strokeColor: strokeColor,
|
||||
|
||||
fontFamily: FontFamily.Poppins,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.Medium,
|
||||
color: ShapeFillColor.Black,
|
||||
|
||||
filled: true,
|
||||
fillColor: ShapeFillColor.White,
|
||||
|
||||
padding: [6, 22] as [number, number],
|
||||
|
||||
shadow: {
|
||||
blur: 12,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
color: 'rgba(66, 65, 73, 0.18)',
|
||||
},
|
||||
},
|
||||
collapseButton: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
radius: 0.5,
|
||||
|
||||
filled: true,
|
||||
fillColor: '--affine-white',
|
||||
|
||||
strokeColor: '#3CBC36',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
expandButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
radius: 8,
|
||||
|
||||
filled: true,
|
||||
fillColor: '#3CBC36',
|
||||
|
||||
padding: [4, 0],
|
||||
|
||||
strokeColor: '#3CBC36',
|
||||
strokeWidth: 0,
|
||||
|
||||
color: '#fff',
|
||||
|
||||
fontFamily: FontFamily.Inter,
|
||||
fontWeight: FontWeight.Bold,
|
||||
fontSize: 15,
|
||||
},
|
||||
connector: {
|
||||
strokeStyle: StrokeStyle.Solid,
|
||||
stroke: strokeColor,
|
||||
strokeWidth: 2,
|
||||
|
||||
mode: ConnectorMode.Curve,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
export const styleThree = new StyleThree();
|
||||
|
||||
export class StyleFour extends MindmapStyleGetter {
|
||||
private _colors = [
|
||||
ShapeFillColor.Purple,
|
||||
ShapeFillColor.Magenta,
|
||||
ShapeFillColor.Orange,
|
||||
ShapeFillColor.Yellow,
|
||||
ShapeFillColor.Green,
|
||||
ShapeFillColor.Blue,
|
||||
];
|
||||
|
||||
readonly root = {
|
||||
radius: 0,
|
||||
|
||||
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
|
||||
|
||||
strokeWidth: 0,
|
||||
strokeColor: 'transparent',
|
||||
|
||||
fontFamily: FontFamily.Kalam,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.Bold,
|
||||
color: '--affine-black',
|
||||
|
||||
filled: true,
|
||||
fillColor: 'transparent',
|
||||
|
||||
padding: [0, 10] as [number, number],
|
||||
};
|
||||
|
||||
private _getColor(order: number) {
|
||||
return this._colors[order % this._colors.length];
|
||||
}
|
||||
|
||||
getNodeStyle(_: MindmapNode, path: number[]) {
|
||||
const stroke = this._getColor(path[1] ?? 0);
|
||||
|
||||
return {
|
||||
connector: {
|
||||
strokeStyle: StrokeStyle.Solid,
|
||||
stroke,
|
||||
strokeWidth: 3,
|
||||
|
||||
mode: ConnectorMode.Curve,
|
||||
},
|
||||
collapseButton: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
radius: 0.5,
|
||||
|
||||
filled: true,
|
||||
fillColor: '--affine-white',
|
||||
|
||||
strokeColor: stroke,
|
||||
strokeWidth: 3,
|
||||
},
|
||||
expandButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
radius: 8,
|
||||
|
||||
filled: true,
|
||||
fillColor: stroke,
|
||||
|
||||
padding: [4, 0],
|
||||
|
||||
strokeColor: stroke,
|
||||
strokeWidth: 0,
|
||||
|
||||
color: '--affine-white',
|
||||
|
||||
fontFamily: FontFamily.Inter,
|
||||
fontWeight: FontWeight.Bold,
|
||||
fontSize: 15,
|
||||
},
|
||||
node: {
|
||||
...this.root,
|
||||
|
||||
fontSize: 18,
|
||||
padding: [1.5, 10] as [number, number],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
export const styleFour = new StyleFour();
|
||||
|
||||
export const mindmapStyleGetters: Record<MindmapStyle, MindmapStyleGetter> = {
|
||||
[MindmapStyle.ONE]: styleOne,
|
||||
[MindmapStyle.TWO]: styleTwo,
|
||||
[MindmapStyle.THREE]: styleThree,
|
||||
[MindmapStyle.FOUR]: styleFour,
|
||||
};
|
||||
|
||||
export const applyNodeStyle = (node: MindmapNode, nodeStyle: NodeStyle) => {
|
||||
Object.entries(nodeStyle).forEach(([key, value]) => {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
if (!isEqual(node.element[key], value)) {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
node.element[key] = value;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { MindmapNode } from './mindmap.js';
|
||||
|
||||
export function findInfiniteLoop(
|
||||
root: MindmapNode,
|
||||
nodeMap: Map<string, MindmapNode>
|
||||
) {
|
||||
const visited = new Set<string>();
|
||||
const loop: {
|
||||
detached: boolean;
|
||||
chain: MindmapNode[];
|
||||
}[] = [];
|
||||
|
||||
const traverse = (
|
||||
node: MindmapNode,
|
||||
traverseChain: MindmapNode[] = [],
|
||||
detached = false
|
||||
) => {
|
||||
if (visited.has(node.id)) {
|
||||
loop.push({
|
||||
detached,
|
||||
chain: traverseChain,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
visited.add(node.id);
|
||||
|
||||
traverseChain.push(node);
|
||||
|
||||
node.children.forEach(child =>
|
||||
traverse(child, traverseChain.slice(), detached)
|
||||
);
|
||||
};
|
||||
|
||||
traverse(root);
|
||||
|
||||
nodeMap.forEach(node => {
|
||||
if (visited.has(node.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
traverse(node, [], true);
|
||||
});
|
||||
|
||||
return loop;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { PointTestOptions } from '@blocksuite/block-std/gfx';
|
||||
import type { IBound, IVec } from '@blocksuite/global/utils';
|
||||
import {
|
||||
Bound,
|
||||
getCenterAreaBounds,
|
||||
getPointsFromBoundWithRotation,
|
||||
linePolygonIntersects,
|
||||
pointInPolygon,
|
||||
PointLocation,
|
||||
pointOnPolygonStoke,
|
||||
polygonGetPointTangent,
|
||||
polygonNearestPoint,
|
||||
rotatePoints,
|
||||
} from '@blocksuite/global/utils';
|
||||
|
||||
import { DEFAULT_CENTRAL_AREA_RATIO } from '../../../consts/index.js';
|
||||
import type { ShapeElementModel } from '../shape.js';
|
||||
|
||||
export const diamond = {
|
||||
points({ x, y, w, h }: IBound): IVec[] {
|
||||
return [
|
||||
[x, y + h / 2],
|
||||
[x + w / 2, y],
|
||||
[x + w, y + h / 2],
|
||||
[x + w / 2, y + h],
|
||||
];
|
||||
},
|
||||
draw(ctx: CanvasRenderingContext2D, { x, y, w, h, rotate = 0 }: IBound) {
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate((rotate * Math.PI) / 180);
|
||||
ctx.translate(-cx, -cy);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y + h / 2);
|
||||
ctx.lineTo(x + w / 2, y);
|
||||
ctx.lineTo(x + w, y + h / 2);
|
||||
ctx.lineTo(x + w / 2, y + h);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
includesPoint(
|
||||
this: ShapeElementModel,
|
||||
x: number,
|
||||
y: number,
|
||||
options: PointTestOptions
|
||||
) {
|
||||
const point: IVec = [x, y];
|
||||
const points = getPointsFromBoundWithRotation(this, diamond.points);
|
||||
|
||||
let hit = pointOnPolygonStoke(
|
||||
point,
|
||||
points,
|
||||
(options?.hitThreshold ?? 1) / (options.zoom ?? 1)
|
||||
);
|
||||
|
||||
if (!hit) {
|
||||
if (!options.ignoreTransparent || this.filled) {
|
||||
hit = pointInPolygon([x, y], points);
|
||||
} else {
|
||||
// If shape is not filled or transparent
|
||||
const text = this.text;
|
||||
if (!text || !text.length) {
|
||||
// Check the center area of the shape
|
||||
const centralBounds = getCenterAreaBounds(
|
||||
this,
|
||||
DEFAULT_CENTRAL_AREA_RATIO
|
||||
);
|
||||
const centralPoints = getPointsFromBoundWithRotation(
|
||||
centralBounds,
|
||||
diamond.points
|
||||
);
|
||||
hit = pointInPolygon(point, centralPoints);
|
||||
} else if (this.textBound) {
|
||||
hit = pointInPolygon(
|
||||
point,
|
||||
getPointsFromBoundWithRotation(
|
||||
this,
|
||||
() => Bound.from(this.textBound!).points
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hit;
|
||||
},
|
||||
|
||||
containsBound(bounds: Bound, element: ShapeElementModel) {
|
||||
const points = getPointsFromBoundWithRotation(element, diamond.points);
|
||||
return points.some(point => bounds.containsPoint(point));
|
||||
},
|
||||
|
||||
getNearestPoint(point: IVec, element: ShapeElementModel) {
|
||||
const points = getPointsFromBoundWithRotation(element, diamond.points);
|
||||
return polygonNearestPoint(points, point);
|
||||
},
|
||||
|
||||
getLineIntersections(start: IVec, end: IVec, element: ShapeElementModel) {
|
||||
const points = getPointsFromBoundWithRotation(element, diamond.points);
|
||||
return linePolygonIntersects(start, end, points);
|
||||
},
|
||||
|
||||
getRelativePointLocation(position: IVec, element: ShapeElementModel) {
|
||||
const bound = Bound.deserialize(element.xywh);
|
||||
const point = bound.getRelativePoint(position);
|
||||
let points = diamond.points(bound);
|
||||
points.push(point);
|
||||
|
||||
points = rotatePoints(points, bound.center, element.rotate);
|
||||
const rotatePoint = points.pop() as IVec;
|
||||
const tangent = polygonGetPointTangent(points, rotatePoint);
|
||||
return new PointLocation(rotatePoint, tangent);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { PointTestOptions } from '@blocksuite/block-std/gfx';
|
||||
import {
|
||||
Bound,
|
||||
clamp,
|
||||
getPointsFromBoundWithRotation,
|
||||
type IBound,
|
||||
type IVec,
|
||||
lineEllipseIntersects,
|
||||
pointInEllipse,
|
||||
pointInPolygon,
|
||||
PointLocation,
|
||||
rotatePoints,
|
||||
toRadian,
|
||||
Vec,
|
||||
} from '@blocksuite/global/utils';
|
||||
|
||||
import { DEFAULT_CENTRAL_AREA_RATIO } from '../../../consts/index.js';
|
||||
import type { ShapeElementModel } from '../shape.js';
|
||||
|
||||
export const ellipse = {
|
||||
points({ x, y, w, h }: IBound): IVec[] {
|
||||
return [
|
||||
[x, y + h / 2],
|
||||
[x + w / 2, y],
|
||||
[x + w, y + h / 2],
|
||||
[x + w / 2, y + h],
|
||||
];
|
||||
},
|
||||
draw(ctx: CanvasRenderingContext2D, { x, y, w, h, rotate = 0 }: IBound) {
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate((rotate * Math.PI) / 180);
|
||||
ctx.translate(-cx, -cy);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, w / 2, h / 2, 0, 0, 2 * Math.PI);
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
includesPoint(
|
||||
this: ShapeElementModel,
|
||||
x: number,
|
||||
y: number,
|
||||
options: PointTestOptions
|
||||
) {
|
||||
const point: IVec = [x, y];
|
||||
const expand = (options?.hitThreshold ?? 1) / (options?.zoom ?? 1);
|
||||
const rx = this.w / 2;
|
||||
const ry = this.h / 2;
|
||||
const center: IVec = [this.x + rx, this.y + ry];
|
||||
const rad = (this.rotate * Math.PI) / 180;
|
||||
|
||||
let hit =
|
||||
pointInEllipse(point, center, rx + expand, ry + expand, rad) &&
|
||||
!pointInEllipse(point, center, rx - expand, ry - expand, rad);
|
||||
|
||||
if (!hit) {
|
||||
if (!options.ignoreTransparent || this.filled) {
|
||||
hit = pointInEllipse(point, center, rx, ry, rad);
|
||||
} else {
|
||||
// If shape is not filled or transparent
|
||||
const text = this.text;
|
||||
if (!text || !text.length) {
|
||||
// Check the center area of the shape
|
||||
const centralRx = rx * DEFAULT_CENTRAL_AREA_RATIO;
|
||||
const centralRy = ry * DEFAULT_CENTRAL_AREA_RATIO;
|
||||
hit = pointInEllipse(point, center, centralRx, centralRy, rad);
|
||||
} else if (this.textBound) {
|
||||
hit = pointInPolygon(
|
||||
point,
|
||||
getPointsFromBoundWithRotation(
|
||||
this,
|
||||
() => Bound.from(this.textBound!).points
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hit;
|
||||
},
|
||||
containsBound(bounds: Bound, element: ShapeElementModel): boolean {
|
||||
const points = getPointsFromBoundWithRotation(element, ellipse.points);
|
||||
return points.some(point => bounds.containsPoint(point));
|
||||
},
|
||||
|
||||
// See links:
|
||||
// * https://github.com/0xfaded/ellipse_demo/issues/1
|
||||
// * https://blog.chatfield.io/simple-method-for-distance-to-ellipse/
|
||||
// * https://gist.github.com/fundon/11331322d3ca223c42e216df48c339e1
|
||||
// * https://github.com/excalidraw/excalidraw/blob/master/packages/utils/geometry/geometry.ts#L888 (MIT)
|
||||
getNearestPoint(point: IVec, { rotate, xywh }: ShapeElementModel) {
|
||||
const { center, w, h } = Bound.deserialize(xywh);
|
||||
const rad = toRadian(rotate);
|
||||
const a = w / 2;
|
||||
const b = h / 2;
|
||||
|
||||
// Use the center of the ellipse as the origin
|
||||
const [rotatedPointX, rotatedPointY] = Vec.rot(
|
||||
Vec.sub(point, center),
|
||||
-rad
|
||||
);
|
||||
|
||||
const px = Math.abs(rotatedPointX);
|
||||
const py = Math.abs(rotatedPointY);
|
||||
|
||||
let tx = Math.SQRT1_2; // 0.707
|
||||
let ty = Math.SQRT1_2; // 0.707
|
||||
let i = 0;
|
||||
|
||||
for (; i < 3; i++) {
|
||||
const x = a * tx;
|
||||
const y = b * ty;
|
||||
|
||||
const ex = ((a * a - b * b) * tx ** 3) / a;
|
||||
const ey = ((b * b - a * a) * ty ** 3) / b;
|
||||
|
||||
const rx = x - ex;
|
||||
const ry = y - ey;
|
||||
|
||||
const qx = px - ex;
|
||||
const qy = py - ey;
|
||||
|
||||
const r = Math.hypot(ry, rx);
|
||||
const q = Math.hypot(qy, qx);
|
||||
|
||||
tx = clamp(((qx * r) / q + ex) / a, 0, 1);
|
||||
ty = clamp(((qy * r) / q + ey) / b, 0, 1);
|
||||
const t = Math.hypot(ty, tx);
|
||||
tx /= t;
|
||||
ty /= t;
|
||||
}
|
||||
|
||||
return Vec.add(
|
||||
Vec.rot(
|
||||
[a * tx * Math.sign(rotatedPointX), b * ty * Math.sign(rotatedPointY)],
|
||||
rad
|
||||
),
|
||||
center
|
||||
);
|
||||
},
|
||||
|
||||
getLineIntersections(
|
||||
start: IVec,
|
||||
end: IVec,
|
||||
{ rotate, xywh }: ShapeElementModel
|
||||
) {
|
||||
const rad = toRadian(rotate);
|
||||
const bound = Bound.deserialize(xywh);
|
||||
return lineEllipseIntersects(
|
||||
start,
|
||||
end,
|
||||
bound.center,
|
||||
bound.w / 2,
|
||||
bound.h / 2,
|
||||
rad
|
||||
);
|
||||
},
|
||||
|
||||
getRelativePointLocation(
|
||||
relativePoint: IVec,
|
||||
{ rotate, xywh }: ShapeElementModel
|
||||
) {
|
||||
const bounds = Bound.deserialize(xywh);
|
||||
const point = bounds.getRelativePoint(relativePoint);
|
||||
const { x, y, w, h, center } = bounds;
|
||||
const points = rotatePoints(
|
||||
[
|
||||
[x, y],
|
||||
[x + w / 2, y],
|
||||
[x + w, y],
|
||||
[x + w, y + h / 2],
|
||||
[x + w, y + h],
|
||||
[x + w / 2, y + h],
|
||||
[x, y + h],
|
||||
[x, y + h / 2],
|
||||
point,
|
||||
],
|
||||
center,
|
||||
rotate
|
||||
);
|
||||
const rotatedPoint = points.pop() as IVec;
|
||||
const len = points.length;
|
||||
let tangent: IVec = [0, 0.5];
|
||||
let i = 0;
|
||||
|
||||
for (; i < len; i++) {
|
||||
const p0 = points[i];
|
||||
const p1 = points[(i + 1) % len];
|
||||
const bounds = Bound.fromPoints([p0, p1, center]);
|
||||
if (bounds.containsPoint(rotatedPoint)) {
|
||||
tangent = Vec.normalize(Vec.sub(p1, p0));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new PointLocation(rotatedPoint, tangent);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ShapeType } from '../../../consts/shape.js';
|
||||
import { diamond } from './diamond.js';
|
||||
import { ellipse } from './ellipse.js';
|
||||
import { rect } from './rect.js';
|
||||
import { triangle } from './triangle.js';
|
||||
|
||||
export const shapeMethods: Record<ShapeType, typeof rect> = {
|
||||
rect,
|
||||
triangle,
|
||||
ellipse,
|
||||
diamond,
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { PointTestOptions } from '@blocksuite/block-std/gfx';
|
||||
import type { IBound, IVec } from '@blocksuite/global/utils';
|
||||
import {
|
||||
Bound,
|
||||
getCenterAreaBounds,
|
||||
getPointsFromBoundWithRotation,
|
||||
linePolygonIntersects,
|
||||
pointInPolygon,
|
||||
PointLocation,
|
||||
pointOnPolygonStoke,
|
||||
polygonGetPointTangent,
|
||||
polygonNearestPoint,
|
||||
rotatePoints,
|
||||
} from '@blocksuite/global/utils';
|
||||
|
||||
import { DEFAULT_CENTRAL_AREA_RATIO } from '../../../consts/index.js';
|
||||
import type { ShapeElementModel } from '../shape.js';
|
||||
|
||||
export const rect = {
|
||||
points({ x, y, w, h }: IBound) {
|
||||
return [
|
||||
[x, y],
|
||||
[x + w, y],
|
||||
[x + w, y + h],
|
||||
[x, y + h],
|
||||
];
|
||||
},
|
||||
draw(ctx: CanvasRenderingContext2D, { x, y, w, h, rotate = 0 }: IBound) {
|
||||
ctx.save();
|
||||
ctx.translate(x + w / 2, y + h / 2);
|
||||
ctx.rotate((rotate * Math.PI) / 180);
|
||||
ctx.translate(-x - w / 2, -y - h / 2);
|
||||
ctx.rect(x, y, w, h);
|
||||
ctx.restore();
|
||||
},
|
||||
includesPoint(
|
||||
this: ShapeElementModel,
|
||||
x: number,
|
||||
y: number,
|
||||
options: PointTestOptions
|
||||
) {
|
||||
const point: IVec = [x, y];
|
||||
const points = getPointsFromBoundWithRotation(
|
||||
this,
|
||||
undefined,
|
||||
options.responsePadding
|
||||
);
|
||||
|
||||
let hit = pointOnPolygonStoke(
|
||||
point,
|
||||
points,
|
||||
(options?.hitThreshold ?? 1) / (options.zoom ?? 1)
|
||||
);
|
||||
|
||||
if (!hit) {
|
||||
// If the point is not on the stroke, check if it is in the shape
|
||||
// When the shape is filled and transparent is not ignored
|
||||
if (!options.ignoreTransparent || this.filled) {
|
||||
hit = pointInPolygon([x, y], points);
|
||||
} else {
|
||||
// If shape is not filled or transparent
|
||||
// Check if hit the text area
|
||||
const text = this.text;
|
||||
if (!text || !text.length) {
|
||||
// if not, check the default center area of the shape
|
||||
const centralBounds = getCenterAreaBounds(
|
||||
this,
|
||||
DEFAULT_CENTRAL_AREA_RATIO
|
||||
);
|
||||
const centralPoints = getPointsFromBoundWithRotation(centralBounds);
|
||||
// Check if the point is in the center area
|
||||
hit = pointInPolygon([x, y], centralPoints);
|
||||
} else if (this.textBound) {
|
||||
hit = pointInPolygon(
|
||||
point,
|
||||
getPointsFromBoundWithRotation(
|
||||
this,
|
||||
() => Bound.from(this.textBound!).points
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hit;
|
||||
},
|
||||
|
||||
containsBound(bounds: Bound, element: ShapeElementModel): boolean {
|
||||
const points = getPointsFromBoundWithRotation(element);
|
||||
return points.some(point => bounds.containsPoint(point));
|
||||
},
|
||||
|
||||
getNearestPoint(point: IVec, element: ShapeElementModel) {
|
||||
const points = getPointsFromBoundWithRotation(element);
|
||||
return polygonNearestPoint(points, point);
|
||||
},
|
||||
|
||||
getLineIntersections(start: IVec, end: IVec, element: ShapeElementModel) {
|
||||
const points = getPointsFromBoundWithRotation(element);
|
||||
return linePolygonIntersects(start, end, points);
|
||||
},
|
||||
|
||||
getRelativePointLocation(relativePoint: IVec, element: ShapeElementModel) {
|
||||
const bound = Bound.deserialize(element.xywh);
|
||||
const point = bound.getRelativePoint(relativePoint);
|
||||
const rotatePoint = rotatePoints(
|
||||
[point],
|
||||
bound.center,
|
||||
element.rotate ?? 0
|
||||
)[0];
|
||||
const points = rotatePoints(
|
||||
bound.points,
|
||||
bound.center,
|
||||
element.rotate ?? 0
|
||||
);
|
||||
const tangent = polygonGetPointTangent(points, rotatePoint);
|
||||
return new PointLocation(rotatePoint, tangent);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { PointTestOptions } from '@blocksuite/block-std/gfx';
|
||||
import type { IBound, IVec } from '@blocksuite/global/utils';
|
||||
import {
|
||||
Bound,
|
||||
getCenterAreaBounds,
|
||||
getPointsFromBoundWithRotation,
|
||||
linePolygonIntersects,
|
||||
pointInPolygon,
|
||||
PointLocation,
|
||||
pointOnPolygonStoke,
|
||||
polygonGetPointTangent,
|
||||
polygonNearestPoint,
|
||||
rotatePoints,
|
||||
} from '@blocksuite/global/utils';
|
||||
|
||||
import { DEFAULT_CENTRAL_AREA_RATIO } from '../../../consts/index.js';
|
||||
import type { ShapeElementModel } from '../shape.js';
|
||||
|
||||
export const triangle = {
|
||||
points({ x, y, w, h }: IBound): IVec[] {
|
||||
return [
|
||||
[x, y + h],
|
||||
[x + w / 2, y],
|
||||
[x + w, y + h],
|
||||
];
|
||||
},
|
||||
draw(ctx: CanvasRenderingContext2D, { x, y, w, h, rotate = 0 }: IBound) {
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate((rotate * Math.PI) / 180);
|
||||
ctx.translate(-cx, -cy);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y + h);
|
||||
ctx.lineTo(x + w / 2, y);
|
||||
ctx.lineTo(x + w, y + h);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
includesPoint(
|
||||
this: ShapeElementModel,
|
||||
x: number,
|
||||
y: number,
|
||||
options: PointTestOptions
|
||||
) {
|
||||
const point: IVec = [x, y];
|
||||
const points = getPointsFromBoundWithRotation(this, triangle.points);
|
||||
|
||||
let hit = pointOnPolygonStoke(
|
||||
point,
|
||||
points,
|
||||
(options?.hitThreshold ?? 1) / (options?.zoom ?? 1)
|
||||
);
|
||||
|
||||
if (!hit) {
|
||||
if (!options.ignoreTransparent || this.filled) {
|
||||
hit = pointInPolygon([x, y], points);
|
||||
} else {
|
||||
// If shape is not filled or transparent
|
||||
const text = this.text;
|
||||
if (!text || !text.length) {
|
||||
// Check the center area of the shape
|
||||
const centralBounds = getCenterAreaBounds(
|
||||
this,
|
||||
DEFAULT_CENTRAL_AREA_RATIO
|
||||
);
|
||||
const centralPoints = getPointsFromBoundWithRotation(
|
||||
centralBounds,
|
||||
triangle.points
|
||||
);
|
||||
hit = pointInPolygon([x, y], centralPoints);
|
||||
} else if (this.textBound) {
|
||||
hit = pointInPolygon(
|
||||
point,
|
||||
getPointsFromBoundWithRotation(
|
||||
this,
|
||||
() => Bound.from(this.textBound!).points
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hit;
|
||||
},
|
||||
containsBound(bounds: Bound, element: ShapeElementModel): boolean {
|
||||
const points = getPointsFromBoundWithRotation(element, triangle.points);
|
||||
return points.some(point => bounds.containsPoint(point));
|
||||
},
|
||||
|
||||
getNearestPoint(point: IVec, element: ShapeElementModel) {
|
||||
const points = getPointsFromBoundWithRotation(element, triangle.points);
|
||||
return polygonNearestPoint(points, point);
|
||||
},
|
||||
|
||||
getLineIntersections(start: IVec, end: IVec, element: ShapeElementModel) {
|
||||
const points = getPointsFromBoundWithRotation(element, triangle.points);
|
||||
return linePolygonIntersects(start, end, points);
|
||||
},
|
||||
|
||||
getRelativePointLocation(position: IVec, element: ShapeElementModel) {
|
||||
const bound = Bound.deserialize(element.xywh);
|
||||
const point = bound.getRelativePoint(position);
|
||||
let points = triangle.points(bound);
|
||||
points.push(point);
|
||||
|
||||
points = rotatePoints(points, bound.center, element.rotate);
|
||||
const rotatePoint = points.pop() as IVec;
|
||||
const tangent = polygonGetPointTangent(points, rotatePoint);
|
||||
return new PointLocation(rotatePoint, tangent);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './api/index.js';
|
||||
export * from './shape.js';
|
||||
@@ -0,0 +1,277 @@
|
||||
import type {
|
||||
BaseElementProps,
|
||||
PointTestOptions,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import {
|
||||
field,
|
||||
GfxLocalElementModel,
|
||||
GfxPrimitiveElementModel,
|
||||
local,
|
||||
prop,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import type {
|
||||
Bound,
|
||||
IBound,
|
||||
IVec,
|
||||
PointLocation,
|
||||
SerializedXYWH,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { DocCollection, type Y } from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
type Color,
|
||||
DEFAULT_ROUGHNESS,
|
||||
FontFamily,
|
||||
FontStyle,
|
||||
FontWeight,
|
||||
LineColor,
|
||||
ShapeFillColor,
|
||||
ShapeStyle,
|
||||
ShapeTextFontSize,
|
||||
ShapeType,
|
||||
StrokeStyle,
|
||||
TextAlign,
|
||||
TextResizing,
|
||||
type TextStyleProps,
|
||||
TextVerticalAlign,
|
||||
} from '../../consts/index.js';
|
||||
import { shapeMethods } from './api/index.js';
|
||||
|
||||
export type ShapeProps = BaseElementProps & {
|
||||
shapeType: ShapeType;
|
||||
radius: number;
|
||||
filled: boolean;
|
||||
fillColor: Color;
|
||||
strokeWidth: number;
|
||||
strokeColor: Color;
|
||||
strokeStyle: StrokeStyle;
|
||||
shapeStyle: ShapeStyle;
|
||||
// https://github.com/rough-stuff/rough/wiki#roughness
|
||||
roughness?: number;
|
||||
|
||||
text?: Y.Text;
|
||||
textHorizontalAlign?: TextAlign;
|
||||
textVerticalAlign?: TextVerticalAlign;
|
||||
textResizing?: TextResizing;
|
||||
maxWidth?: false | number;
|
||||
} & Partial<TextStyleProps>;
|
||||
|
||||
export const SHAPE_TEXT_PADDING = 20;
|
||||
export const SHAPE_TEXT_VERTICAL_PADDING = 10;
|
||||
|
||||
export class ShapeElementModel extends GfxPrimitiveElementModel<ShapeProps> {
|
||||
/**
|
||||
* The bound of the text content.
|
||||
*/
|
||||
textBound: IBound | null = null;
|
||||
|
||||
get type() {
|
||||
return 'shape';
|
||||
}
|
||||
|
||||
static override propsToY(props: ShapeProps) {
|
||||
if (props.text && !(props.text instanceof DocCollection.Y.Text)) {
|
||||
props.text = new DocCollection.Y.Text(props.text);
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
override containsBound(bounds: Bound) {
|
||||
return shapeMethods[this.shapeType].containsBound(bounds, this);
|
||||
}
|
||||
|
||||
override getLineIntersections(start: IVec, end: IVec) {
|
||||
return shapeMethods[this.shapeType].getLineIntersections(start, end, this);
|
||||
}
|
||||
|
||||
override getNearestPoint(point: IVec): IVec {
|
||||
return shapeMethods[this.shapeType].getNearestPoint(point, this) as IVec;
|
||||
}
|
||||
|
||||
override getRelativePointLocation(point: IVec): PointLocation {
|
||||
return shapeMethods[this.shapeType].getRelativePointLocation(point, this);
|
||||
}
|
||||
|
||||
override includesPoint(x: number, y: number, options: PointTestOptions) {
|
||||
return shapeMethods[this.shapeType].includesPoint.call(this, x, y, {
|
||||
...options,
|
||||
ignoreTransparent: options.ignoreTransparent ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
@field('#000000' as Color)
|
||||
accessor color!: Color;
|
||||
|
||||
@field()
|
||||
accessor fillColor: Color = ShapeFillColor.Yellow;
|
||||
|
||||
@field()
|
||||
accessor filled: boolean = false;
|
||||
|
||||
@field(FontFamily.Inter as string)
|
||||
accessor fontFamily!: string;
|
||||
|
||||
@field(ShapeTextFontSize.MEDIUM)
|
||||
accessor fontSize!: number;
|
||||
|
||||
@field(FontStyle.Normal as FontStyle)
|
||||
accessor fontStyle!: FontStyle;
|
||||
|
||||
@field(FontWeight.Regular as FontWeight)
|
||||
accessor fontWeight!: FontWeight;
|
||||
|
||||
@field(false as false | number)
|
||||
accessor maxWidth: false | number = false;
|
||||
|
||||
@field([SHAPE_TEXT_VERTICAL_PADDING, SHAPE_TEXT_PADDING])
|
||||
accessor padding: [number, number] = [
|
||||
SHAPE_TEXT_VERTICAL_PADDING,
|
||||
SHAPE_TEXT_PADDING,
|
||||
];
|
||||
|
||||
@field()
|
||||
accessor radius: number = 0;
|
||||
|
||||
@field(0)
|
||||
accessor rotate: number = 0;
|
||||
|
||||
@field(DEFAULT_ROUGHNESS)
|
||||
accessor roughness: number = DEFAULT_ROUGHNESS;
|
||||
|
||||
@field()
|
||||
accessor shadow: {
|
||||
/**
|
||||
* @deprecated Since the shadow blur will reduce the performance of canvas rendering,
|
||||
* we already disable the shadow blur rendering by default, so set this field will not take effect.
|
||||
* You can enable it by setting the flag `enable_shape_shadow_blur` in the awareness store.
|
||||
* https://web.dev/articles/canvas-performance#avoid_shadowblur
|
||||
*/
|
||||
blur: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
color: string;
|
||||
} | null = null;
|
||||
|
||||
@field()
|
||||
accessor shapeStyle: ShapeStyle = ShapeStyle.General;
|
||||
|
||||
@field()
|
||||
accessor shapeType: ShapeType = ShapeType.Rect;
|
||||
|
||||
@field()
|
||||
accessor strokeColor: Color = LineColor.Yellow;
|
||||
|
||||
@field()
|
||||
accessor strokeStyle: StrokeStyle = StrokeStyle.Solid;
|
||||
|
||||
@field()
|
||||
accessor strokeWidth: number = 4;
|
||||
|
||||
@field()
|
||||
accessor text: Y.Text | undefined = undefined;
|
||||
|
||||
@field(TextAlign.Center as TextAlign)
|
||||
accessor textAlign!: TextAlign;
|
||||
|
||||
@local()
|
||||
accessor textDisplay: boolean = true;
|
||||
|
||||
@field(TextAlign.Center as TextAlign)
|
||||
accessor textHorizontalAlign!: TextAlign;
|
||||
|
||||
@field(TextResizing.AUTO_HEIGHT as TextResizing)
|
||||
accessor textResizing: TextResizing = TextResizing.AUTO_HEIGHT;
|
||||
|
||||
@field(TextVerticalAlign.Center as TextVerticalAlign)
|
||||
accessor textVerticalAlign!: TextVerticalAlign;
|
||||
|
||||
@field()
|
||||
accessor xywh: SerializedXYWH = '[0,0,100,100]';
|
||||
}
|
||||
|
||||
export class LocalShapeElementModel extends GfxLocalElementModel {
|
||||
roughness: number = DEFAULT_ROUGHNESS;
|
||||
|
||||
textBound: Bound | null = null;
|
||||
|
||||
textDisplay: boolean = true;
|
||||
|
||||
get type() {
|
||||
return 'shape';
|
||||
}
|
||||
|
||||
@prop()
|
||||
accessor color: Color = '#000000';
|
||||
|
||||
@prop()
|
||||
accessor fillColor: Color = ShapeFillColor.Yellow;
|
||||
|
||||
@prop()
|
||||
accessor filled: boolean = false;
|
||||
|
||||
@prop()
|
||||
accessor fontFamily: string = FontFamily.Inter;
|
||||
|
||||
@prop()
|
||||
accessor fontSize: number = 16;
|
||||
|
||||
@prop()
|
||||
accessor fontStyle: FontStyle = FontStyle.Normal;
|
||||
|
||||
@prop()
|
||||
accessor fontWeight: FontWeight = FontWeight.Regular;
|
||||
|
||||
@prop()
|
||||
accessor padding: [number, number] = [
|
||||
SHAPE_TEXT_VERTICAL_PADDING,
|
||||
SHAPE_TEXT_PADDING,
|
||||
];
|
||||
|
||||
@prop()
|
||||
accessor radius: number = 0;
|
||||
|
||||
@prop()
|
||||
accessor shadow: {
|
||||
blur: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
color: string;
|
||||
} | null = null;
|
||||
|
||||
@prop()
|
||||
accessor shapeStyle: ShapeStyle = ShapeStyle.General;
|
||||
|
||||
@prop()
|
||||
accessor shapeType: ShapeType = ShapeType.Rect;
|
||||
|
||||
@prop()
|
||||
accessor strokeColor: Color = LineColor.Yellow;
|
||||
|
||||
@prop()
|
||||
accessor strokeStyle: StrokeStyle = StrokeStyle.Solid;
|
||||
|
||||
@prop()
|
||||
accessor strokeWidth: number = 4;
|
||||
|
||||
@prop()
|
||||
accessor text: string = '';
|
||||
|
||||
@prop()
|
||||
accessor textAlign: TextAlign = TextAlign.Center;
|
||||
|
||||
@prop()
|
||||
accessor textVerticalAlign: TextVerticalAlign = TextVerticalAlign.Center;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace BlockSuite {
|
||||
interface SurfaceElementModelMap {
|
||||
shape: ShapeElementModel;
|
||||
}
|
||||
|
||||
interface EdgelessTextModelMap {
|
||||
shape: ShapeElementModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './text.js';
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { BaseElementProps } from '@blocksuite/block-std/gfx';
|
||||
import { field, GfxPrimitiveElementModel } from '@blocksuite/block-std/gfx';
|
||||
import type { IVec, SerializedXYWH } from '@blocksuite/global/utils';
|
||||
import {
|
||||
Bound,
|
||||
getPointsFromBoundWithRotation,
|
||||
linePolygonIntersects,
|
||||
pointInPolygon,
|
||||
polygonNearestPoint,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { DocCollection, type Y } from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
type Color,
|
||||
FontFamily,
|
||||
FontStyle,
|
||||
FontWeight,
|
||||
TextAlign,
|
||||
type TextStyleProps,
|
||||
} from '../../consts/index.js';
|
||||
|
||||
export type TextElementProps = BaseElementProps & {
|
||||
text: Y.Text;
|
||||
hasMaxWidth?: boolean;
|
||||
} & Omit<TextStyleProps, 'fontWeight' | 'fontStyle'> &
|
||||
Partial<Pick<TextStyleProps, 'fontWeight' | 'fontStyle'>>;
|
||||
|
||||
export class TextElementModel extends GfxPrimitiveElementModel<TextElementProps> {
|
||||
get type() {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
static override propsToY(props: Record<string, unknown>) {
|
||||
if (props.text && !(props.text instanceof DocCollection.Y.Text)) {
|
||||
props.text = new DocCollection.Y.Text(props.text as string);
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
override containsBound(bounds: Bound): boolean {
|
||||
const points = getPointsFromBoundWithRotation(this);
|
||||
return points.some(point => bounds.containsPoint(point));
|
||||
}
|
||||
|
||||
override getLineIntersections(start: IVec, end: IVec) {
|
||||
const points = getPointsFromBoundWithRotation(this);
|
||||
return linePolygonIntersects(start, end, points);
|
||||
}
|
||||
|
||||
override getNearestPoint(point: IVec): IVec {
|
||||
return polygonNearestPoint(
|
||||
Bound.deserialize(this.xywh).points,
|
||||
point
|
||||
) as IVec;
|
||||
}
|
||||
|
||||
override includesPoint(x: number, y: number): boolean {
|
||||
const points = getPointsFromBoundWithRotation(this);
|
||||
return pointInPolygon([x, y], points);
|
||||
}
|
||||
|
||||
@field()
|
||||
accessor color: Color = '#000000';
|
||||
|
||||
@field()
|
||||
accessor fontFamily: FontFamily = FontFamily.Inter;
|
||||
|
||||
@field()
|
||||
accessor fontSize: number = 16;
|
||||
|
||||
@field(FontStyle.Normal as FontStyle)
|
||||
accessor fontStyle: FontStyle = FontStyle.Normal;
|
||||
|
||||
@field(FontWeight.Regular as FontWeight)
|
||||
accessor fontWeight: FontWeight = FontWeight.Regular;
|
||||
|
||||
@field(false)
|
||||
accessor hasMaxWidth: boolean = false;
|
||||
|
||||
@field(0)
|
||||
accessor rotate: number = 0;
|
||||
|
||||
@field()
|
||||
accessor text: Y.Text = new DocCollection.Y.Text();
|
||||
|
||||
@field()
|
||||
accessor textAlign: TextAlign = TextAlign.Center;
|
||||
|
||||
@field()
|
||||
accessor xywh: SerializedXYWH = '[0,0,16,16]';
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace BlockSuite {
|
||||
interface SurfaceElementModelMap {
|
||||
text: TextElementModel;
|
||||
}
|
||||
|
||||
interface EdgelessTextModelMap {
|
||||
text: TextElementModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user