Files
AFFiNE/blocksuite/framework/std/src/gfx/model/surface/element-model.ts
L-Sun 1d865f16fe feat(editor): comment for edgeless element (#13098)
#### PR Dependency Tree


* **PR #13098** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added support for comments on graphical elements, allowing users to
comment on both blocks and graphical elements within surfaces.
* Enhanced comment previews to include graphical elements in selection
summaries.
* Improved editor navigation to focus on commented graphical elements in
addition to blocks and inline texts.

* **Bug Fixes**
* Updated comment highlighting and management to consistently use the
new comment manager across all block and element types.

* **Refactor**
* Renamed and extended the comment manager to handle both block and
element comments.
* Streamlined toolbar configurations by removing outdated comment button
entries and adding a consolidated comment button in the root toolbar.

* **Tests**
* Disabled the mock comment provider integration in the test editor
environment to refine testing setup.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-08 10:33:09 +00:00

604 lines
14 KiB
TypeScript

import { DisposableGroup } from '@blocksuite/global/disposable';
import {
Bound,
deserializeXYWH,
getBoundWithRotation,
getPointsFromBoundWithRotation,
type IVec,
linePolygonIntersects,
PointLocation,
polygonGetPointTangent,
polygonNearestPoint,
randomSeed,
rotatePoints,
type SerializedXYWH,
type XYWH,
} from '@blocksuite/global/gfx';
import { createMutex } from 'lib0/mutex';
import isEqual from 'lodash-es/isEqual';
import { Subject } from 'rxjs';
import * as Y from 'yjs';
import {
descendantElementsImpl,
hasDescendantElementImpl,
isLockedByAncestorImpl,
isLockedBySelfImpl,
isLockedImpl,
lockElementImpl,
unlockElementImpl,
} from '../../../utils/tree.js';
import type { EditorHost } from '../../../view/index.js';
import type {
GfxCompatibleInterface,
GfxGroupCompatibleInterface,
PointTestOptions,
} from '../base.js';
import { gfxGroupCompatibleSymbol } from '../base.js';
import type { GfxBlockElementModel } from '../gfx-block-model.js';
import type { GfxGroupModel, GfxModel } from '../model.js';
import {
convertProps,
field,
getDerivedProps,
getFieldPropsSet,
local,
updateDerivedProps,
watch,
} from './decorators/index.js';
import type { SurfaceBlockModel } from './surface-model.js';
export type BaseElementProps = {
index: string;
seed: number;
lockedBySelf?: boolean;
comments?: Record<string, boolean>;
};
export type SerializedElement = Record<string, unknown> & {
type: string;
xywh: SerializedXYWH;
id: string;
index: string;
lockedBySelf?: boolean;
comments?: Record<string, boolean>;
props: Record<string, unknown>;
};
export abstract class GfxPrimitiveElementModel<
Props extends BaseElementProps = BaseElementProps,
> implements GfxCompatibleInterface
{
private _lastXYWH!: SerializedXYWH;
protected _disposable = new DisposableGroup();
protected _id: string;
protected _local = new Map<string | symbol, unknown>();
protected _onChange: (payload: {
props: Record<string, unknown>;
oldValues: Record<string, unknown>;
local: boolean;
}) => void;
/**
* Used to store a copy of data in the yMap.
*/
protected _preserved = new Map<string, unknown>();
protected _stashed: Map<keyof Props | string, unknown>;
propsUpdated = new Subject<{ key: string }>();
abstract rotate: number;
surface!: SurfaceBlockModel;
abstract xywh: SerializedXYWH;
yMap: Y.Map<unknown>;
get connectable() {
return true;
}
get deserializedXYWH() {
if (!this._lastXYWH || this.xywh !== this._lastXYWH) {
const xywh = this.xywh;
this._local.set('deserializedXYWH', deserializeXYWH(xywh));
this._lastXYWH = xywh;
}
return (this._local.get('deserializedXYWH') as XYWH) ?? [0, 0, 0, 0];
}
/**
* The bound of the element after rotation.
* The bound without rotation should be created by `Bound.deserialize(this.xywh)`.
*/
get elementBound() {
if (this.rotate) {
return Bound.from(getBoundWithRotation(this));
}
return Bound.deserialize(this.xywh);
}
get externalBound(): Bound | null {
if (!this._local.has('externalBound')) {
const bound = this.externalXYWH
? Bound.deserialize(this.externalXYWH)
: null;
this._local.set('externalBound', bound);
}
return this._local.get('externalBound') as Bound | null;
}
get group(): GfxGroupModel | null {
return this.surface.getGroup(this.id);
}
/**
* Return the ancestor elements in order from the most recent to the earliest.
*/
get groups(): GfxGroupModel[] {
return this.surface.getGroups(this.id);
}
get h() {
return this.deserializedXYWH[3];
}
get id() {
return this._id;
}
get isConnected() {
return this.surface.hasElementById(this.id);
}
get responseBound() {
return this.elementBound.expand(this.responseExtension);
}
abstract get type(): string;
get w() {
return this.deserializedXYWH[2];
}
get x() {
return this.deserializedXYWH[0];
}
get y() {
return this.deserializedXYWH[1];
}
constructor(options: {
id: string;
yMap: Y.Map<unknown>;
model: SurfaceBlockModel;
stashedStore: Map<unknown, unknown>;
onChange: (payload: {
props: Record<string, unknown>;
oldValues: Record<string, unknown>;
local: boolean;
}) => void;
}) {
const { id, yMap, model, stashedStore, onChange } = options;
this._id = id;
this.yMap = yMap;
this.surface = model;
this._stashed = stashedStore as Map<keyof Props, unknown>;
this._onChange = onChange;
this.index = 'a0';
this.seed = randomSeed();
}
containsBound(bounds: Bound): boolean {
return getPointsFromBoundWithRotation(this).some(point =>
bounds.containsPoint(point)
);
}
getLineIntersections(start: IVec, end: IVec) {
const points = getPointsFromBoundWithRotation(this);
return linePolygonIntersects(start, end, points);
}
getNearestPoint(point: IVec) {
const points = getPointsFromBoundWithRotation(this);
return polygonNearestPoint(points, point);
}
getRelativePointLocation(relativePoint: IVec) {
const bound = Bound.deserialize(this.xywh);
const point = bound.getRelativePoint(relativePoint);
const rotatePoint = rotatePoints([point], bound.center, this.rotate)[0];
const points = rotatePoints(bound.points, bound.center, this.rotate);
const tangent = polygonGetPointTangent(points, rotatePoint);
return new PointLocation(rotatePoint, tangent);
}
includesPoint(
x: number,
y: number,
opt: PointTestOptions,
__: EditorHost
): boolean {
const bound = opt.useElementBound ? this.elementBound : this.responseBound;
return bound.isPointInBound([x, y]);
}
intersectsBound(bound: Bound): boolean {
return (
this.containsBound(bound) ||
bound.points.some((point, i, points) =>
this.getLineIntersections(point, points[(i + 1) % points.length])
)
);
}
isLocked(): boolean {
return isLockedImpl(this);
}
isLockedByAncestor(): boolean {
return isLockedByAncestorImpl(this);
}
isLockedBySelf(): boolean {
return isLockedBySelfImpl(this);
}
lock() {
lockElementImpl(this.surface.store, this);
}
onCreated() {}
onDestroyed() {
this._disposable.dispose();
this.propsUpdated.complete();
}
pop(prop: keyof Props | string) {
if (!this._stashed.has(prop)) {
return;
}
const value = this._stashed.get(prop);
this._stashed.delete(prop);
// @ts-expect-error ignore
delete this[prop];
if (getFieldPropsSet(this).has(prop as string)) {
if (!isEqual(value, this.yMap.get(prop as string))) {
this.yMap.set(prop as string, value);
}
} else {
console.warn('pop a prop that is not field or local:', prop);
}
}
serialize() {
const result = this.yMap.toJSON();
result.xywh = this.xywh;
return result as SerializedElement;
}
stash(prop: keyof Props | string) {
if (this._stashed.has(prop)) {
return;
}
if (!getFieldPropsSet(this).has(prop as string)) {
return;
}
const curVal = this[prop as unknown as keyof GfxPrimitiveElementModel];
this._stashed.set(prop, curVal);
Object.defineProperty(this, prop, {
configurable: true,
enumerable: true,
get: () => this._stashed.get(prop),
set: (original: unknown) => {
const value = convertProps(prop as string, original, this);
const oldValue = this._stashed.get(prop);
const derivedProps = getDerivedProps(
prop as string,
original,
this as unknown as GfxPrimitiveElementModel
);
this._stashed.set(prop, value);
this._onChange({
props: {
[prop]: value,
},
oldValues: {
[prop]: oldValue,
},
local: true,
});
updateDerivedProps(
derivedProps,
this as unknown as GfxPrimitiveElementModel
);
},
});
}
unlock() {
unlockElementImpl(this.surface.store, this);
}
@local()
accessor display: boolean = true;
/**
* In some cases, you need to draw something related to the element, but it does not belong to the element itself.
* And it is also interactive, you can select element by clicking on it. E.g. the title of the group element.
* In this case, we need to store this kind of external xywh in order to do hit test. This property should not be synced to the doc.
* This property should be updated every time it gets rendered.
*/
@watch((_, instance) => {
instance['_local'].delete('externalBound');
})
@local()
accessor externalXYWH: SerializedXYWH | undefined = undefined;
@field(false)
accessor hidden: boolean = false;
@field()
accessor index!: string;
@field()
accessor lockedBySelf: boolean | undefined = false;
@local()
accessor opacity: number = 1;
@local()
accessor responseExtension: [number, number] = [0, 0];
@field()
accessor seed!: number;
@field()
accessor comments: Record<string, boolean> | undefined = undefined;
}
export abstract class GfxGroupLikeElementModel<
Props extends BaseElementProps = BaseElementProps,
>
extends GfxPrimitiveElementModel<Props>
implements GfxGroupCompatibleInterface
{
private _childIds: string[] = [];
private readonly _mutex = createMutex();
abstract children: Y.Map<any>;
[gfxGroupCompatibleSymbol] = true as const;
get childElements() {
const elements: GfxModel[] = [];
for (const key of this.childIds) {
const element =
this.surface.getElementById(key) ||
(this.surface.store.getModelById(key) as GfxBlockElementModel);
element && elements.push(element);
}
return elements;
}
/**
* The ids of the children. Its role is to provide a unique way to access the children.
* You should update this field through `setChildIds` when the children are added or removed.
*/
get childIds() {
return this._childIds;
}
get descendantElements(): GfxModel[] {
return descendantElementsImpl(this);
}
get xywh() {
this._mutex(() => {
const curXYWH =
(this._local.get('xywh') as SerializedXYWH) ?? '[0,0,0,0]';
const newXYWH = this._getXYWH().serialize();
if (curXYWH !== newXYWH || !this._local.has('xywh')) {
this._local.set('xywh', newXYWH);
if (curXYWH !== newXYWH) {
this._onChange({
props: {
xywh: newXYWH,
},
oldValues: {
xywh: curXYWH,
},
local: true,
});
}
}
});
return (this._local.get('xywh') as SerializedXYWH) ?? '[0,0,0,0]';
}
set xywh(_) {}
protected _getXYWH(): Bound {
let bound: Bound | undefined;
this.childElements.forEach(child => {
if (child instanceof GfxPrimitiveElementModel && child.hidden) {
return;
}
bound = bound ? bound.unite(child.elementBound) : child.elementBound;
});
if (bound) {
this._local.set('xywh', bound.serialize());
} else {
this._local.delete('xywh');
}
return bound ?? new Bound(0, 0, 0, 0);
}
abstract addChild(element: GfxModel): void;
/**
* The actual field that stores the children of the group.
* It should be a ymap decorated with `@field`.
*/
hasChild(element: GfxCompatibleInterface) {
return this.childElements.includes(element as GfxModel);
}
/**
* Check if the group has the given descendant.
*/
hasDescendant(element: GfxCompatibleInterface): boolean {
return hasDescendantElementImpl(this, element);
}
/**
* Remove the child from the group
*/
abstract removeChild(element: GfxCompatibleInterface): void;
/**
* Set the new value of the childIds
* @param value the new value of the childIds
* @param fromLocal if true, the change is happened in the local
*/
setChildIds(value: string[], fromLocal: boolean) {
const oldChildIds = this.childIds;
this._childIds = value;
this._onChange({
props: {
childIds: value,
},
oldValues: {
childIds: oldChildIds,
},
local: fromLocal,
});
}
}
export function syncElementFromY(
model: GfxPrimitiveElementModel,
callback: (payload: {
props: Record<string, unknown>;
oldValues: Record<string, unknown>;
local: boolean;
}) => void
) {
const disposables: Record<string, () => void> = {};
const observer = (
event: Y.YMapEvent<unknown>,
transaction: Y.Transaction
) => {
const props: Record<string, unknown> = {};
const oldValues: Record<string, unknown> = {};
event.keysChanged.forEach(key => {
const type = event.changes.keys.get(key);
const oldValue = event.changes.keys.get(key)?.oldValue;
if (!type) {
return;
}
if (type.action === 'update' || type.action === 'add') {
const value = model.yMap.get(key);
if (value instanceof Y.Text) {
disposables[key]?.();
disposables[key] = watchText(key, value, callback);
}
model['_preserved'].set(key, value);
props[key] = value;
oldValues[key] = oldValue;
} else {
model['_preserved'].delete(key);
oldValues[key] = oldValue;
}
});
callback({
props,
oldValues,
local: transaction.local,
});
};
Array.from(model.yMap.entries()).forEach(([key, value]) => {
if (value instanceof Y.Text) {
disposables[key] = watchText(key, value, callback);
}
model['_preserved'].set(key, value);
});
model.yMap.observe(observer);
disposables['ymap'] = () => {
model.yMap.unobserve(observer);
};
return () => {
Object.values(disposables).forEach(fn => fn());
};
}
function watchText(
key: string,
value: Y.Text,
callback: (payload: {
props: Record<string, unknown>;
oldValues: Record<string, unknown>;
local: boolean;
}) => void
) {
const fn = (_: Y.YTextEvent, transaction: Y.Transaction) => {
callback({
props: {
[key]: value,
},
oldValues: {},
local: transaction.local,
});
};
value.observe(fn);
return () => {
value.unobserve(fn);
};
}