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,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;
});
});
}
}