chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions
@@ -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;
}
}
}