mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 10:06:17 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import type {
|
||||
Bound,
|
||||
IBound,
|
||||
IVec,
|
||||
PointLocation,
|
||||
SerializedXYWH,
|
||||
XYWH,
|
||||
} from '@blocksuite/global/utils';
|
||||
|
||||
import type { EditorHost } from '../../view/element/lit-host.js';
|
||||
import type { GfxGroupModel, GfxModel } from './model.js';
|
||||
|
||||
/**
|
||||
* The methods that a graphic element should implement.
|
||||
* It is already included in the `GfxCompatibleInterface` interface.
|
||||
*/
|
||||
export interface GfxElementGeometry {
|
||||
containsBound(bound: Bound): boolean;
|
||||
getNearestPoint(point: IVec): IVec;
|
||||
getLineIntersections(start: IVec, end: IVec): PointLocation[] | null;
|
||||
getRelativePointLocation(point: IVec): PointLocation;
|
||||
includesPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
options: PointTestOptions,
|
||||
host: EditorHost
|
||||
): boolean;
|
||||
intersectsBound(bound: Bound): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* All the model that can be rendered in graphics mode should implement this interface.
|
||||
*/
|
||||
export interface GfxCompatibleInterface extends IBound, GfxElementGeometry {
|
||||
xywh: SerializedXYWH;
|
||||
index: string;
|
||||
|
||||
/**
|
||||
* Defines the extension of the response area beyond the element's bounding box.
|
||||
* This tuple specifies the horizontal and vertical margins to be added to the element's bound.
|
||||
*
|
||||
* The first value represents the horizontal extension (added to both left and right sides),
|
||||
* and the second value represents the vertical extension (added to both top and bottom sides).
|
||||
*
|
||||
* The response area is computed as:
|
||||
* `[x - horizontal, y - vertical, w + 2 * horizontal, h + 2 * vertical]`.
|
||||
*
|
||||
* Example:
|
||||
* - xywh: `[0, 0, 100, 100]`, `responseExtension: [10, 20]`
|
||||
* Resulting response area: `[-10, -20, 120, 140]`.
|
||||
* - `responseExtension: [0, 0]` keeps the response area equal to the bounding box.
|
||||
*/
|
||||
responseExtension: [number, number];
|
||||
|
||||
readonly group: GfxGroupCompatibleInterface | null;
|
||||
|
||||
readonly groups: GfxGroupCompatibleInterface[];
|
||||
|
||||
readonly deserializedXYWH: XYWH;
|
||||
|
||||
/**
|
||||
* The bound of the element without considering the response extension.
|
||||
*/
|
||||
readonly elementBound: Bound;
|
||||
|
||||
/**
|
||||
* The bound of the element considering the response extension.
|
||||
*/
|
||||
readonly responseBound: Bound;
|
||||
|
||||
/**
|
||||
* Indicates whether the current block is explicitly locked by self.
|
||||
* For checking the lock status of the element, use `isLocked` instead.
|
||||
* For (un)locking the element, use `(un)lock` instead.
|
||||
*/
|
||||
lockedBySelf?: boolean;
|
||||
|
||||
/**
|
||||
* Check if the element is locked. It will check the lock status of the element and its ancestors.
|
||||
*/
|
||||
isLocked(): boolean;
|
||||
isLockedBySelf(): boolean;
|
||||
isLockedByAncestor(): boolean;
|
||||
|
||||
lock(): void;
|
||||
unlock(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The symbol to mark a model as a container.
|
||||
*/
|
||||
export const gfxGroupCompatibleSymbol = Symbol('GfxGroupCompatible');
|
||||
|
||||
/**
|
||||
* Check if the element is a container element.
|
||||
*/
|
||||
export const isGfxGroupCompatibleModel = (
|
||||
elm: unknown
|
||||
): elm is GfxGroupModel => {
|
||||
if (typeof elm !== 'object' || elm === null) return false;
|
||||
return (
|
||||
gfxGroupCompatibleSymbol in elm && elm[gfxGroupCompatibleSymbol] === true
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* GfxGroupCompatibleElement is a model that can contain other models.
|
||||
* It just like a group that in common graphic software.
|
||||
*/
|
||||
export interface GfxGroupCompatibleInterface extends GfxCompatibleInterface {
|
||||
[gfxGroupCompatibleSymbol]: true;
|
||||
|
||||
/**
|
||||
* All child ids of this container.
|
||||
*/
|
||||
childIds: string[];
|
||||
|
||||
/**
|
||||
* All child element models of this container.
|
||||
* Note that the `childElements` may not contains all the children in `childIds`,
|
||||
* because some children may not be loaded.
|
||||
*/
|
||||
childElements: GfxModel[];
|
||||
|
||||
descendantElements: GfxModel[];
|
||||
|
||||
addChild(element: GfxCompatibleInterface): void;
|
||||
removeChild(element: GfxCompatibleInterface): void;
|
||||
hasChild(element: GfxCompatibleInterface): boolean;
|
||||
|
||||
hasDescendant(element: GfxCompatibleInterface): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The options for the hit testing of a point.
|
||||
*/
|
||||
export interface PointTestOptions {
|
||||
/**
|
||||
* The threshold of the hit test. The unit is pixel.
|
||||
*/
|
||||
hitThreshold?: number;
|
||||
|
||||
/**
|
||||
* If true, the element bound will be used for the hit testing.
|
||||
* By default, the response bound will be used.
|
||||
*/
|
||||
useElementBound?: boolean;
|
||||
|
||||
/**
|
||||
* The padding of the response area for each element when do the hit testing. The unit is pixel.
|
||||
* The first value is the padding for the x-axis, and the second value is the padding for the y-axis.
|
||||
*/
|
||||
responsePadding?: [number, number];
|
||||
|
||||
/**
|
||||
* If true, the transparent area of the element will be ignored during the point inclusion test.
|
||||
* Otherwise, the transparent area will be considered as filled area.
|
||||
*
|
||||
* Default is true.
|
||||
*/
|
||||
ignoreTransparent?: boolean;
|
||||
|
||||
/**
|
||||
* The zoom level of current view when do the hit testing.
|
||||
*/
|
||||
zoom?: number;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import type {
|
||||
Constructor,
|
||||
IVec,
|
||||
SerializedXYWH,
|
||||
XYWH,
|
||||
} from '@blocksuite/global/utils';
|
||||
import {
|
||||
Bound,
|
||||
deserializeXYWH,
|
||||
getBoundWithRotation,
|
||||
getPointsFromBoundWithRotation,
|
||||
linePolygonIntersects,
|
||||
PointLocation,
|
||||
polygonGetPointTangent,
|
||||
polygonNearestPoint,
|
||||
rotatePoints,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
isLockedByAncestorImpl,
|
||||
isLockedBySelfImpl,
|
||||
isLockedImpl,
|
||||
lockElementImpl,
|
||||
unlockElementImpl,
|
||||
} from '../../utils/tree.js';
|
||||
import type { EditorHost } from '../../view/index.js';
|
||||
import type { GfxCompatibleInterface, PointTestOptions } from './base.js';
|
||||
import type { GfxGroupModel } from './model.js';
|
||||
import type { SurfaceBlockModel } from './surface/surface-model.js';
|
||||
|
||||
/**
|
||||
* The props that a graphics block model should have.
|
||||
*/
|
||||
export type GfxCompatibleProps = {
|
||||
xywh: SerializedXYWH;
|
||||
index: string;
|
||||
lockedBySelf?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* This type include the common props for the graphic block model.
|
||||
* You can use this type with Omit to define the props of a graphic block model.
|
||||
*/
|
||||
export type GfxCommonBlockProps = GfxCompatibleProps & {
|
||||
rotate: number;
|
||||
scale: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The graphic block model that can be rendered in the graphics mode.
|
||||
* All the graphic block model should extend this class.
|
||||
* You can use `GfxCompatibleBlockModel` to convert a BlockModel to a subclass that extends it.
|
||||
*/
|
||||
export class GfxBlockElementModel<
|
||||
Props extends GfxCompatibleProps = GfxCompatibleProps,
|
||||
>
|
||||
extends BlockModel<Props>
|
||||
implements GfxCompatibleInterface
|
||||
{
|
||||
private _cacheDeserKey: string | null = null;
|
||||
|
||||
private _cacheDeserXYWH: XYWH | null = null;
|
||||
|
||||
private _externalXYWH: SerializedXYWH | undefined = undefined;
|
||||
|
||||
connectable = true;
|
||||
|
||||
/**
|
||||
* Defines the extension of the response area beyond the element's bounding box.
|
||||
* This tuple specifies the horizontal and vertical margins to be added to the element's [x, y, width, height].
|
||||
*
|
||||
* The first value represents the horizontal extension (added to both left and right sides),
|
||||
* and the second value represents the vertical extension (added to both top and bottom sides).
|
||||
*
|
||||
* The response area is computed as:
|
||||
* `[x - horizontal, y - vertical, width + 2 * horizontal, height + 2 * vertical]`.
|
||||
*
|
||||
* Example:
|
||||
* - Bounding box: `[0, 0, 100, 100]`, `responseExtension: [10, 20]`
|
||||
* Resulting response area: `[-10, -20, 120, 140]`.
|
||||
* - `responseExtension: [0, 0]` keeps the response area equal to the bounding box.
|
||||
*/
|
||||
responseExtension: [number, number] = [0, 0];
|
||||
|
||||
rotate = 0;
|
||||
|
||||
get deserializedXYWH() {
|
||||
if (this._cacheDeserKey !== this.xywh || !this._cacheDeserXYWH) {
|
||||
this._cacheDeserKey = this.xywh;
|
||||
this._cacheDeserXYWH = deserializeXYWH(this.xywh);
|
||||
}
|
||||
|
||||
return this._cacheDeserXYWH;
|
||||
}
|
||||
|
||||
get elementBound() {
|
||||
return Bound.from(getBoundWithRotation(this));
|
||||
}
|
||||
|
||||
get externalBound(): Bound | null {
|
||||
return this._externalXYWH ? Bound.deserialize(this._externalXYWH) : null;
|
||||
}
|
||||
|
||||
get externalXYWH(): SerializedXYWH | undefined {
|
||||
return this._externalXYWH;
|
||||
}
|
||||
|
||||
set externalXYWH(xywh: SerializedXYWH | undefined) {
|
||||
this._externalXYWH = xywh;
|
||||
}
|
||||
|
||||
get group(): GfxGroupModel | null {
|
||||
if (!this.surface) return null;
|
||||
|
||||
return this.surface.getGroup(this.id) ?? null;
|
||||
}
|
||||
|
||||
get groups(): GfxGroupModel[] {
|
||||
if (!this.surface) return [];
|
||||
|
||||
return this.surface.getGroups(this.id);
|
||||
}
|
||||
|
||||
get h() {
|
||||
return this.deserializedXYWH[3];
|
||||
}
|
||||
|
||||
get responseBound() {
|
||||
return this.elementBound.expand(this.responseExtension);
|
||||
}
|
||||
|
||||
get surface(): SurfaceBlockModel | null {
|
||||
const result = this.doc.getBlocksByFlavour('affine:surface');
|
||||
if (result.length === 0) return null;
|
||||
return result[0].model as SurfaceBlockModel;
|
||||
}
|
||||
|
||||
get w() {
|
||||
return this.deserializedXYWH[2];
|
||||
}
|
||||
|
||||
get x() {
|
||||
return this.deserializedXYWH[0];
|
||||
}
|
||||
|
||||
get y() {
|
||||
return this.deserializedXYWH[1];
|
||||
}
|
||||
|
||||
containsBound(bounds: Bound): boolean {
|
||||
const bound = Bound.deserialize(this.xywh);
|
||||
const points = getPointsFromBoundWithRotation({
|
||||
x: bound.x,
|
||||
y: bound.y,
|
||||
w: bound.w,
|
||||
h: bound.h,
|
||||
rotate: this.rotate,
|
||||
});
|
||||
return points.some(point => bounds.containsPoint(point));
|
||||
}
|
||||
|
||||
getLineIntersections(start: IVec, end: IVec): PointLocation[] | null {
|
||||
const bound = Bound.deserialize(this.xywh);
|
||||
|
||||
return linePolygonIntersects(
|
||||
start,
|
||||
end,
|
||||
rotatePoints(bound.points, bound.center, this.rotate ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
getNearestPoint(point: IVec): IVec {
|
||||
const bound = Bound.deserialize(this.xywh);
|
||||
return polygonNearestPoint(
|
||||
rotatePoints(bound.points, bound.center, this.rotate ?? 0),
|
||||
point
|
||||
);
|
||||
}
|
||||
|
||||
getRelativePointLocation(relativePoint: IVec): PointLocation {
|
||||
const bound = Bound.deserialize(this.xywh);
|
||||
const point = bound.getRelativePoint(relativePoint);
|
||||
const rotatePoint = rotatePoints(
|
||||
[point],
|
||||
bound.center,
|
||||
this.rotate ?? 0
|
||||
)[0];
|
||||
const points = rotatePoints(bound.points, bound.center, this.rotate ?? 0);
|
||||
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], 0);
|
||||
}
|
||||
|
||||
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.doc, this);
|
||||
}
|
||||
|
||||
unlock() {
|
||||
unlockElementImpl(this.doc, this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a BlockModel to a GfxBlockElementModel.
|
||||
* @param BlockModelSuperClass The BlockModel class to be converted.
|
||||
* @returns The returned class is a subclass of the GfxBlockElementModel class and the given BlockModelSuperClass.
|
||||
*/
|
||||
export function GfxCompatibleBlockModel<
|
||||
Props extends GfxCompatibleProps,
|
||||
T extends Constructor<BlockModel<Props>> = Constructor<BlockModel<Props>>,
|
||||
>(BlockModelSuperClass: T) {
|
||||
if (BlockModelSuperClass === BlockModel) {
|
||||
return GfxBlockElementModel as unknown as typeof GfxBlockElementModel<Props>;
|
||||
} else {
|
||||
let currentClass = BlockModelSuperClass;
|
||||
|
||||
while (
|
||||
Object.getPrototypeOf(currentClass.prototype) !== BlockModel.prototype &&
|
||||
Object.getPrototypeOf(currentClass.prototype) !== null
|
||||
) {
|
||||
currentClass = Object.getPrototypeOf(currentClass.prototype).constructor;
|
||||
}
|
||||
|
||||
if (Object.getPrototypeOf(currentClass.prototype) === null) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.GfxBlockElementError,
|
||||
'The SuperClass is not a subclass of BlockModel'
|
||||
);
|
||||
}
|
||||
|
||||
Object.setPrototypeOf(
|
||||
currentClass.prototype,
|
||||
GfxBlockElementModel.prototype
|
||||
);
|
||||
}
|
||||
|
||||
return BlockModelSuperClass as unknown as typeof GfxBlockElementModel<Props>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { GfxGroupCompatibleInterface } from './base.js';
|
||||
import type { GfxBlockElementModel } from './gfx-block-model.js';
|
||||
import type {
|
||||
GfxGroupLikeElementModel,
|
||||
GfxPrimitiveElementModel,
|
||||
} from './surface/element-model.js';
|
||||
|
||||
export type GfxModel = GfxBlockElementModel | GfxPrimitiveElementModel;
|
||||
|
||||
export type GfxGroupModel =
|
||||
| (GfxGroupCompatibleInterface & GfxBlockElementModel)
|
||||
| GfxGroupLikeElementModel;
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { SurfaceBlockModel } from '../surface-model.js';
|
||||
|
||||
/**
|
||||
* Set metadata for a property
|
||||
* @param symbol Unique symbol for the metadata
|
||||
* @param target The target object to set metadata on, usually the prototype
|
||||
* @param prop The property name
|
||||
* @param val The value to set
|
||||
*/
|
||||
export function setObjectPropMeta(
|
||||
symbol: symbol,
|
||||
target: unknown,
|
||||
prop: string | symbol,
|
||||
val: unknown
|
||||
) {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
target[symbol] = target[symbol] ?? {};
|
||||
// @ts-expect-error FIXME: ts error
|
||||
target[symbol][prop] = val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for a property
|
||||
* @param target The target object to retrieve metadata from, usually the prototype
|
||||
* @param symbol Unique symbol for the metadata
|
||||
* @param prop The property name, if not provided, returns all metadata for that symbol
|
||||
* @returns
|
||||
*/
|
||||
export function getObjectPropMeta(
|
||||
target: unknown,
|
||||
symbol: symbol,
|
||||
prop?: string | symbol
|
||||
) {
|
||||
if (prop) {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
return target[symbol]?.[prop] ?? null;
|
||||
}
|
||||
|
||||
// @ts-expect-error FIXME: ts error
|
||||
return target[symbol] ?? {};
|
||||
}
|
||||
|
||||
export function getDecoratorState(surface: SurfaceBlockModel) {
|
||||
return surface['_decoratorState'];
|
||||
}
|
||||
|
||||
export function createDecoratorState() {
|
||||
return {
|
||||
creating: false,
|
||||
deriving: false,
|
||||
skipField: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { GfxPrimitiveElementModel } from '../element-model.js';
|
||||
import { getObjectPropMeta, setObjectPropMeta } from './common.js';
|
||||
|
||||
const convertSymbol = Symbol('convert');
|
||||
|
||||
/**
|
||||
* The convert decorator is used to convert the property value before it's
|
||||
* set to the Y map.
|
||||
*
|
||||
* Note:
|
||||
* 1. This decorator function will not execute in model initialization.
|
||||
* @param fn
|
||||
* @returns
|
||||
*/
|
||||
export function convert<V, T extends GfxPrimitiveElementModel>(
|
||||
fn: (propValue: V, instance: T) => unknown
|
||||
) {
|
||||
return function convertDecorator(
|
||||
_: unknown,
|
||||
context: ClassAccessorDecoratorContext
|
||||
) {
|
||||
const prop = String(context.name);
|
||||
return {
|
||||
init(this: T, v: V) {
|
||||
const proto = Object.getPrototypeOf(this);
|
||||
setObjectPropMeta(convertSymbol, proto, prop, fn);
|
||||
return v;
|
||||
},
|
||||
} as ClassAccessorDecoratorResult<T, V>;
|
||||
};
|
||||
}
|
||||
|
||||
function getConvertMeta(
|
||||
proto: unknown,
|
||||
prop: string | symbol
|
||||
): null | ((propValue: unknown, instance: unknown) => unknown) {
|
||||
return getObjectPropMeta(proto, convertSymbol, prop);
|
||||
}
|
||||
|
||||
export function convertProps(
|
||||
propName: string | symbol,
|
||||
propValue: unknown,
|
||||
receiver: unknown
|
||||
) {
|
||||
const proto = Object.getPrototypeOf(receiver);
|
||||
const convertFn = getConvertMeta(proto, propName as string)!;
|
||||
|
||||
return convertFn ? convertFn(propValue, receiver) : propValue;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { GfxPrimitiveElementModel } from '../element-model.js';
|
||||
import {
|
||||
getDecoratorState,
|
||||
getObjectPropMeta,
|
||||
setObjectPropMeta,
|
||||
} from './common.js';
|
||||
|
||||
const deriveSymbol = Symbol('derive');
|
||||
|
||||
const keys = Object.keys;
|
||||
|
||||
function getDerivedMeta(
|
||||
proto: unknown,
|
||||
prop: string | symbol
|
||||
):
|
||||
| null
|
||||
| ((propValue: unknown, instance: unknown) => Record<string, unknown>)[] {
|
||||
return getObjectPropMeta(proto, deriveSymbol, prop);
|
||||
}
|
||||
|
||||
export function getDerivedProps(
|
||||
prop: string | symbol,
|
||||
propValue: unknown,
|
||||
receiver: GfxPrimitiveElementModel
|
||||
) {
|
||||
const prototype = Object.getPrototypeOf(receiver);
|
||||
const decoratorState = getDecoratorState(receiver.surface);
|
||||
|
||||
if (decoratorState.deriving || decoratorState.creating) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const deriveFns = getDerivedMeta(prototype, prop as string)!;
|
||||
|
||||
return deriveFns
|
||||
? deriveFns.reduce(
|
||||
(derivedProps, fn) => {
|
||||
const props = fn(propValue, receiver);
|
||||
|
||||
Object.entries(props).forEach(([key, value]) => {
|
||||
derivedProps[key] = value;
|
||||
});
|
||||
|
||||
return derivedProps;
|
||||
},
|
||||
{} as Record<string, unknown>
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function updateDerivedProps(
|
||||
derivedProps: Record<string, unknown> | null,
|
||||
receiver: GfxPrimitiveElementModel
|
||||
) {
|
||||
if (derivedProps) {
|
||||
const decoratorState = getDecoratorState(receiver.surface);
|
||||
decoratorState.deriving = true;
|
||||
keys(derivedProps).forEach(key => {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
receiver[key] = derivedProps[key];
|
||||
});
|
||||
decoratorState.deriving = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The derive decorator is used to derive other properties' update when the
|
||||
* decorated property is updated through assignment in the local.
|
||||
*
|
||||
* Note:
|
||||
* 1. The first argument of the function is the new value of the decorated property
|
||||
* before the `convert` decorator is called.
|
||||
* 2. The decorator function will execute after the decorated property has been updated.
|
||||
* 3. The decorator function will not execute during model creation.
|
||||
* 4. The decorator function will not execute if the decorated property is updated through
|
||||
* the Y map. That is to say, if other peers update the property will not trigger this decorator
|
||||
* @param fn
|
||||
* @returns
|
||||
*/
|
||||
export function derive<V, T extends GfxPrimitiveElementModel>(
|
||||
fn: (propValue: any, instance: T) => Record<string, unknown>
|
||||
) {
|
||||
return function deriveDecorator(
|
||||
_: unknown,
|
||||
context: ClassAccessorDecoratorContext
|
||||
) {
|
||||
const prop = String(context.name);
|
||||
return {
|
||||
init(this: GfxPrimitiveElementModel, v: V) {
|
||||
const proto = Object.getPrototypeOf(this);
|
||||
const derived = getDerivedMeta(proto, prop);
|
||||
|
||||
if (Array.isArray(derived)) {
|
||||
derived.push(fn as (typeof derived)[0]);
|
||||
} else {
|
||||
setObjectPropMeta(deriveSymbol, proto, prop as string, [fn]);
|
||||
}
|
||||
|
||||
return v;
|
||||
},
|
||||
} as ClassAccessorDecoratorResult<GfxPrimitiveElementModel, V>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { GfxPrimitiveElementModel } from '../element-model.js';
|
||||
import { getDecoratorState } from './common.js';
|
||||
import { convertProps } from './convert.js';
|
||||
import { getDerivedProps, updateDerivedProps } from './derive.js';
|
||||
import { startObserve } from './observer.js';
|
||||
|
||||
const yPropsSetSymbol = Symbol('yProps');
|
||||
|
||||
export function getFieldPropsSet(target: unknown): Set<string | symbol> {
|
||||
const proto = Object.getPrototypeOf(target);
|
||||
if (!Object.hasOwn(proto, yPropsSetSymbol)) {
|
||||
proto[yPropsSetSymbol] = new Set();
|
||||
}
|
||||
|
||||
return proto[yPropsSetSymbol] as Set<string | symbol>;
|
||||
}
|
||||
|
||||
export function field<V, T extends GfxPrimitiveElementModel>(fallback?: V) {
|
||||
return function yDecorator(
|
||||
_: ClassAccessorDecoratorTarget<T, V>,
|
||||
context: ClassAccessorDecoratorContext
|
||||
) {
|
||||
const prop = context.name;
|
||||
|
||||
return {
|
||||
init(this: GfxPrimitiveElementModel, v: V) {
|
||||
const yProps = getFieldPropsSet(this);
|
||||
|
||||
yProps.add(prop);
|
||||
|
||||
if (
|
||||
getDecoratorState(
|
||||
this.surface ?? Object.getPrototypeOf(this).constructor
|
||||
)?.skipField
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.yMap) {
|
||||
if (this.yMap.doc) {
|
||||
this.surface.doc.transact(() => {
|
||||
this.yMap.set(prop as string, v);
|
||||
});
|
||||
} else {
|
||||
this.yMap.set(prop as string, v);
|
||||
this._preserved.set(prop as string, v);
|
||||
}
|
||||
}
|
||||
|
||||
return v;
|
||||
},
|
||||
get(this: GfxPrimitiveElementModel) {
|
||||
return (
|
||||
(this.yMap.doc ? this.yMap.get(prop as string) : null) ??
|
||||
this._preserved.get(prop as string) ??
|
||||
fallback
|
||||
);
|
||||
},
|
||||
set(this: T, originalVal: V) {
|
||||
const isCreating = getDecoratorState(this.surface)?.creating;
|
||||
|
||||
if (getDecoratorState(this.surface)?.skipField) {
|
||||
return;
|
||||
}
|
||||
|
||||
const derivedProps = getDerivedProps(prop, originalVal, this);
|
||||
const val = isCreating
|
||||
? originalVal
|
||||
: convertProps(prop, originalVal, this);
|
||||
|
||||
if (this.yMap.doc) {
|
||||
this.surface.doc.transact(() => {
|
||||
this.yMap.set(prop as string, val);
|
||||
});
|
||||
} else {
|
||||
this.yMap.set(prop as string, val);
|
||||
this._preserved.set(prop as string, val);
|
||||
}
|
||||
|
||||
startObserve(prop as string, this);
|
||||
|
||||
if (!isCreating) {
|
||||
updateDerivedProps(derivedProps, this);
|
||||
}
|
||||
},
|
||||
} as ClassAccessorDecoratorResult<T, V>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { convert, convertProps } from './convert.js';
|
||||
export { derive, getDerivedProps, updateDerivedProps } from './derive.js';
|
||||
export { field, getFieldPropsSet } from './field.js';
|
||||
export { local } from './local.js';
|
||||
export { initializeObservers, observe } from './observer.js';
|
||||
export { initializeWatchers, watch } from './watch.js';
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { GfxPrimitiveElementModel } from '../element-model.js';
|
||||
import { getDecoratorState } from './common.js';
|
||||
import { convertProps } from './convert.js';
|
||||
import { getDerivedProps, updateDerivedProps } from './derive.js';
|
||||
|
||||
/**
|
||||
* A decorator to mark the property as a local property.
|
||||
*
|
||||
* The local property act like it is a field property, but it's not synced to the Y map.
|
||||
* Updating local property will also trigger the `elementUpdated` slot of the surface model
|
||||
*/
|
||||
export function local<V, T extends GfxPrimitiveElementModel>() {
|
||||
return function localDecorator(
|
||||
_target: ClassAccessorDecoratorTarget<T, V>,
|
||||
context: ClassAccessorDecoratorContext
|
||||
) {
|
||||
const prop = context.name;
|
||||
|
||||
return {
|
||||
init(this: T, v: V) {
|
||||
this._local.set(prop, v);
|
||||
|
||||
return v;
|
||||
},
|
||||
get(this: T) {
|
||||
return this._local.get(prop);
|
||||
},
|
||||
set(this: T, originalValue: unknown) {
|
||||
const isCreating = getDecoratorState(this.surface)?.creating;
|
||||
const oldValue = this._local.get(prop);
|
||||
// When state is creating, the value is considered as default value
|
||||
// hence there's no need to convert it
|
||||
const newVal = isCreating
|
||||
? originalValue
|
||||
: convertProps(prop, originalValue, this);
|
||||
|
||||
const derivedProps = getDerivedProps(prop, originalValue, this);
|
||||
|
||||
this._local.set(prop, newVal);
|
||||
|
||||
// During creating, no need to invoke an update event and derive another update
|
||||
if (!isCreating) {
|
||||
updateDerivedProps(derivedProps, this);
|
||||
|
||||
this._onChange({
|
||||
props: {
|
||||
[prop]: newVal,
|
||||
},
|
||||
oldValues: {
|
||||
[prop]: oldValue,
|
||||
},
|
||||
local: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
} as ClassAccessorDecoratorResult<T, V>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { Y } from '@blocksuite/store';
|
||||
|
||||
import type { GfxPrimitiveElementModel } from '../element-model.js';
|
||||
import { getObjectPropMeta, setObjectPropMeta } from './common.js';
|
||||
|
||||
const observeSymbol = Symbol('observe');
|
||||
const observerDisposableSymbol = Symbol('observerDisposable');
|
||||
|
||||
type ObserveFn<
|
||||
E extends Y.YEvent<any> = Y.YEvent<any>,
|
||||
T extends GfxPrimitiveElementModel = GfxPrimitiveElementModel,
|
||||
> = (
|
||||
/**
|
||||
* The event object of the Y.Map or Y.Array, the `null` value means the observer is initializing.
|
||||
*/
|
||||
event: E | null,
|
||||
instance: T,
|
||||
/**
|
||||
* The transaction object of the Y.Map or Y.Array, the `null` value means the observer is initializing.
|
||||
*/
|
||||
transaction: Y.Transaction | null
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* A decorator to observe the y type property.
|
||||
* You can think of it is just a decorator version of 'observe' method of Y.Array and Y.Map.
|
||||
*
|
||||
* The observer function start to observe the property when the model is mounted. And it will
|
||||
* re-observe the property automatically when the value is altered.
|
||||
* @param fn
|
||||
* @returns
|
||||
*/
|
||||
export function observe<
|
||||
V,
|
||||
E extends Y.YEvent<any>,
|
||||
T extends GfxPrimitiveElementModel,
|
||||
>(fn: ObserveFn<E, T>) {
|
||||
return function observeDecorator(
|
||||
_: unknown,
|
||||
context: ClassAccessorDecoratorContext
|
||||
) {
|
||||
const prop = context.name;
|
||||
return {
|
||||
init(this: T, v: V) {
|
||||
setObjectPropMeta(observeSymbol, Object.getPrototypeOf(this), prop, fn);
|
||||
return v;
|
||||
},
|
||||
} as ClassAccessorDecoratorResult<GfxPrimitiveElementModel, V>;
|
||||
};
|
||||
}
|
||||
|
||||
function getObserveMeta(
|
||||
proto: unknown,
|
||||
prop: string | symbol
|
||||
): null | ObserveFn {
|
||||
return getObjectPropMeta(proto, observeSymbol, prop);
|
||||
}
|
||||
|
||||
export function startObserve(
|
||||
prop: string | symbol,
|
||||
receiver: GfxPrimitiveElementModel
|
||||
) {
|
||||
const proto = Object.getPrototypeOf(receiver);
|
||||
const observeFn = getObserveMeta(proto, prop as string)!;
|
||||
// @ts-expect-error FIXME: ts error
|
||||
const observerDisposable = receiver[observerDisposableSymbol] ?? {};
|
||||
|
||||
// @ts-expect-error FIXME: ts error
|
||||
receiver[observerDisposableSymbol] = observerDisposable;
|
||||
|
||||
if (observerDisposable[prop]) {
|
||||
observerDisposable[prop]();
|
||||
delete observerDisposable[prop];
|
||||
}
|
||||
|
||||
if (!observeFn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = receiver[prop as keyof GfxPrimitiveElementModel] as
|
||||
| Y.Map<unknown>
|
||||
| Y.Array<unknown>
|
||||
| null;
|
||||
|
||||
observeFn(null, receiver, null);
|
||||
|
||||
const fn = (event: Y.YEvent<any>, transaction: Y.Transaction) => {
|
||||
observeFn(event, receiver, transaction);
|
||||
};
|
||||
|
||||
if (value && 'observe' in value) {
|
||||
value.observe(fn);
|
||||
|
||||
observerDisposable[prop] = () => {
|
||||
value.unobserve(fn);
|
||||
};
|
||||
} else {
|
||||
console.warn(
|
||||
`Failed to observe "${prop.toString()}" of ${
|
||||
receiver.type
|
||||
} element, make sure it's a Y type.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeObservers(
|
||||
proto: unknown,
|
||||
receiver: GfxPrimitiveElementModel
|
||||
) {
|
||||
const observers = getObjectPropMeta(proto, observeSymbol);
|
||||
|
||||
Object.keys(observers).forEach(prop => {
|
||||
startObserve(prop, receiver);
|
||||
});
|
||||
|
||||
receiver['_disposable'].add(() => {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
Object.values(receiver[observerDisposableSymbol] ?? {}).forEach(dispose =>
|
||||
(dispose as () => void)()
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { GfxPrimitiveElementModel } from '../element-model.js';
|
||||
import { getObjectPropMeta, setObjectPropMeta } from './common.js';
|
||||
|
||||
type WatchFn<T extends GfxPrimitiveElementModel = GfxPrimitiveElementModel> = (
|
||||
oldValue: unknown,
|
||||
instance: T,
|
||||
local: boolean
|
||||
) => void;
|
||||
|
||||
const watchSymbol = Symbol('watch');
|
||||
|
||||
/**
|
||||
* The watch decorator is used to watch the property change of the element.
|
||||
* You can thinks of it as a decorator version of `elementUpdated` slot of the surface model.
|
||||
*/
|
||||
export function watch<V, T extends GfxPrimitiveElementModel>(fn: WatchFn<T>) {
|
||||
return function watchDecorator(
|
||||
_: unknown,
|
||||
context: ClassAccessorDecoratorContext
|
||||
) {
|
||||
const prop = context.name;
|
||||
return {
|
||||
init(this: GfxPrimitiveElementModel, v: V) {
|
||||
setObjectPropMeta(watchSymbol, Object.getPrototypeOf(this), prop, fn);
|
||||
return v;
|
||||
},
|
||||
} as ClassAccessorDecoratorResult<GfxPrimitiveElementModel, V>;
|
||||
};
|
||||
}
|
||||
|
||||
function getWatchMeta(proto: unknown, prop: string | symbol): null | WatchFn {
|
||||
return getObjectPropMeta(proto, watchSymbol, prop);
|
||||
}
|
||||
|
||||
function startWatch(prop: string | symbol, receiver: GfxPrimitiveElementModel) {
|
||||
const proto = Object.getPrototypeOf(receiver);
|
||||
const watchFn = getWatchMeta(proto, prop as string)!;
|
||||
|
||||
if (!watchFn) return;
|
||||
|
||||
receiver['_disposable'].add(
|
||||
receiver.surface.elementUpdated.on(payload => {
|
||||
if (payload.id === receiver.id && prop in payload.props) {
|
||||
watchFn(payload.oldValues[prop as string], receiver, payload.local);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function initializeWatchers(
|
||||
prototype: unknown,
|
||||
receiver: GfxPrimitiveElementModel
|
||||
) {
|
||||
const watchers = getObjectPropMeta(prototype, watchSymbol);
|
||||
|
||||
Object.keys(watchers).forEach(prop => {
|
||||
startWatch(prop, receiver);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
import {
|
||||
Bound,
|
||||
deserializeXYWH,
|
||||
DisposableGroup,
|
||||
getBoundWithRotation,
|
||||
getPointsFromBoundWithRotation,
|
||||
isEqual,
|
||||
type IVec,
|
||||
linePolygonIntersects,
|
||||
PointLocation,
|
||||
polygonGetPointTangent,
|
||||
polygonNearestPoint,
|
||||
randomSeed,
|
||||
rotatePoints,
|
||||
type SerializedXYWH,
|
||||
Slot,
|
||||
type XYWH,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { DocCollection, type Y } from '@blocksuite/store';
|
||||
import { createMutex } from 'lib0/mutex';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export type SerializedElement = Record<string, unknown> & {
|
||||
type: string;
|
||||
xywh: SerializedXYWH;
|
||||
id: string;
|
||||
index: string;
|
||||
lockedBySelf?: 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 Slot<{ 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();
|
||||
}
|
||||
|
||||
static propsToY(props: Record<string, unknown>) {
|
||||
return props;
|
||||
}
|
||||
|
||||
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.doc, this);
|
||||
}
|
||||
|
||||
onCreated() {}
|
||||
|
||||
onDestroyed() {
|
||||
this._disposable.dispose();
|
||||
this.propsUpdated.dispose();
|
||||
}
|
||||
|
||||
pop(prop: keyof Props | string) {
|
||||
if (!this._stashed.has(prop)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = this._stashed.get(prop);
|
||||
this._stashed.delete(prop);
|
||||
// @ts-expect-error FIXME: ts error
|
||||
delete this[prop];
|
||||
|
||||
if (getFieldPropsSet(this).has(prop as string)) {
|
||||
if (!isEqual(value, this.yMap.get(prop as string))) {
|
||||
this.surface.doc.transact(() => {
|
||||
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.doc, 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;
|
||||
}
|
||||
|
||||
export abstract class GfxGroupLikeElementModel<
|
||||
Props extends BaseElementProps = BaseElementProps,
|
||||
>
|
||||
extends GfxPrimitiveElementModel<Props>
|
||||
implements GfxGroupCompatibleInterface
|
||||
{
|
||||
private _childIds: string[] = [];
|
||||
|
||||
private _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.doc.getBlockById(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 DocCollection.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 DocCollection.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);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { IVec, SerializedXYWH, XYWH } from '@blocksuite/global/utils';
|
||||
import {
|
||||
Bound,
|
||||
deserializeXYWH,
|
||||
getPointsFromBoundWithRotation,
|
||||
linePolygonIntersects,
|
||||
PointLocation,
|
||||
polygonGetPointTangent,
|
||||
polygonNearestPoint,
|
||||
rotatePoints,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { mutex } from 'lib0';
|
||||
|
||||
import type { EditorHost } from '../../../view/index.js';
|
||||
import type { GfxCompatibleInterface, PointTestOptions } from '../base.js';
|
||||
import type { GfxGroupModel } from '../model.js';
|
||||
import type { SurfaceBlockModel } from './surface-model.js';
|
||||
|
||||
export function prop<V, T extends GfxLocalElementModel>() {
|
||||
return function propDecorator(
|
||||
_target: ClassAccessorDecoratorTarget<T, V>,
|
||||
context: ClassAccessorDecoratorContext
|
||||
) {
|
||||
const prop = context.name;
|
||||
|
||||
return {
|
||||
init(this: T, val: unknown) {
|
||||
this._props.add(prop);
|
||||
this._local.set(prop, val);
|
||||
},
|
||||
get(this: T) {
|
||||
return this._local.get(prop);
|
||||
},
|
||||
set(this: T, val: V) {
|
||||
this._local.set(prop, val);
|
||||
},
|
||||
} as ClassAccessorDecoratorResult<T, V>;
|
||||
};
|
||||
}
|
||||
|
||||
export abstract class GfxLocalElementModel implements GfxCompatibleInterface {
|
||||
private _mutex: mutex.mutex = mutex.createMutex();
|
||||
|
||||
protected _local = new Map<string | symbol, unknown>();
|
||||
|
||||
/**
|
||||
* Used to store all the name of the properties that have been decorated
|
||||
* with the `@prop`
|
||||
*/
|
||||
protected _props = new Set<string | symbol>();
|
||||
|
||||
protected _surface: SurfaceBlockModel;
|
||||
|
||||
/**
|
||||
* used to store the properties' cache key
|
||||
* when the properties required heavy computation
|
||||
*/
|
||||
cache = new Map<string | symbol, unknown>();
|
||||
|
||||
id: string = '';
|
||||
|
||||
abstract readonly type: string;
|
||||
|
||||
get deserializedXYWH() {
|
||||
if (!this._local.has('deserializedXYWH')) {
|
||||
const xywh = this.xywh;
|
||||
const deserialized = deserializeXYWH(xywh);
|
||||
|
||||
this._local.set('deserializedXYWH', deserialized);
|
||||
}
|
||||
|
||||
return this._local.get('deserializedXYWH') as XYWH;
|
||||
}
|
||||
|
||||
get elementBound() {
|
||||
return new Bound(this.x, this.y, this.w, this.h);
|
||||
}
|
||||
|
||||
get group() {
|
||||
return (
|
||||
this.groupId ? this._surface.getElementById(this.groupId) : null
|
||||
) as GfxGroupModel | null;
|
||||
}
|
||||
|
||||
get groups() {
|
||||
if (this.group) {
|
||||
const groups = this._surface.getGroups(this.group.id);
|
||||
groups.unshift(this.group);
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
get h() {
|
||||
return this.deserializedXYWH[3];
|
||||
}
|
||||
|
||||
get responseBound() {
|
||||
return this.elementBound.expand(this.responseExtension);
|
||||
}
|
||||
|
||||
get surface() {
|
||||
return this._surface;
|
||||
}
|
||||
|
||||
get w() {
|
||||
return this.deserializedXYWH[2];
|
||||
}
|
||||
|
||||
get x() {
|
||||
return this.deserializedXYWH[0];
|
||||
}
|
||||
|
||||
get y() {
|
||||
return this.deserializedXYWH[1];
|
||||
}
|
||||
|
||||
constructor(surfaceModel: SurfaceBlockModel) {
|
||||
this._surface = surfaceModel;
|
||||
|
||||
const p = new Proxy(this, {
|
||||
set: (target, prop, value) => {
|
||||
if (prop === 'xywh') {
|
||||
this._local.delete('deserializedXYWH');
|
||||
}
|
||||
|
||||
// @ts-expect-error FIXME: ts error
|
||||
const oldValue = target[prop as string];
|
||||
|
||||
if (oldValue === value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// @ts-expect-error FIXME: ts error
|
||||
target[prop as string] = value;
|
||||
|
||||
if (!this._props.has(prop)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (surfaceModel.localElementModels.has(p)) {
|
||||
this._mutex(() => {
|
||||
surfaceModel.localElementUpdated.emit({
|
||||
model: p,
|
||||
props: {
|
||||
[prop as string]: value,
|
||||
},
|
||||
oldValues: {
|
||||
[prop as string]: oldValue,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-constructor-return
|
||||
return p;
|
||||
}
|
||||
|
||||
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() {
|
||||
return false;
|
||||
}
|
||||
|
||||
isLockedByAncestor() {
|
||||
return false;
|
||||
}
|
||||
|
||||
isLockedBySelf() {
|
||||
return false;
|
||||
}
|
||||
|
||||
lock() {
|
||||
return;
|
||||
}
|
||||
|
||||
unlock() {
|
||||
return;
|
||||
}
|
||||
|
||||
@prop()
|
||||
accessor groupId: string = '';
|
||||
|
||||
@prop()
|
||||
accessor hidden: boolean = false;
|
||||
|
||||
@prop()
|
||||
accessor index: string = 'a0';
|
||||
|
||||
@prop()
|
||||
accessor opacity: number = 1;
|
||||
|
||||
@prop()
|
||||
accessor responseExtension: [number, number] = [0, 0];
|
||||
|
||||
@prop()
|
||||
accessor rotate: number = 0;
|
||||
|
||||
@prop()
|
||||
accessor seed: number = Math.random();
|
||||
|
||||
@prop()
|
||||
accessor xywh: SerializedXYWH = '[0,0,0,0]';
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
import { assertType, type Constructor, Slot } from '@blocksuite/global/utils';
|
||||
import type { Boxed, Y } from '@blocksuite/store';
|
||||
import { BlockModel, DocCollection, nanoid } from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
type GfxGroupCompatibleInterface,
|
||||
isGfxGroupCompatibleModel,
|
||||
} from '../base.js';
|
||||
import type { GfxGroupModel, GfxModel } from '../model.js';
|
||||
import { createDecoratorState } from './decorators/common.js';
|
||||
import { initializeObservers, initializeWatchers } from './decorators/index.js';
|
||||
import {
|
||||
GfxGroupLikeElementModel,
|
||||
GfxPrimitiveElementModel,
|
||||
syncElementFromY,
|
||||
} from './element-model.js';
|
||||
import type { GfxLocalElementModel } from './local-element-model.js';
|
||||
|
||||
export type SurfaceBlockProps = {
|
||||
elements: Boxed<Y.Map<Y.Map<unknown>>>;
|
||||
};
|
||||
|
||||
export interface ElementUpdatedData {
|
||||
id: string;
|
||||
props: Record<string, unknown>;
|
||||
oldValues: Record<string, unknown>;
|
||||
local: boolean;
|
||||
}
|
||||
|
||||
export type MiddlewareCtx = {
|
||||
type: 'beforeAdd';
|
||||
payload: {
|
||||
type: string;
|
||||
props: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type SurfaceMiddleware = (ctx: MiddlewareCtx) => void;
|
||||
|
||||
export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
||||
protected _decoratorState = createDecoratorState();
|
||||
|
||||
protected _elementCtorMap: Record<
|
||||
string,
|
||||
Constructor<
|
||||
GfxPrimitiveElementModel,
|
||||
ConstructorParameters<typeof GfxPrimitiveElementModel>
|
||||
>
|
||||
> = Object.create(null);
|
||||
|
||||
protected _elementModels = new Map<
|
||||
string,
|
||||
{
|
||||
mount: () => void;
|
||||
unmount: () => void;
|
||||
model: GfxPrimitiveElementModel;
|
||||
}
|
||||
>();
|
||||
|
||||
protected _elementTypeMap = new Map<string, GfxPrimitiveElementModel[]>();
|
||||
|
||||
protected _groupLikeModels = new Map<string, GfxGroupModel>();
|
||||
|
||||
protected _middlewares: SurfaceMiddleware[] = [];
|
||||
|
||||
protected _surfaceBlockModel = true;
|
||||
|
||||
elementAdded = new Slot<{ id: string; local: boolean }>();
|
||||
|
||||
elementRemoved = new Slot<{
|
||||
id: string;
|
||||
type: string;
|
||||
model: GfxPrimitiveElementModel;
|
||||
local: boolean;
|
||||
}>();
|
||||
|
||||
elementUpdated = new Slot<ElementUpdatedData>();
|
||||
|
||||
localElementAdded = new Slot<GfxLocalElementModel>();
|
||||
|
||||
localElementDeleted = new Slot<GfxLocalElementModel>();
|
||||
|
||||
protected localElements = new Set<GfxLocalElementModel>();
|
||||
|
||||
localElementUpdated = new Slot<{
|
||||
model: GfxLocalElementModel;
|
||||
props: Record<string, unknown>;
|
||||
oldValues: Record<string, unknown>;
|
||||
}>();
|
||||
|
||||
get elementModels() {
|
||||
const models: GfxPrimitiveElementModel[] = [];
|
||||
this._elementModels.forEach(model => models.push(model.model));
|
||||
return models;
|
||||
}
|
||||
|
||||
get localElementModels() {
|
||||
return this.localElements;
|
||||
}
|
||||
|
||||
get registeredElementTypes() {
|
||||
return Object.keys(this._elementCtorMap);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.created.once(() => this._init());
|
||||
}
|
||||
|
||||
private _createElementFromProps(
|
||||
props: Record<string, unknown>,
|
||||
options: {
|
||||
onChange: (payload: {
|
||||
id: string;
|
||||
props: Record<string, unknown>;
|
||||
oldValues: Record<string, unknown>;
|
||||
local: boolean;
|
||||
}) => void;
|
||||
}
|
||||
) {
|
||||
const { type, id, ...rest } = props;
|
||||
|
||||
if (!id) {
|
||||
throw new Error('Cannot find id in props');
|
||||
}
|
||||
|
||||
const yMap = new DocCollection.Y.Map();
|
||||
const elementModel = this._createElementFromYMap(
|
||||
type as string,
|
||||
id as string,
|
||||
yMap,
|
||||
{
|
||||
...options,
|
||||
newCreate: true,
|
||||
}
|
||||
);
|
||||
|
||||
props = this._propsToY(type as string, props);
|
||||
|
||||
yMap.set('type', type);
|
||||
yMap.set('id', id);
|
||||
|
||||
Object.keys(rest).forEach(key => {
|
||||
if (props[key] !== undefined) {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
elementModel.model[key] = props[key];
|
||||
}
|
||||
});
|
||||
|
||||
return elementModel;
|
||||
}
|
||||
|
||||
private _createElementFromYMap(
|
||||
type: string,
|
||||
id: string,
|
||||
yMap: Y.Map<unknown>,
|
||||
options: {
|
||||
onChange: (payload: {
|
||||
id: string;
|
||||
props: Record<string, unknown>;
|
||||
oldValues: Record<string, unknown>;
|
||||
local: boolean;
|
||||
}) => void;
|
||||
skipFieldInit?: boolean;
|
||||
newCreate?: boolean;
|
||||
}
|
||||
) {
|
||||
const stashed = new Map<string | symbol, unknown>();
|
||||
const Ctor = this._elementCtorMap[type];
|
||||
|
||||
if (!Ctor) {
|
||||
throw new Error(`Invalid element type: ${yMap.get('type')}`);
|
||||
}
|
||||
const state = this._decoratorState;
|
||||
|
||||
state.creating = true;
|
||||
state.skipField = options.skipFieldInit ?? false;
|
||||
|
||||
let mounted = false;
|
||||
// @ts-expect-error FIXME: ts error
|
||||
Ctor['_decoratorState'] = state;
|
||||
|
||||
const elementModel = new Ctor({
|
||||
id,
|
||||
yMap,
|
||||
model: this,
|
||||
stashedStore: stashed,
|
||||
onChange: payload => mounted && options.onChange({ id, ...payload }),
|
||||
}) as GfxPrimitiveElementModel;
|
||||
|
||||
// @ts-expect-error FIXME: ts error
|
||||
delete Ctor['_decoratorState'];
|
||||
state.creating = false;
|
||||
state.skipField = false;
|
||||
|
||||
const unmount = () => {
|
||||
mounted = false;
|
||||
elementModel.onDestroyed();
|
||||
};
|
||||
|
||||
const mount = () => {
|
||||
initializeObservers(Ctor.prototype, elementModel);
|
||||
initializeWatchers(Ctor.prototype, elementModel);
|
||||
elementModel['_disposable'].add(
|
||||
syncElementFromY(elementModel, payload => {
|
||||
mounted &&
|
||||
options.onChange({
|
||||
id,
|
||||
...payload,
|
||||
});
|
||||
})
|
||||
);
|
||||
mounted = true;
|
||||
elementModel.onCreated();
|
||||
};
|
||||
|
||||
return {
|
||||
model: elementModel,
|
||||
mount,
|
||||
unmount,
|
||||
};
|
||||
}
|
||||
|
||||
private _initElementModels() {
|
||||
const elementsYMap = this.elements.getValue()!;
|
||||
const addToType = (type: string, model: GfxPrimitiveElementModel) => {
|
||||
const sameTypeElements = this._elementTypeMap.get(type) || [];
|
||||
|
||||
if (sameTypeElements.indexOf(model) === -1) {
|
||||
sameTypeElements.push(model);
|
||||
}
|
||||
|
||||
this._elementTypeMap.set(type, sameTypeElements);
|
||||
|
||||
if (isGfxGroupCompatibleModel(model)) {
|
||||
this._groupLikeModels.set(model.id, model);
|
||||
}
|
||||
};
|
||||
const removeFromType = (type: string, model: GfxPrimitiveElementModel) => {
|
||||
const sameTypeElements = this._elementTypeMap.get(type) || [];
|
||||
const index = sameTypeElements.indexOf(model);
|
||||
|
||||
if (index !== -1) {
|
||||
sameTypeElements.splice(index, 1);
|
||||
}
|
||||
|
||||
if (this._groupLikeModels.has(model.id)) {
|
||||
this._groupLikeModels.delete(model.id);
|
||||
}
|
||||
};
|
||||
const onElementsMapChange = (
|
||||
event: Y.YMapEvent<Y.Map<unknown>>,
|
||||
transaction: Y.Transaction
|
||||
) => {
|
||||
const { changes, keysChanged } = event;
|
||||
const addedElements: {
|
||||
mount: () => void;
|
||||
model: GfxPrimitiveElementModel;
|
||||
}[] = [];
|
||||
const deletedElements: {
|
||||
unmount: () => void;
|
||||
model: GfxPrimitiveElementModel;
|
||||
}[] = [];
|
||||
|
||||
keysChanged.forEach(id => {
|
||||
const change = changes.keys.get(id);
|
||||
const element = this.elements.getValue()!.get(id);
|
||||
|
||||
switch (change?.action) {
|
||||
case 'add':
|
||||
if (element) {
|
||||
const hasModel = this._elementModels.has(id);
|
||||
const model = hasModel
|
||||
? this._elementModels.get(id)!
|
||||
: this._createElementFromYMap(
|
||||
element.get('type') as string,
|
||||
element.get('id') as string,
|
||||
element,
|
||||
{
|
||||
onChange: payload => {
|
||||
this.elementUpdated.emit(payload);
|
||||
Object.keys(payload.props).forEach(key => {
|
||||
model.model.propsUpdated.emit({ key });
|
||||
});
|
||||
},
|
||||
skipFieldInit: true,
|
||||
}
|
||||
);
|
||||
|
||||
!hasModel && this._elementModels.set(id, model);
|
||||
addToType(model.model.type, model.model);
|
||||
addedElements.push(model);
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
if (this._elementModels.has(id)) {
|
||||
const { model, unmount } = this._elementModels.get(id)!;
|
||||
removeFromType(model.type, model);
|
||||
this._elementModels.delete(id);
|
||||
deletedElements.push({ model, unmount });
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
addedElements.forEach(({ mount, model }) => {
|
||||
mount();
|
||||
this.elementAdded.emit({ id: model.id, local: transaction.local });
|
||||
});
|
||||
deletedElements.forEach(({ unmount, model }) => {
|
||||
unmount();
|
||||
this.elementRemoved.emit({
|
||||
id: model.id,
|
||||
type: model.type,
|
||||
model,
|
||||
local: transaction.local,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
elementsYMap.forEach((val, key) => {
|
||||
const model = this._createElementFromYMap(
|
||||
val.get('type') as string,
|
||||
val.get('id') as string,
|
||||
val,
|
||||
{
|
||||
onChange: payload => {
|
||||
this.elementUpdated.emit(payload),
|
||||
Object.keys(payload.props).forEach(key => {
|
||||
model.model.propsUpdated.emit({ key });
|
||||
});
|
||||
},
|
||||
skipFieldInit: true,
|
||||
}
|
||||
);
|
||||
|
||||
this._elementModels.set(key, model);
|
||||
});
|
||||
|
||||
this._elementModels.forEach(({ mount, model }) => {
|
||||
addToType(model.type, model);
|
||||
mount();
|
||||
});
|
||||
|
||||
Object.values(this.doc.blocks.peek()).forEach(block => {
|
||||
if (isGfxGroupCompatibleModel(block.model)) {
|
||||
this._groupLikeModels.set(block.id, block.model);
|
||||
}
|
||||
});
|
||||
|
||||
elementsYMap.observe(onElementsMapChange);
|
||||
|
||||
const disposable = this.doc.slots.blockUpdated.on(payload => {
|
||||
switch (payload.type) {
|
||||
case 'add':
|
||||
if (isGfxGroupCompatibleModel(payload.model)) {
|
||||
this._groupLikeModels.set(payload.id, payload.model);
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
if (isGfxGroupCompatibleModel(payload.model)) {
|
||||
this._groupLikeModels.delete(payload.id);
|
||||
}
|
||||
{
|
||||
const group = this.getGroup(payload.id);
|
||||
if (group) {
|
||||
// eslint-disable-next-line unicorn/prefer-dom-node-remove
|
||||
group.removeChild(payload.model as GfxModel);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
this.deleted.on(() => {
|
||||
elementsYMap.unobserve(onElementsMapChange);
|
||||
disposable.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
private _propsToY(type: string, props: Record<string, unknown>) {
|
||||
const ctor = this._elementCtorMap[type];
|
||||
|
||||
if (!ctor) {
|
||||
throw new Error(`Invalid element type: ${type}`);
|
||||
}
|
||||
|
||||
// @ts-expect-error FIXME: ts error
|
||||
return (ctor.propsToY ?? GfxPrimitiveElementModel.propsToY)(props);
|
||||
}
|
||||
|
||||
private _watchGroupRelationChange() {
|
||||
const isGroup = (
|
||||
element: GfxPrimitiveElementModel
|
||||
): element is GfxGroupLikeElementModel =>
|
||||
element instanceof GfxGroupLikeElementModel;
|
||||
|
||||
const disposable = this.elementUpdated.on(({ id, oldValues }) => {
|
||||
const element = this.getElementById(id)!;
|
||||
|
||||
if (
|
||||
isGroup(element) &&
|
||||
oldValues['childIds'] &&
|
||||
element.childIds.length === 0
|
||||
) {
|
||||
this.deleteElement(id);
|
||||
}
|
||||
});
|
||||
this.deleted.on(() => {
|
||||
disposable.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
protected _extendElement(
|
||||
ctorMap: Record<
|
||||
string,
|
||||
Constructor<
|
||||
GfxPrimitiveElementModel,
|
||||
ConstructorParameters<typeof GfxPrimitiveElementModel>
|
||||
>
|
||||
>
|
||||
) {
|
||||
Object.assign(this._elementCtorMap, ctorMap);
|
||||
}
|
||||
|
||||
protected _init() {
|
||||
this._initElementModels();
|
||||
this._watchGroupRelationChange();
|
||||
}
|
||||
|
||||
addElement<T extends object = Record<string, unknown>>(
|
||||
props: Partial<T> & { type: string }
|
||||
) {
|
||||
if (this.doc.readonly) {
|
||||
throw new Error('Cannot add element in readonly mode');
|
||||
}
|
||||
|
||||
const middlewareCtx: MiddlewareCtx = {
|
||||
type: 'beforeAdd',
|
||||
payload: {
|
||||
type: props.type,
|
||||
props,
|
||||
},
|
||||
};
|
||||
|
||||
this._middlewares.forEach(mid => mid(middlewareCtx));
|
||||
|
||||
props = middlewareCtx.payload.props as Partial<T> & { type: string };
|
||||
|
||||
const id = nanoid();
|
||||
|
||||
// @ts-expect-error FIXME: ts error
|
||||
props.id = id;
|
||||
|
||||
const elementModel = this._createElementFromProps(props, {
|
||||
onChange: payload => {
|
||||
this.elementUpdated.emit(payload);
|
||||
Object.keys(payload.props).forEach(key => {
|
||||
elementModel.model.propsUpdated.emit({ key });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
this._elementModels.set(id, elementModel);
|
||||
|
||||
this.doc.transact(() => {
|
||||
this.elements.getValue()!.set(id, elementModel.model.yMap);
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
addLocalElement(elem: GfxLocalElementModel) {
|
||||
this.localElements.add(elem);
|
||||
this.localElementAdded.emit(elem);
|
||||
}
|
||||
|
||||
applyMiddlewares(middlewares: SurfaceMiddleware[]) {
|
||||
this._middlewares = middlewares;
|
||||
}
|
||||
|
||||
deleteElement(id: string) {
|
||||
if (this.doc.readonly) {
|
||||
throw new Error('Cannot remove element in readonly mode');
|
||||
}
|
||||
|
||||
if (!this.hasElementById(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.doc.transact(() => {
|
||||
const element = this.getElementById(id)!;
|
||||
const group = this.getGroup(id);
|
||||
|
||||
if (element instanceof GfxGroupLikeElementModel) {
|
||||
element.childIds.forEach(childId => {
|
||||
if (this.hasElementById(childId)) {
|
||||
this.deleteElement(childId);
|
||||
} else if (this.doc.hasBlock(childId)) {
|
||||
this.doc.deleteBlock(this.doc.getBlock(childId)!.model);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unicorn/prefer-dom-node-remove
|
||||
group?.removeChild(element as GfxModel);
|
||||
|
||||
this.elements.getValue()!.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
deleteLocalElement(elem: GfxLocalElementModel) {
|
||||
if (this.localElements.delete(elem)) {
|
||||
this.localElementDeleted.emit(elem);
|
||||
}
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
|
||||
this.elementAdded.dispose();
|
||||
this.elementRemoved.dispose();
|
||||
this.elementUpdated.dispose();
|
||||
|
||||
this._elementModels.forEach(({ unmount }) => unmount());
|
||||
this._elementModels.clear();
|
||||
}
|
||||
|
||||
getElementById(id: string): GfxPrimitiveElementModel | null {
|
||||
return this._elementModels.get(id)?.model ?? null;
|
||||
}
|
||||
|
||||
getElementsByType(type: string): GfxPrimitiveElementModel[] {
|
||||
return this._elementTypeMap.get(type) || [];
|
||||
}
|
||||
|
||||
getGroup(elem: string | GfxModel): GfxGroupModel | null {
|
||||
elem =
|
||||
typeof elem === 'string'
|
||||
? ((this.getElementById(elem) ??
|
||||
this.doc.getBlock(elem)?.model) as GfxModel)
|
||||
: elem;
|
||||
|
||||
if (!elem) return null;
|
||||
|
||||
assertType<GfxModel>(elem);
|
||||
|
||||
for (const group of this._groupLikeModels.values()) {
|
||||
if (group.hasChild(elem)) {
|
||||
return group;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getGroups(id: string): GfxGroupModel[] {
|
||||
const groups: GfxGroupModel[] = [];
|
||||
const visited = new Set<GfxGroupModel>();
|
||||
let group = this.getGroup(id);
|
||||
|
||||
while (group) {
|
||||
if (visited.has(group)) {
|
||||
console.warn('Exists a cycle in group relation');
|
||||
break;
|
||||
}
|
||||
visited.add(group);
|
||||
groups.push(group);
|
||||
group = this.getGroup(group.id);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
hasElementById(id: string): boolean {
|
||||
return this._elementModels.has(id);
|
||||
}
|
||||
|
||||
isGroup(element: GfxModel): element is GfxModel & GfxGroupCompatibleInterface;
|
||||
isGroup(id: string): boolean;
|
||||
isGroup(element: string | GfxModel): boolean {
|
||||
if (typeof element === 'string') {
|
||||
const el = this.getElementById(element);
|
||||
if (el) return isGfxGroupCompatibleModel(el);
|
||||
|
||||
const blockModel = this.doc.getBlock(element)?.model;
|
||||
if (blockModel) return isGfxGroupCompatibleModel(blockModel);
|
||||
|
||||
return false;
|
||||
} else {
|
||||
return isGfxGroupCompatibleModel(element);
|
||||
}
|
||||
}
|
||||
|
||||
updateElement<T extends object = Record<string, unknown>>(
|
||||
id: string,
|
||||
props: Partial<T>
|
||||
) {
|
||||
if (this.doc.readonly) {
|
||||
throw new Error('Cannot update element in readonly mode');
|
||||
}
|
||||
|
||||
const elementModel = this.getElementById(id);
|
||||
|
||||
if (!elementModel) {
|
||||
throw new Error(`Element ${id} is not found`);
|
||||
}
|
||||
|
||||
this.doc.transact(() => {
|
||||
props = this._propsToY(
|
||||
elementModel.type,
|
||||
props as Record<string, unknown>
|
||||
) as T;
|
||||
Object.entries(props).forEach(([key, value]) => {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
elementModel[key] = value;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user