From 44b2ec1bca46cd594964fe803bbd02d562727668 Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Wed, 27 Dec 2023 15:52:01 +0800 Subject: [PATCH 01/25] feat: add basic elements api --- .../src/surface-block/element-model/base.ts | 61 +++++++ .../src/surface-block/element-model/index.ts | 92 +++++++++++ .../blocks/src/surface-block/surface-model.ts | 149 +++++++++++++++++- 3 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 packages/blocks/src/surface-block/element-model/base.ts create mode 100644 packages/blocks/src/surface-block/element-model/index.ts diff --git a/packages/blocks/src/surface-block/element-model/base.ts b/packages/blocks/src/surface-block/element-model/base.ts new file mode 100644 index 000000000000..5e8cab6d4345 --- /dev/null +++ b/packages/blocks/src/surface-block/element-model/base.ts @@ -0,0 +1,61 @@ +import { type Y } from '@blocksuite/store'; + +import type { SerializedXYWH } from '../index.js'; +import type { SurfaceBlockModel } from '../surface-model.js'; + +function MagicProps(): { + new (): Props; +} { + // @ts-ignore + return class {}; +} + +export type BaseProps = { + xywh: SerializedXYWH; +}; + +// @ts-ignore +export class ElementModel< + Props extends BaseProps = BaseProps, +> extends MagicProps() { + private _stashed: Map; + yMap!: Y.Map; + surfaceModel!: SurfaceBlockModel; + + constructor( + yMap: Y.Map, + model: SurfaceBlockModel, + stashedStore: Map + ) { + super(); + this.yMap = yMap; + this.surfaceModel = model; + this._stashed = stashedStore as Map; + } + + get type() { + return this.yMap.get('type') as string; + } + + get id() { + return this.yMap.get('id') as string; + } + + stash(prop: keyof Props) { + if (this._stashed.has(prop)) { + return; + } + + this._stashed.set(prop, this.yMap.get(prop as string)); + } + + pop(prop: keyof Props) { + if (!this._stashed.has(prop)) { + return; + } + + const value = this._stashed.get(prop); + this._stashed.delete(prop); + this.yMap.set(prop as string, value); + } +} diff --git a/packages/blocks/src/surface-block/element-model/index.ts b/packages/blocks/src/surface-block/element-model/index.ts new file mode 100644 index 000000000000..4cdbcd1e65f0 --- /dev/null +++ b/packages/blocks/src/surface-block/element-model/index.ts @@ -0,0 +1,92 @@ +import { type Y } from '@blocksuite/store'; + +import type { SurfaceBlockModel } from '../surface-model.js'; +import { ElementModel } from './base.js'; + +export function createElementModel( + yMap: Y.Map, + model: SurfaceBlockModel, + options: { + onChange: (payload: { id: string; props: Record }) => void; + } +) { + const stashed = new Map(); + const elementModel = new ElementModel(yMap, model, stashed); + const proxy = new Proxy(elementModel, { + has(target, prop) { + return Reflect.has(target, prop); + }, + + get(target, prop) { + if (stashed.has(prop)) { + return stashed.get(prop); + } + + return Reflect.get(target, prop); + }, + + set(target, prop, value) { + if (stashed.has(prop)) { + stashed.set(prop, value); + options.onChange({ + id: elementModel.id, + props: { + [prop]: value, + }, + }); + + return true; + } + + return Reflect.set(target, prop, value); + }, + + getPrototypeOf() { + return ElementModel.prototype; + }, + }); + const dispose = onElementChange(yMap, keys => { + options.onChange({ + id: elementModel.id, + props: keys.reduce((acc, key) => { + // @ts-ignore + acc[key] = proxy[key]; + return acc; + }, {}), + }); + }); + + return { + model: proxy, + dispose, + }; +} + +function onElementChange( + yMap: Y.Map, + callback: (keys: string[]) => void +) { + const observer = (event: Y.YMapEvent) => { + const keys: string[] = []; + + event.keysChanged.forEach(key => { + const type = event.changes.keys.get(key); + + if (!type) { + return; + } + + if (type.action === 'update' || type.action === 'add') { + keys.push(key); + } + }); + + callback(keys); + }; + + yMap.observe(observer); + + return () => { + yMap.unobserve(observer); + }; +} diff --git a/packages/blocks/src/surface-block/surface-model.ts b/packages/blocks/src/surface-block/surface-model.ts index 4870c3aa4242..dcdc8d8c5fcc 100644 --- a/packages/blocks/src/surface-block/surface-model.ts +++ b/packages/blocks/src/surface-block/surface-model.ts @@ -1,16 +1,20 @@ +import { Slot } from '@blocksuite/global/utils'; import type { MigrationRunner, Y } from '@blocksuite/store'; import { + BaseBlockModel, Boxed, defineBlockSchema, - type SchemaToModel, Text, Workspace, } from '@blocksuite/store'; +import type { ElementModel } from './element-model/base.js'; +import { createElementModel } from './element-model/index.js'; +import { generateElementId } from './index.js'; import { SurfaceBlockTransformer } from './surface-transformer.js'; export type SurfaceBlockProps = { - elements: Boxed>; + elements: Boxed>>; }; const migration = { @@ -89,7 +93,7 @@ const migration = { toV5: data => { const { elements } = data; if (!((elements as object | Boxed) instanceof Boxed)) { - const yMap = new Workspace.Y.Map(); + const yMap = new Workspace.Y.Map() as Y.Map>; Object.entries(elements).forEach(([key, value]) => { const map = new Workspace.Y.Map(); @@ -138,4 +142,141 @@ export const SurfaceBlockSchema = defineBlockSchema({ transformer: () => new SurfaceBlockTransformer(), }); -export type SurfaceBlockModel = SchemaToModel; +export class SurfaceBlockModel extends BaseBlockModel { + private _elementModels: Map< + string, + { dispose: () => void; model: ElementModel } + > = new Map(); + private _disposables: Array<() => void> = []; + + elementUpdated = new Slot<{ id: string; props: Record }>(); + elementAdded = new Slot<{ id: string }>(); + elementRemoved = new Slot<{ id: string; model: ElementModel }>(); + + get elementModels() { + const models: ElementModel[] = []; + this._elementModels.forEach(model => models.push(model.model)); + return models; + } + + constructor() { + super(); + this.created.once(() => this._init()); + } + + private _init() { + const elementsYMap = this.elements.getValue()!; + const emitUpdatedSlot = (payload: { + id: string; + props: Record; + }) => this.elementUpdated.emit(payload); + const createModel = (yMap: Y.Map) => + createElementModel(yMap, this, { + onChange: emitUpdatedSlot, + }); + const onElementsMapChange = (event: Y.YMapEvent>) => { + const { changes, keysChanged } = event; + + keysChanged.forEach(id => { + const change = changes.keys.get(id); + const element = this.elements.getValue()!.get(id); + + switch (change?.action) { + case 'add': + if (!this._elementModels.has(id) && element) { + this._elementModels.set(id, createModel(element)); + this.elementAdded.emit({ id }); + } + break; + case 'delete': + if (this._elementModels.has(id)) { + const { model, dispose } = this._elementModels.get(id)!; + dispose(); + this._elementModels.delete(id); + this.elementRemoved.emit({ id, model }); + } + break; + } + }); + }; + + elementsYMap.forEach((val, key) => { + this._elementModels.set(key, createModel(val)); + }); + elementsYMap.observe(onElementsMapChange); + + this._disposables.push(() => { + elementsYMap.unobserve(onElementsMapChange); + }); + } + + override dispose(): void { + super.dispose(); + this._disposables.forEach(dispose => dispose()); + } + + getElementById(id: string) { + return this._elementModels.get(id)?.model ?? null; + } + + addElement(props: Record) { + if (this.page.readonly) { + throw new Error('Cannot add element in readonly mode'); + } + + const id = generateElementId(); + const yMap = new Workspace.Y.Map(); + + props.id = id; + + Object.entries(props).forEach(([key, value]) => { + if ( + (key === 'text' || key === 'title') && + !(value instanceof Workspace.Y.Text) + ) { + yMap.set(key, new Workspace.Y.Text(value as string)); + } else { + yMap.set(key, value); + } + }); + + this.page.transact(() => { + this.elements.getValue()!.set(id, yMap); + }); + } + + removeElement(id: string) { + if (this.page.readonly) { + throw new Error('Cannot remove element in readonly mode'); + } + + this.page.transact(() => { + this.elements.getValue()!.delete(id); + }); + } + + updateElement(props: Record) { + if (this.page.readonly) { + throw new Error('Cannot update element in readonly mode'); + } + + const id = props.id as string; + const elementModel = this._elementModels.get(id); + + if (!elementModel) { + throw new Error(`Element ${id} is not found`); + } + + this.page.transact(() => { + Object.entries(props).forEach(([key, value]) => { + if (key === 'text' && !(value instanceof Workspace.Y.Text)) { + // @ts-ignore + elementModel[key] = new Workspace.Y.Text(value as string); + } else { + // @ts-ignore + elementModel[key] = value; + } + }); + }); + } +} From b08b75704c95571d14c94a73f2cdf969b9bcb54e Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Wed, 27 Dec 2023 17:11:29 +0800 Subject: [PATCH 02/25] feat: group --- .../edgeless/mixin/edgeless-selectable.ts | 9 ++ .../src/surface-block/element-model/base.ts | 16 ++-- .../src/surface-block/element-model/group.ts | 34 ++++++++ .../src/surface-block/element-model/index.ts | 46 ++++++---- .../blocks/src/surface-block/surface-model.ts | 86 ++++++++++++++++--- 5 files changed, 151 insertions(+), 40 deletions(-) create mode 100644 packages/blocks/src/surface-block/element-model/group.ts diff --git a/packages/blocks/src/_common/edgeless/mixin/edgeless-selectable.ts b/packages/blocks/src/_common/edgeless/mixin/edgeless-selectable.ts index 726c2489551e..8014d5ea3696 100644 --- a/packages/blocks/src/_common/edgeless/mixin/edgeless-selectable.ts +++ b/packages/blocks/src/_common/edgeless/mixin/edgeless-selectable.ts @@ -1,6 +1,7 @@ import type { Constructor } from '@blocksuite/global/utils'; import type { BaseBlockModel } from '@blocksuite/store'; +import type { SurfaceBlockModel } from '../../../models.js'; import { BLOCK_BATCH } from '../../../surface-block/batch.js'; import { Bound, @@ -99,6 +100,14 @@ export function selectable< ) ); } + + get group() { + const surfaceModel = this.page.getBlockByFlavour( + 'affine:surface' + ) as SurfaceBlockModel[]; + + return surfaceModel[0]?.getGroup(this.id) ?? null; + } } return DerivedSelectableInEdgelessClass as Constructor< diff --git a/packages/blocks/src/surface-block/element-model/base.ts b/packages/blocks/src/surface-block/element-model/base.ts index 5e8cab6d4345..26785258cae5 100644 --- a/packages/blocks/src/surface-block/element-model/base.ts +++ b/packages/blocks/src/surface-block/element-model/base.ts @@ -3,21 +3,12 @@ import { type Y } from '@blocksuite/store'; import type { SerializedXYWH } from '../index.js'; import type { SurfaceBlockModel } from '../surface-model.js'; -function MagicProps(): { - new (): Props; -} { - // @ts-ignore - return class {}; -} - export type BaseProps = { xywh: SerializedXYWH; }; // @ts-ignore -export class ElementModel< - Props extends BaseProps = BaseProps, -> extends MagicProps() { +export class ElementModel { private _stashed: Map; yMap!: Y.Map; surfaceModel!: SurfaceBlockModel; @@ -27,12 +18,15 @@ export class ElementModel< model: SurfaceBlockModel, stashedStore: Map ) { - super(); this.yMap = yMap; this.surfaceModel = model; this._stashed = stashedStore as Map; } + get group() { + return this.surfaceModel.getGroup(this.id); + } + get type() { return this.yMap.get('type') as string; } diff --git a/packages/blocks/src/surface-block/element-model/group.ts b/packages/blocks/src/surface-block/element-model/group.ts new file mode 100644 index 000000000000..c65a9c54dae9 --- /dev/null +++ b/packages/blocks/src/surface-block/element-model/group.ts @@ -0,0 +1,34 @@ +import type { Y } from '@blocksuite/store'; + +import type { BaseProps } from './base.js'; +import { ElementModel } from './base.js'; + +type GroupElementProps = BaseProps & { + children: Y.Map; + title: Y.Text; +}; + +export class GroupElementModel extends ElementModel { + get childrenIds() { + return [...this.children.keys()]; + } + + get children() { + return this.yMap.get('children') as GroupElementProps['children']; + } + + get childrenElements() { + const elements = []; + const keys = this.children.keys(); + + for (const key of keys) { + const element = + this.surfaceModel.getElementById(key) || + this.surfaceModel.page.getBlockById(key); + + element && elements.push(element); + } + + return elements; + } +} diff --git a/packages/blocks/src/surface-block/element-model/index.ts b/packages/blocks/src/surface-block/element-model/index.ts index 4cdbcd1e65f0..3f28413ea065 100644 --- a/packages/blocks/src/surface-block/element-model/index.ts +++ b/packages/blocks/src/surface-block/element-model/index.ts @@ -2,17 +2,31 @@ import { type Y } from '@blocksuite/store'; import type { SurfaceBlockModel } from '../surface-model.js'; import { ElementModel } from './base.js'; +import { GroupElementModel } from './group.js'; + +const elementsCtorMap = { + group: GroupElementModel, +}; export function createElementModel( yMap: Y.Map, model: SurfaceBlockModel, options: { - onChange: (payload: { id: string; props: Record }) => void; + onChange: (payload: { + id: string; + props: Record; + }) => void; } -) { +): { + model: ElementModel; + dispose: () => void; +} { const stashed = new Map(); - const elementModel = new ElementModel(yMap, model, stashed); - const proxy = new Proxy(elementModel, { + const Ctor = + elementsCtorMap[yMap.get('type') as keyof typeof elementsCtorMap] ?? + ElementModel; + const elementModel = new Ctor(yMap, model, stashed); + const proxy = new Proxy(elementModel as ElementModel, { has(target, prop) { return Reflect.has(target, prop); }, @@ -45,14 +59,10 @@ export function createElementModel( return ElementModel.prototype; }, }); - const dispose = onElementChange(yMap, keys => { + const dispose = onElementChange(yMap, props => { options.onChange({ id: elementModel.id, - props: keys.reduce((acc, key) => { - // @ts-ignore - acc[key] = proxy[key]; - return acc; - }, {}), + props, }); }); @@ -64,29 +74,31 @@ export function createElementModel( function onElementChange( yMap: Y.Map, - callback: (keys: string[]) => void + callback: (props: Record) => void ) { - const observer = (event: Y.YMapEvent) => { - const keys: string[] = []; + const observer = (events: Y.YEvent>[]) => { + const props: Record = {}; + const event = events[0] as Y.YMapEvent; 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') { - keys.push(key); + props[key] = { oldValue }; } }); - callback(keys); + callback(props); }; - yMap.observe(observer); + yMap.observeDeep(observer); return () => { - yMap.unobserve(observer); + yMap.observeDeep(observer); }; } diff --git a/packages/blocks/src/surface-block/surface-model.ts b/packages/blocks/src/surface-block/surface-model.ts index dcdc8d8c5fcc..76d43338040f 100644 --- a/packages/blocks/src/surface-block/surface-model.ts +++ b/packages/blocks/src/surface-block/surface-model.ts @@ -9,6 +9,7 @@ import { } from '@blocksuite/store'; import type { ElementModel } from './element-model/base.js'; +import type { GroupElementModel } from './element-model/group.js'; import { createElementModel } from './element-model/index.js'; import { generateElementId } from './index.js'; import { SurfaceBlockTransformer } from './surface-transformer.js'; @@ -148,8 +149,12 @@ export class SurfaceBlockModel extends BaseBlockModel { { dispose: () => void; model: ElementModel } > = new Map(); private _disposables: Array<() => void> = []; + private _elementsToGroup: Map = new Map(); - elementUpdated = new Slot<{ id: string; props: Record }>(); + elementUpdated = new Slot<{ + id: string; + props: Record; + }>(); elementAdded = new Slot<{ id: string }>(); elementRemoved = new Slot<{ id: string; model: ElementModel }>(); @@ -165,15 +170,12 @@ export class SurfaceBlockModel extends BaseBlockModel { } private _init() { + this._initElementModels(); + this._initGroup(); + } + + private _initElementModels() { const elementsYMap = this.elements.getValue()!; - const emitUpdatedSlot = (payload: { - id: string; - props: Record; - }) => this.elementUpdated.emit(payload); - const createModel = (yMap: Y.Map) => - createElementModel(yMap, this, { - onChange: emitUpdatedSlot, - }); const onElementsMapChange = (event: Y.YMapEvent>) => { const { changes, keysChanged } = event; @@ -184,7 +186,12 @@ export class SurfaceBlockModel extends BaseBlockModel { switch (change?.action) { case 'add': if (!this._elementModels.has(id) && element) { - this._elementModels.set(id, createModel(element)); + this._elementModels.set( + id, + createElementModel(element, this, { + onChange: payload => this.elementUpdated.emit(payload), + }) + ); this.elementAdded.emit({ id }); } break; @@ -201,7 +208,12 @@ export class SurfaceBlockModel extends BaseBlockModel { }; elementsYMap.forEach((val, key) => { - this._elementModels.set(key, createModel(val)); + this._elementModels.set( + key, + createElementModel(val, this, { + onChange: payload => this.elementUpdated.emit(payload), + }) + ); }); elementsYMap.observe(onElementsMapChange); @@ -210,12 +222,62 @@ export class SurfaceBlockModel extends BaseBlockModel { }); } + private _initGroup() { + this.elementModels.forEach(model => { + if (model.type === 'group') { + (model as GroupElementModel).childrenIds.forEach(childId => { + this._elementsToGroup.set(childId, model.id); + }); + } + }); + + this.elementUpdated.on(({ id, props }) => { + const element = this.getElementById(id)!; + + if (element.type === 'group' && props['children']) { + (props['children'].oldValue as Y.Map).forEach((_, childId) => { + this._elementsToGroup.delete(childId); + }); + + (element as GroupElementModel).childrenIds.forEach(childId => { + this._elementsToGroup.set(childId, id); + }); + } + }); + + this.elementAdded.on(id => { + const element = this.getElementById(id.id)!; + + if (element.type === 'group') { + (element as GroupElementModel).childrenIds.forEach(childId => { + this._elementsToGroup.set(childId, id.id); + }); + } + }); + + this.elementRemoved.on(id => { + const element = this.getElementById(id.id)!; + + if (element.type === 'group') { + (element as GroupElementModel).childrenIds.forEach(childId => { + this._elementsToGroup.delete(childId); + }); + } + }); + } + override dispose(): void { super.dispose(); this._disposables.forEach(dispose => dispose()); } - getElementById(id: string) { + getGroup(id: string): ElementModel | null { + return this._elementsToGroup.has(id) + ? this.getElementById(this._elementsToGroup.get(id)!) + : null; + } + + getElementById(id: string): ElementModel | null { return this._elementModels.get(id)?.model ?? null; } From ffb208df991d65a145cbd617feab2066f90ce9e2 Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Wed, 27 Dec 2023 20:45:19 +0800 Subject: [PATCH 03/25] feat: add connector --- .../src/surface-block/element-model/common.ts | 1 + .../surface-block/element-model/connector.ts | 83 +++++++++++++++++ .../blocks/src/surface-block/surface-model.ts | 92 +++++++++++++++++-- 3 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 packages/blocks/src/surface-block/element-model/common.ts create mode 100644 packages/blocks/src/surface-block/element-model/connector.ts diff --git a/packages/blocks/src/surface-block/element-model/common.ts b/packages/blocks/src/surface-block/element-model/common.ts new file mode 100644 index 000000000000..d037415a2d6f --- /dev/null +++ b/packages/blocks/src/surface-block/element-model/common.ts @@ -0,0 +1 @@ +export type StrokeStyle = 'solid' | 'dash' | 'none'; diff --git a/packages/blocks/src/surface-block/element-model/connector.ts b/packages/blocks/src/surface-block/element-model/connector.ts new file mode 100644 index 000000000000..17d01448d699 --- /dev/null +++ b/packages/blocks/src/surface-block/element-model/connector.ts @@ -0,0 +1,83 @@ +import { DEFAULT_ROUGHNESS } from '../consts.js'; +import { type BaseProps, ElementModel } from './base.js'; +import type { StrokeStyle } from './common.js'; + +export type PointStyle = 'None' | 'Arrow' | 'Triangle' | 'Circle' | 'Diamond'; + +export type Connection = { + id?: string; + position: [number, number]; +}; + +export enum ConnectorMode { + Straight, + Orthogonal, + Curve, +} + +type ConnectorElementProps = BaseProps & { + mode: ConnectorMode; + stroke: string; + strokeWidth: number; + strokeStyle: StrokeStyle; + roughness?: number; + rough?: boolean; + source: Connection; + target: Connection; + + frontEndpointStyle?: PointStyle; + rearEndpointStyle?: PointStyle; +}; + +export class ConnectorElementModel extends ElementModel { + get mode() { + return this.yMap.get('mode') as ConnectorElementProps['mode']; + } + + get strokeWidth() { + return this.yMap.get('strokeWidth') as ConnectorElementProps['strokeWidth']; + } + + get stroke() { + return this.yMap.get('stroke') as ConnectorElementProps['stroke']; + } + + get strokeStyle() { + return this.yMap.get('strokeStyle') as ConnectorElementProps['strokeStyle']; + } + + get roughness() { + return ( + (this.yMap.get('roughness') as ConnectorElementProps['roughness']) ?? + DEFAULT_ROUGHNESS + ); + } + + get rough() { + return (this.yMap.get('rough') as ConnectorElementProps['rough']) ?? false; + } + + get target() { + return this.yMap.get('target') as ConnectorElementProps['target']; + } + + get source() { + return this.yMap.get('source') as ConnectorElementProps['source']; + } + + get frontEndpointStyle() { + return ( + (this.yMap.get( + 'frontEndpointStyle' + ) as ConnectorElementProps['frontEndpointStyle']) ?? 'None' + ); + } + + get rearEndpointStyle() { + return ( + (this.yMap.get( + 'rearEndpointStyle' + ) as ConnectorElementProps['rearEndpointStyle']) ?? 'Arrow' + ); + } +} diff --git a/packages/blocks/src/surface-block/surface-model.ts b/packages/blocks/src/surface-block/surface-model.ts index 76d43338040f..b8e4cefe5995 100644 --- a/packages/blocks/src/surface-block/surface-model.ts +++ b/packages/blocks/src/surface-block/surface-model.ts @@ -9,6 +9,10 @@ import { } from '@blocksuite/store'; import type { ElementModel } from './element-model/base.js'; +import type { + Connection, + ConnectorElementModel, +} from './element-model/connector.js'; import type { GroupElementModel } from './element-model/group.js'; import { createElementModel } from './element-model/index.js'; import { generateElementId } from './index.js'; @@ -149,7 +153,8 @@ export class SurfaceBlockModel extends BaseBlockModel { { dispose: () => void; model: ElementModel } > = new Map(); private _disposables: Array<() => void> = []; - private _elementsToGroup: Map = new Map(); + private _elementToGroup: Map = new Map(); + private _elementToConnector: Map = new Map(); elementUpdated = new Slot<{ id: string; @@ -172,6 +177,7 @@ export class SurfaceBlockModel extends BaseBlockModel { private _init() { this._initElementModels(); this._initGroup(); + this._initConnector(); } private _initElementModels() { @@ -199,6 +205,8 @@ export class SurfaceBlockModel extends BaseBlockModel { if (this._elementModels.has(id)) { const { model, dispose } = this._elementModels.get(id)!; dispose(); + this._elementToGroup.delete(id); + this._elementToConnector.delete(id); this._elementModels.delete(id); this.elementRemoved.emit({ id, model }); } @@ -226,7 +234,7 @@ export class SurfaceBlockModel extends BaseBlockModel { this.elementModels.forEach(model => { if (model.type === 'group') { (model as GroupElementModel).childrenIds.forEach(childId => { - this._elementsToGroup.set(childId, model.id); + this._elementToGroup.set(childId, model.id); }); } }); @@ -236,11 +244,11 @@ export class SurfaceBlockModel extends BaseBlockModel { if (element.type === 'group' && props['children']) { (props['children'].oldValue as Y.Map).forEach((_, childId) => { - this._elementsToGroup.delete(childId); + this._elementToGroup.delete(childId); }); (element as GroupElementModel).childrenIds.forEach(childId => { - this._elementsToGroup.set(childId, id); + this._elementToGroup.set(childId, id); }); } }); @@ -250,7 +258,7 @@ export class SurfaceBlockModel extends BaseBlockModel { if (element.type === 'group') { (element as GroupElementModel).childrenIds.forEach(childId => { - this._elementsToGroup.set(childId, id.id); + this._elementToGroup.set(childId, id.id); }); } }); @@ -260,20 +268,88 @@ export class SurfaceBlockModel extends BaseBlockModel { if (element.type === 'group') { (element as GroupElementModel).childrenIds.forEach(childId => { - this._elementsToGroup.delete(childId); + this._elementToGroup.delete(childId); }); } }); } + private _initConnector() { + const addConnector = (targetId: string, connectorId: string) => { + const connectors = this._elementToConnector.get(targetId); + + if (!connectors) { + this._elementToConnector.set(targetId, [connectorId]); + } else { + connectors.push(connectorId); + } + }; + const removeConnector = (targetId: string, connectorId: string) => { + const connectors = this._elementToConnector.get(targetId); + + if (!connectors) { + return; + } + + const index = connectors.indexOf(connectorId); + + if (index !== -1) { + connectors.splice(index, 1); + } + + if (connectors.length === 0) { + this._elementToConnector.delete(targetId); + } + }; + const updateConnectorMap = ( + element: ElementModel, + type: 'add' | 'remove' + ) => { + if (element.type !== 'connector') return; + + const connector = element as ConnectorElementModel; + const connected = [connector.source.id, connector.target.id]; + const action = type === 'add' ? addConnector : removeConnector; + + connected.forEach(id => { + id && action(id, connector.id); + }); + }; + + this.elementModels.forEach(model => updateConnectorMap(model, 'add')); + + this.elementUpdated.on(({ id, props }) => { + const element = this.getElementById(id)!; + + if (element.type !== 'connector') return; + + const oldConnected = [ + (props['source']?.oldValue as Connection)?.id, + (props['target']?.oldValue as Connection)?.id, + ]; + + oldConnected.forEach(id => { + id && removeConnector(id, element.id); + }); + + updateConnectorMap(element, 'add'); + }); + + this.elementAdded.on(id => + updateConnectorMap(this.getElementById(id.id)!, 'add') + ); + + this.elementRemoved.on(({ model }) => updateConnectorMap(model, 'remove')); + } + override dispose(): void { super.dispose(); this._disposables.forEach(dispose => dispose()); } getGroup(id: string): ElementModel | null { - return this._elementsToGroup.has(id) - ? this.getElementById(this._elementsToGroup.get(id)!) + return this._elementToGroup.has(id) + ? this.getElementById(this._elementToGroup.get(id)!) : null; } From 943353927c4b536ad13be76fef07dccecc8f01ee Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Wed, 27 Dec 2023 20:49:30 +0800 Subject: [PATCH 04/25] fix: dispose --- packages/blocks/src/surface-block/surface-model.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/blocks/src/surface-block/surface-model.ts b/packages/blocks/src/surface-block/surface-model.ts index b8e4cefe5995..f8f24c418d6b 100644 --- a/packages/blocks/src/surface-block/surface-model.ts +++ b/packages/blocks/src/surface-block/surface-model.ts @@ -344,7 +344,15 @@ export class SurfaceBlockModel extends BaseBlockModel { override dispose(): void { super.dispose(); + this._disposables.forEach(dispose => dispose()); + + this.elementAdded.dispose(); + this.elementRemoved.dispose(); + this.elementUpdated.dispose(); + + this._elementModels.forEach(({ dispose }) => dispose()); + this._elementModels.clear(); } getGroup(id: string): ElementModel | null { From fb37bcbec70a8ea3e346b926fd3b39749c9efd47 Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Wed, 27 Dec 2023 20:50:55 +0800 Subject: [PATCH 05/25] feat: add getConnectors --- packages/blocks/src/surface-block/surface-model.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/blocks/src/surface-block/surface-model.ts b/packages/blocks/src/surface-block/surface-model.ts index f8f24c418d6b..80252d21f35e 100644 --- a/packages/blocks/src/surface-block/surface-model.ts +++ b/packages/blocks/src/surface-block/surface-model.ts @@ -355,6 +355,12 @@ export class SurfaceBlockModel extends BaseBlockModel { this._elementModels.clear(); } + getConnectors(id: string) { + return (this._elementToConnector.get(id) || []).map( + id => this.getElementById(id)! + ); + } + getGroup(id: string): ElementModel | null { return this._elementToGroup.has(id) ? this.getElementById(this._elementToGroup.get(id)!) From c1c92e390dbde8a86bbbac4e71e5423f09cc8984 Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Wed, 27 Dec 2023 20:51:59 +0800 Subject: [PATCH 06/25] fix: updateElement --- packages/blocks/src/surface-block/surface-model.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/blocks/src/surface-block/surface-model.ts b/packages/blocks/src/surface-block/surface-model.ts index 80252d21f35e..905ccd10ac82 100644 --- a/packages/blocks/src/surface-block/surface-model.ts +++ b/packages/blocks/src/surface-block/surface-model.ts @@ -413,7 +413,7 @@ export class SurfaceBlockModel extends BaseBlockModel { } const id = props.id as string; - const elementModel = this._elementModels.get(id); + const elementModel = this.getElementById(id); if (!elementModel) { throw new Error(`Element ${id} is not found`); From 782bc76f6414265505ff9fc6e17b8ca753646f4a Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Thu, 28 Dec 2023 17:19:47 +0800 Subject: [PATCH 07/25] feat: group and connector functionalities && add tests --- packages/blocks/src/index.ts | 3 + .../src/surface-block/element-model/base.ts | 24 +- .../src/surface-block/element-model/common.ts | 18 ++ .../surface-block/element-model/connector.ts | 16 ++ .../src/surface-block/element-model/group.ts | 31 +++ .../src/surface-block/element-model/index.ts | 47 +++- .../src/surface-block/element-model/shape.ts | 171 ++++++++++++ packages/blocks/src/surface-block/index.ts | 4 + .../blocks/src/surface-block/surface-model.ts | 136 ++++++---- .../tests/edgeless/surface-model.spec.ts | 245 ++++++++++++++++++ 10 files changed, 638 insertions(+), 57 deletions(-) create mode 100644 packages/blocks/src/surface-block/element-model/shape.ts create mode 100644 packages/presets/tests/edgeless/surface-model.spec.ts diff --git a/packages/blocks/src/index.ts b/packages/blocks/src/index.ts index 4116d821d183..1e8df01c4142 100644 --- a/packages/blocks/src/index.ts +++ b/packages/blocks/src/index.ts @@ -78,9 +78,12 @@ export * from './paragraph-block/index.js'; export { Bound, CanvasElementType, + ConnectorElementModel, ConnectorEndpointStyle, ConnectorMode, generateKeyBetween, + GroupElementModel, + ShapeElementModel, ShapeStyle, StrokeStyle, } from './surface-block/index.js'; diff --git a/packages/blocks/src/surface-block/element-model/base.ts b/packages/blocks/src/surface-block/element-model/base.ts index 26785258cae5..b405b8819af5 100644 --- a/packages/blocks/src/surface-block/element-model/base.ts +++ b/packages/blocks/src/surface-block/element-model/base.ts @@ -7,9 +7,19 @@ export type BaseProps = { xywh: SerializedXYWH; }; -// @ts-ignore -export class ElementModel { +export abstract class ElementModel { + static default() { + return { + xywh: '[0, 0, 100, 100]', + }; + } + + static propsToYStruct(props: BaseProps) { + return props; + } + private _stashed: Map; + yMap!: Y.Map; surfaceModel!: SurfaceBlockModel; @@ -23,12 +33,14 @@ export class ElementModel { this._stashed = stashedStore as Map; } - get group() { - return this.surfaceModel.getGroup(this.id); + abstract get type(): string; + + get xywh() { + return this.yMap.get('xywh') as SerializedXYWH; } - get type() { - return this.yMap.get('type') as string; + get group() { + return this.surfaceModel.getGroup(this.id); } get id() { diff --git a/packages/blocks/src/surface-block/element-model/common.ts b/packages/blocks/src/surface-block/element-model/common.ts index d037415a2d6f..a990ed059b9d 100644 --- a/packages/blocks/src/surface-block/element-model/common.ts +++ b/packages/blocks/src/surface-block/element-model/common.ts @@ -1 +1,19 @@ export type StrokeStyle = 'solid' | 'dash' | 'none'; + +export enum FontFamily { + Inter = 'blocksuite:surface:Inter', + Kalam = 'blocksuite:surface:Kalam', + Satoshi = 'blocksuite:surface:Satoshi', + Poppins = 'blocksuite:surface:Poppins', + Lora = 'blocksuite:surface:Lora', + BebasNeue = 'blocksuite:surface:BebasNeue', + OrelegaOne = 'blocksuite:surface:OrelegaOne', +} + +export const enum FontWeight { + Light = '300', + Regular = '400', + SemiBold = '600', +} + +export type FontStyle = 'normal' | 'italic'; diff --git a/packages/blocks/src/surface-block/element-model/connector.ts b/packages/blocks/src/surface-block/element-model/connector.ts index 17d01448d699..62ea07a0fb47 100644 --- a/packages/blocks/src/surface-block/element-model/connector.ts +++ b/packages/blocks/src/surface-block/element-model/connector.ts @@ -30,6 +30,22 @@ type ConnectorElementProps = BaseProps & { }; export class ConnectorElementModel extends ElementModel { + static override default() { + return { + mode: ConnectorMode.Orthogonal, + strokeWidth: 4, + stroke: '#000000', + strokeStyle: 'solid', + roughness: DEFAULT_ROUGHNESS, + source: {}, + target: {}, + } as ConnectorElementProps; + } + + get type() { + return 'connector'; + } + get mode() { return this.yMap.get('mode') as ConnectorElementProps['mode']; } diff --git a/packages/blocks/src/surface-block/element-model/group.ts b/packages/blocks/src/surface-block/element-model/group.ts index c65a9c54dae9..0f2ed4fe566c 100644 --- a/packages/blocks/src/surface-block/element-model/group.ts +++ b/packages/blocks/src/surface-block/element-model/group.ts @@ -1,5 +1,7 @@ import type { Y } from '@blocksuite/store'; +import { Workspace } from '@blocksuite/store'; +import { keys } from '../../_common/utils/iterable.js'; import type { BaseProps } from './base.js'; import { ElementModel } from './base.js'; @@ -9,6 +11,35 @@ type GroupElementProps = BaseProps & { }; export class GroupElementModel extends ElementModel { + static override default() { + return { + children: new Workspace.Y.Map(), + title: new Workspace.Y.Text(), + } as GroupElementProps; + } + + static override propsToYStruct(props: GroupElementProps) { + if (props.title && !(props.title instanceof Workspace.Y.Text)) { + props.title = new Workspace.Y.Text(props.title); + } + + if (props.children && !(props.children instanceof Workspace.Y.Map)) { + const children = new Workspace.Y.Map() as Y.Map; + + keys(props.children).forEach(key => { + children.set(key as string, true); + }); + + props.children = children; + } + + return props; + } + + get type() { + return 'group'; + } + get childrenIds() { return [...this.children.keys()]; } diff --git a/packages/blocks/src/surface-block/element-model/index.ts b/packages/blocks/src/surface-block/element-model/index.ts index 3f28413ea065..994490870ef3 100644 --- a/packages/blocks/src/surface-block/element-model/index.ts +++ b/packages/blocks/src/surface-block/element-model/index.ts @@ -1,11 +1,15 @@ -import { type Y } from '@blocksuite/store'; +import { Workspace, type Y } from '@blocksuite/store'; import type { SurfaceBlockModel } from '../surface-model.js'; import { ElementModel } from './base.js'; +import { ConnectorElementModel } from './connector.js'; import { GroupElementModel } from './group.js'; +import { ShapeElementModel } from './shape.js'; const elementsCtorMap = { group: GroupElementModel, + connector: ConnectorElementModel, + shape: ShapeElementModel, }; export function createElementModel( @@ -25,6 +29,11 @@ export function createElementModel( const Ctor = elementsCtorMap[yMap.get('type') as keyof typeof elementsCtorMap] ?? ElementModel; + + if (!Ctor) { + throw new Error(`Invalid element type: ${yMap.get('type')}`); + } + const elementModel = new Ctor(yMap, model, stashed); const proxy = new Proxy(elementModel as ElementModel, { has(target, prop) { @@ -52,7 +61,9 @@ export function createElementModel( return true; } - return Reflect.set(target, prop, value); + target.yMap.set(prop as string, value); + + return true; }, getPrototypeOf() { @@ -102,3 +113,35 @@ function onElementChange( yMap.observeDeep(observer); }; } + +export function propsToYStruct(type: string, props: Record) { + const ctor = elementsCtorMap[type as keyof typeof elementsCtorMap]; + + if (!ctor) { + throw new Error(`Invalid element type: ${type}`); + } + + return (ctor.propsToYStruct ?? ElementModel.propsToYStruct)( + // @ts-ignore + Object.assign(ctor.default(), props) + ); +} + +export function createYMapFromProps(props: Record) { + const type = props.type as string; + const ctor = elementsCtorMap[type as keyof typeof elementsCtorMap]; + + if (!ctor) { + throw new Error(`Invalid element type: ${type}`); + } + + const yMap = new Workspace.Y.Map(); + + props = propsToYStruct(type, Object.assign(ctor.default(), props)); + + Object.keys(props).forEach(key => { + yMap.set(key, props[key]); + }); + + return yMap; +} diff --git a/packages/blocks/src/surface-block/element-model/shape.ts b/packages/blocks/src/surface-block/element-model/shape.ts new file mode 100644 index 000000000000..5f88abc1ecfd --- /dev/null +++ b/packages/blocks/src/surface-block/element-model/shape.ts @@ -0,0 +1,171 @@ +import { Workspace, type Y } from '@blocksuite/store'; + +import { DEFAULT_ROUGHNESS } from '../consts.js'; +import { type BaseProps, ElementModel } from './base.js'; +import { type FontStyle, FontWeight } from './common.js'; +import { FontFamily, type StrokeStyle } from './common.js'; + +export type ShapeType = 'rect' | 'triangle' | 'ellipse' | 'diamond'; +export type ShapeStyle = 'General' | 'Scribbled'; + +export enum ShapeTextFontSize { + SMALL = 12, + MEDIUM = 20, + LARGE = 28, + XLARGE = 36, +} + +export type ShapeProps = BaseProps & { + shapeType: ShapeType; + radius: number; + filled: boolean; + fillColor: string; + strokeWidth: number; + strokeColor: string; + strokeStyle: StrokeStyle; + shapeStyle: ShapeStyle; + // https://github.com/rough-stuff/rough/wiki#roughness + roughness?: number; + + text?: Y.Text; + color?: string; + fontSize?: number; + fontFamily?: string; + fontWeight?: FontWeight; + fontStyle?: FontStyle; + textAlign?: 'left' | 'center' | 'right'; + textHorizontalAlign?: 'left' | 'center' | 'right'; + textVerticalAlign?: 'top' | 'center' | 'bottom'; +}; + +export class ShapeElementModel extends ElementModel { + static override default() { + return { + xywh: '[0,0,10,10]', + rotate: 0, + shapeType: 'rect', + shapeStyle: 'General', + radius: 0, + filled: false, + fillColor: '#ffffff', + strokeWidth: 4, + strokeColor: '#000000', + strokeStyle: 'solid', + roughness: DEFAULT_ROUGHNESS, + } as ShapeProps; + } + + static override propsToYStruct(props: ShapeProps) { + if (props.text && !(props.text instanceof Workspace.Y.Text)) { + props.text = new Workspace.Y.Text(props.text); + } + + return props; + } + + override get type() { + return 'shape'; + } + + get shapeType() { + const shapeType = this.yMap.get('shapeType') as ShapeProps['shapeType']; + return shapeType; + } + + get radius() { + const radius = this.yMap.get('radius') as ShapeProps['radius']; + return radius; + } + + get filled() { + const filled = this.yMap.get('filled') as ShapeProps['filled']; + return filled; + } + + get fillColor() { + const fillColor = this.yMap.get('fillColor') as ShapeProps['fillColor']; + return fillColor; + } + + get strokeWidth() { + const strokeWidth = this.yMap.get( + 'strokeWidth' + ) as ShapeProps['strokeWidth']; + return strokeWidth; + } + + get strokeColor() { + const strokeColor = this.yMap.get( + 'strokeColor' + ) as ShapeProps['strokeColor']; + return strokeColor; + } + + get strokeStyle() { + const strokeStyle = this.yMap.get( + 'strokeStyle' + ) as ShapeProps['strokeStyle']; + return strokeStyle; + } + + get shapeStyle() { + const shapeStyle = this.yMap.get('shapeStyle') as ShapeProps['shapeStyle']; + return shapeStyle; + } + + get text() { + const text = this.yMap.get('text') as ShapeProps['text']; + return text; + } + + get color() { + const color = (this.yMap.get('color') as ShapeProps['color']) ?? '#000000'; + return color; + } + + get fontSize() { + const fontSize = + (this.yMap.get('fontSize') as ShapeProps['fontSize']) ?? + ShapeTextFontSize.MEDIUM; + return fontSize; + } + + get fontFamily() { + const fontFamily = + (this.yMap.get('fontFamily') as ShapeProps['fontFamily']) ?? + FontFamily.Inter; + return fontFamily; + } + + get fontWeight() { + return ( + (this.yMap.get('fontWeight') as ShapeProps['fontWeight']) ?? + FontWeight.Regular + ); + } + + get fontStyle() { + return (this.yMap.get('fontStyle') as ShapeProps['fontStyle']) ?? 'normal'; + } + + get textAlign() { + const textAlign = + (this.yMap.get('textAlign') as ShapeProps['textAlign']) ?? 'center'; + return textAlign; + } + + get textHorizontalAlign() { + const textHorizontalAlign = + (this.yMap.get( + 'textHorizontalAlign' + ) as ShapeProps['textHorizontalAlign']) ?? 'center'; + return textHorizontalAlign; + } + + get textVerticalAlign() { + const textVerticalAlign = + (this.yMap.get('textVerticalAlign') as ShapeProps['textVerticalAlign']) ?? + 'center'; + return textVerticalAlign; + } +} diff --git a/packages/blocks/src/surface-block/index.ts b/packages/blocks/src/surface-block/index.ts index ff7103d5dd03..118e206ed1a0 100644 --- a/packages/blocks/src/surface-block/index.ts +++ b/packages/blocks/src/surface-block/index.ts @@ -10,6 +10,10 @@ export { } from './consts.js'; export { GRID_GAP_MAX, GRID_GAP_MIN } from './consts.js'; export { type EdgelessBlockType } from './edgeless-types.js'; +export { ElementModel } from './element-model/base.js'; +export { ConnectorElementModel } from './element-model/connector.js'; +export { GroupElementModel } from './element-model/group.js'; +export { ShapeElementModel } from './element-model/shape.js'; export { type Connection, ConnectorEndpoint, diff --git a/packages/blocks/src/surface-block/surface-model.ts b/packages/blocks/src/surface-block/surface-model.ts index 905ccd10ac82..82f751e3d3bc 100644 --- a/packages/blocks/src/surface-block/surface-model.ts +++ b/packages/blocks/src/surface-block/surface-model.ts @@ -14,7 +14,11 @@ import type { ConnectorElementModel, } from './element-model/connector.js'; import type { GroupElementModel } from './element-model/group.js'; -import { createElementModel } from './element-model/index.js'; +import { + createElementModel, + createYMapFromProps, + propsToYStruct, +} from './element-model/index.js'; import { generateElementId } from './index.js'; import { SurfaceBlockTransformer } from './surface-transformer.js'; @@ -145,6 +149,7 @@ export const SurfaceBlockSchema = defineBlockSchema({ } }, transformer: () => new SurfaceBlockTransformer(), + toModel: () => new SurfaceBlockModel(), }); export class SurfaceBlockModel extends BaseBlockModel { @@ -153,7 +158,9 @@ export class SurfaceBlockModel extends BaseBlockModel { { dispose: () => void; model: ElementModel } > = new Map(); private _disposables: Array<() => void> = []; + private _groupToElements: Map = new Map(); private _elementToGroup: Map = new Map(); + private _connectorToElements: Map = new Map(); private _elementToConnector: Map = new Map(); elementUpdated = new Slot<{ @@ -161,7 +168,7 @@ export class SurfaceBlockModel extends BaseBlockModel { props: Record; }>(); elementAdded = new Slot<{ id: string }>(); - elementRemoved = new Slot<{ id: string; model: ElementModel }>(); + elementRemoved = new Slot<{ id: string; type: string }>(); get elementModels() { const models: ElementModel[] = []; @@ -205,10 +212,10 @@ export class SurfaceBlockModel extends BaseBlockModel { if (this._elementModels.has(id)) { const { model, dispose } = this._elementModels.get(id)!; dispose(); + this.elementRemoved.emit({ id, type: model.type }); this._elementToGroup.delete(id); this._elementToConnector.delete(id); this._elementModels.delete(id); - this.elementRemoved.emit({ id, model }); } break; } @@ -231,10 +238,36 @@ export class SurfaceBlockModel extends BaseBlockModel { } private _initGroup() { + const addToGroup = (elementId: string, groupId: string) => { + this._elementToGroup.set(elementId, groupId); + this._groupToElements.set( + groupId, + (this._groupToElements.get(groupId) || []).concat(elementId) + ); + }; + const removeFromGroup = (elementId: string, groupId: string) => { + if (this._elementToGroup.has(elementId)) { + const group = this._elementToGroup.get(elementId)!; + if (group === groupId) { + this._elementToGroup.delete(elementId); + } + } + + if (this._groupToElements.has(groupId)) { + const elements = this._groupToElements.get(groupId)!; + const index = elements.indexOf(elementId); + + if (index !== -1) { + elements.splice(index, 1); + elements.length === 0 && this._groupToElements.delete(groupId); + } + } + }; + this.elementModels.forEach(model => { if (model.type === 'group') { (model as GroupElementModel).childrenIds.forEach(childId => { - this._elementToGroup.set(childId, model.id); + addToGroup(childId, model.id); }); } }); @@ -244,32 +277,30 @@ export class SurfaceBlockModel extends BaseBlockModel { if (element.type === 'group' && props['children']) { (props['children'].oldValue as Y.Map).forEach((_, childId) => { - this._elementToGroup.delete(childId); + removeFromGroup(childId, id); }); (element as GroupElementModel).childrenIds.forEach(childId => { - this._elementToGroup.set(childId, id); + addToGroup(childId, id); }); } }); - this.elementAdded.on(id => { - const element = this.getElementById(id.id)!; + this.elementAdded.on(({ id }) => { + const element = this.getElementById(id)!; if (element.type === 'group') { (element as GroupElementModel).childrenIds.forEach(childId => { - this._elementToGroup.set(childId, id.id); + addToGroup(childId, id); }); } }); - this.elementRemoved.on(id => { - const element = this.getElementById(id.id)!; + this.elementRemoved.on(({ id, type }) => { + if (type === 'group') { + const children = [...(this._groupToElements.get(id) || [])]; - if (element.type === 'group') { - (element as GroupElementModel).childrenIds.forEach(childId => { - this._elementToGroup.delete(childId); - }); + children.forEach(childId => removeFromGroup(childId, id)); } }); } @@ -283,24 +314,35 @@ export class SurfaceBlockModel extends BaseBlockModel { } else { connectors.push(connectorId); } + + this._connectorToElements.set( + connectorId, + (this._connectorToElements.get(connectorId) || []).concat(targetId) + ); }; const removeConnector = (targetId: string, connectorId: string) => { - const connectors = this._elementToConnector.get(targetId); + if (this._elementToConnector.has(targetId)) { + const connectors = this._elementToConnector.get(targetId)!; + const index = connectors.indexOf(connectorId); - if (!connectors) { - return; + if (index !== -1) { + connectors.splice(index, 1); + connectors.length === 0 && this._elementToConnector.delete(targetId); + } } - const index = connectors.indexOf(connectorId); - - if (index !== -1) { - connectors.splice(index, 1); - } + if (this._connectorToElements.has(connectorId)) { + const elements = this._connectorToElements.get(connectorId)!; + const index = elements.indexOf(targetId); - if (connectors.length === 0) { - this._elementToConnector.delete(targetId); + if (index !== -1) { + elements.splice(index, 1); + elements.length === 0 && + this._connectorToElements.delete(connectorId); + } } }; + const updateConnectorMap = ( element: ElementModel, type: 'add' | 'remove' @@ -321,7 +363,8 @@ export class SurfaceBlockModel extends BaseBlockModel { this.elementUpdated.on(({ id, props }) => { const element = this.getElementById(id)!; - if (element.type !== 'connector') return; + if (element.type !== 'connector' || !props['source'] || !props['target']) + return; const oldConnected = [ (props['source']?.oldValue as Connection)?.id, @@ -339,7 +382,13 @@ export class SurfaceBlockModel extends BaseBlockModel { updateConnectorMap(this.getElementById(id.id)!, 'add') ); - this.elementRemoved.on(({ model }) => updateConnectorMap(model, 'remove')); + this.elementRemoved.on(({ id, type }) => { + if (type === 'connector') { + const connected = [...(this._connectorToElements.get(id) || [])]; + + connected.forEach(connectedId => removeConnector(connectedId, id)); + } + }); } override dispose(): void { @@ -361,9 +410,11 @@ export class SurfaceBlockModel extends BaseBlockModel { ); } - getGroup(id: string): ElementModel | null { + getGroup(id: string): GroupElementModel | null { return this._elementToGroup.has(id) - ? this.getElementById(this._elementToGroup.get(id)!) + ? (this.getElementById( + this._elementToGroup.get(id)! + ) as GroupElementModel) : null; } @@ -377,24 +428,16 @@ export class SurfaceBlockModel extends BaseBlockModel { } const id = generateElementId(); - const yMap = new Workspace.Y.Map(); props.id = id; - Object.entries(props).forEach(([key, value]) => { - if ( - (key === 'text' || key === 'title') && - !(value instanceof Workspace.Y.Text) - ) { - yMap.set(key, new Workspace.Y.Text(value as string)); - } else { - yMap.set(key, value); - } - }); + const yMap = createYMapFromProps(props); this.page.transact(() => { this.elements.getValue()!.set(id, yMap); }); + + return id; } removeElement(id: string) { @@ -407,12 +450,11 @@ export class SurfaceBlockModel extends BaseBlockModel { }); } - updateElement(props: Record) { + updateElement(id: string, props: Record) { if (this.page.readonly) { throw new Error('Cannot update element in readonly mode'); } - const id = props.id as string; const elementModel = this.getElementById(id); if (!elementModel) { @@ -420,14 +462,10 @@ export class SurfaceBlockModel extends BaseBlockModel { } this.page.transact(() => { + props = propsToYStruct(elementModel.type, props); Object.entries(props).forEach(([key, value]) => { - if (key === 'text' && !(value instanceof Workspace.Y.Text)) { - // @ts-ignore - elementModel[key] = new Workspace.Y.Text(value as string); - } else { - // @ts-ignore - elementModel[key] = value; - } + // @ts-ignore + elementModel[key] = value; }); }); } diff --git a/packages/presets/tests/edgeless/surface-model.spec.ts b/packages/presets/tests/edgeless/surface-model.spec.ts new file mode 100644 index 000000000000..2e6ab21ed588 --- /dev/null +++ b/packages/presets/tests/edgeless/surface-model.spec.ts @@ -0,0 +1,245 @@ +import type { GroupElementModel, SurfaceBlockModel } from '@blocksuite/blocks'; +import { beforeEach, describe, expect, test } from 'vitest'; + +import { wait } from '../utils/common.js'; +import { setupEditor } from '../utils/setup.js'; + +let model: SurfaceBlockModel; + +beforeEach(async () => { + const cleanup = await setupEditor('edgeless'); + const models = page.getBlockByFlavour( + 'affine:surface' + ) as SurfaceBlockModel[]; + + model = models[0]; + + return cleanup; +}); + +describe('elements management', () => { + test('addElement should work correctly', () => { + model.addElement({ + type: 'shape', + }); + + expect(model.elementModels.length).toBe(1); + }); + + test('removeElement should work correctly', () => { + const id = model.addElement({ + type: 'shape', + }); + + model.removeElement(id); + + expect(model.elementModels.length).toBe(0); + }); + + test('updateElement should work correctly', () => { + const id = model.addElement({ + type: 'shape', + }); + + model.updateElement(id, { xywh: '[10,10,200,200]' }); + + expect(model.elementModels[0].xywh).toBe('[10,10,200,200]'); + }); + + test('getElementById should return element', () => { + const id = model.addElement({ + type: 'shape', + }); + + expect(model.getElementById(id)).not.toBeNull(); + }); + + test('getElementById should return null if not found', () => { + expect(model.getElementById('not-found')).toBeNull(); + }); +}); + +describe('group', () => { + test('should get group', () => { + const id = model.addElement({ + type: 'shape', + }); + const id2 = model.addElement({ + type: 'shape', + }); + + const groupId = model.addElement({ + type: 'group', + children: { + [id]: true, + [id2]: true, + }, + }); + const group = model.getElementById(groupId); + + expect(model.getGroup(id)).toBe(group); + expect(model.getGroup(id2)).toBe(group); + }); + + test('should return null if group children are updated', () => { + const id = model.addElement({ + type: 'shape', + }); + const id2 = model.addElement({ + type: 'shape', + }); + + const groupId = model.addElement({ + type: 'group', + children: { + [id]: true, + [id2]: true, + }, + }); + const group = model.getElementById(groupId) as GroupElementModel; + + model.page.transact(() => { + group.children.delete(id); + group.children.delete(id2); + }); + + expect(model.getGroup(id)).toBeNull(); + expect(model.getGroup(id2)).toBeNull(); + }); + + test('should return null if group are deleted', () => { + const id = model.addElement({ + type: 'shape', + }); + const id2 = model.addElement({ + type: 'shape', + }); + + const groupId = model.addElement({ + type: 'group', + children: { + [id]: true, + [id2]: true, + }, + }); + + model.removeElement(groupId); + expect(model.getGroup(id)).toBeNull(); + expect(model.getGroup(id2)).toBeNull(); + // @ts-ignore + expect(model._elementToGroup.get(id)).toBeUndefined(); + // @ts-ignore + expect(model._elementToGroup.get(id2)).toBeUndefined(); + }); +}); + +describe('connector', () => { + test('should get connector', () => { + const id = model.addElement({ + type: 'shape', + }); + const id2 = model.addElement({ + type: 'shape', + }); + const connectorId = model.addElement({ + type: 'connector', + source: { + id, + }, + target: { + id: id2, + }, + }); + const connector = model.getElementById(connectorId); + + expect(model.getConnectors(id)).toEqual([connector]); + expect(model.getConnectors(id2)).toEqual([connector]); + }); + + test('multiple connectors are supported', () => { + const id = model.addElement({ + type: 'shape', + }); + const id2 = model.addElement({ + type: 'shape', + }); + const connectorId = model.addElement({ + type: 'connector', + source: { + id, + }, + target: { + id: id2, + }, + }); + const connectorId2 = model.addElement({ + type: 'connector', + source: { + id, + }, + target: { + id: id2, + }, + }); + const connector = model.getElementById(connectorId); + const connector2 = model.getElementById(connectorId2); + + expect(model.getConnectors(id)).toEqual([connector, connector2]); + expect(model.getConnectors(id2)).toEqual([connector, connector2]); + }); + + test('should return null if connector are updated', () => { + const id = model.addElement({ + type: 'shape', + }); + const id2 = model.addElement({ + type: 'shape', + }); + const connectorId = model.addElement({ + type: 'connector', + source: { + id, + }, + target: { + id: id2, + }, + }); + + model.updateElement(connectorId, { + source: { + position: [0, 0], + }, + target: { + position: [0, 0], + }, + }); + + expect(model.getConnectors(id)).toEqual([]); + expect(model.getConnectors(id2)).toEqual([]); + }); + + test('should return null if connector are deleted', async () => { + const id = model.addElement({ + type: 'shape', + }); + const id2 = model.addElement({ + type: 'shape', + }); + const connectorId = model.addElement({ + type: 'connector', + source: { + id, + }, + target: { + id: id2, + }, + }); + + model.removeElement(connectorId); + + await wait(); + + expect(model.getConnectors(id)).toEqual([]); + expect(model.getConnectors(id2)).toEqual([]); + }); +}); From 76bede5046e397e331757b4cbb6ae6447c022faf Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Mon, 1 Jan 2024 21:03:15 +0800 Subject: [PATCH 08/25] feat: model implementation --- .../src/surface-block/element-model/base.ts | 70 ++++++-- .../src/surface-block/element-model/brush.ts | 63 +++++++ .../surface-block/element-model/connector.ts | 85 ++++------ .../surface-block/element-model/decorators.ts | 55 +++++++ .../src/surface-block/element-model/group.ts | 49 ++++-- .../src/surface-block/element-model/index.ts | 68 +++----- .../src/surface-block/element-model/shape.ts | 155 ++++++------------ .../src/surface-block/element-model/text.ts | 53 ++++++ .../src/surface-block/service/template.ts | 2 +- .../blocks/src/surface-block/surface-model.ts | 26 +-- .../src/surface-block/surface-transformer.ts | 2 +- 11 files changed, 386 insertions(+), 242 deletions(-) create mode 100644 packages/blocks/src/surface-block/element-model/brush.ts create mode 100644 packages/blocks/src/surface-block/element-model/decorators.ts create mode 100644 packages/blocks/src/surface-block/element-model/text.ts diff --git a/packages/blocks/src/surface-block/element-model/base.ts b/packages/blocks/src/surface-block/element-model/base.ts index b405b8819af5..5967feaba282 100644 --- a/packages/blocks/src/surface-block/element-model/base.ts +++ b/packages/blocks/src/surface-block/element-model/base.ts @@ -1,42 +1,70 @@ import { type Y } from '@blocksuite/store'; -import type { SerializedXYWH } from '../index.js'; +import { + Bound, + deserializeXYWH, + getBoundsWithRotation, + type SerializedXYWH, +} from '../index.js'; import type { SurfaceBlockModel } from '../surface-model.js'; +import { ymap } from './decorators.js'; export type BaseProps = { - xywh: SerializedXYWH; + index: string; }; export abstract class ElementModel { - static default() { - return { - xywh: '[0, 0, 100, 100]', - }; - } - - static propsToYStruct(props: BaseProps) { + static propsToYStruct(props: Record) { return props; } private _stashed: Map; + protected _onchange?: (props: Record) => void; yMap!: Y.Map; surfaceModel!: SurfaceBlockModel; - constructor( - yMap: Y.Map, - model: SurfaceBlockModel, - stashedStore: Map - ) { + abstract rotate: number; + + abstract xywh: SerializedXYWH; + + abstract get type(): string; + + @ymap() + index: string = 'a0'; + + constructor(options: { + yMap: Y.Map; + model: SurfaceBlockModel; + stashedStore: Map; + onchange: (props: Record) => void; + }) { + const { yMap, model, stashedStore, onchange } = options; + this.yMap = yMap; this.surfaceModel = model; this._stashed = stashedStore as Map; + this._onchange = onchange; } - abstract get type(): string; + get deserializedXYWH() { + return deserializeXYWH(this.xywh); + } - get xywh() { - return this.yMap.get('xywh') as SerializedXYWH; + get x() { + return this.deserializedXYWH[0]; + } + + get y() { + return this.deserializedXYWH[1]; + } + + get w() { + return this.deserializedXYWH[2]; + } + + get h() { + return this.deserializedXYWH[3]; } get group() { @@ -47,6 +75,14 @@ export abstract class ElementModel { return this.yMap.get('id') as string; } + get elementBound() { + if (this.rotate) { + return Bound.from(getBoundsWithRotation(this)); + } + + return Bound.deserialize(this.xywh); + } + stash(prop: keyof Props) { if (this._stashed.has(prop)) { return; diff --git a/packages/blocks/src/surface-block/element-model/brush.ts b/packages/blocks/src/surface-block/element-model/brush.ts new file mode 100644 index 000000000000..b4aff895cd17 --- /dev/null +++ b/packages/blocks/src/surface-block/element-model/brush.ts @@ -0,0 +1,63 @@ +import { + Bound, + getBoundFromPoints, + inflateBound, + type SerializedXYWH, + transformPointsToNewBound, +} from '../index.js'; +import { type BaseProps, ElementModel } from './base.js'; +import { derive, ymap } from './decorators.js'; + +export type BrushProps = BaseProps & { + /** + * [[x0,y0],[x1,y1]...] + */ + points: number[][]; + color: string; + lineWidth: number; +}; + +export class BrushElementModel extends ElementModel { + @derive((instance: BrushElementModel) => { + const lineWidth = instance.lineWidth; + const bound = getBoundFromPoints(instance.points); + const boundWidthLineWidth = inflateBound(bound, lineWidth); + + return { + xywh: boundWidthLineWidth.serialize(), + }; + }) + @ymap() + points: number[][] = []; + + @derive((instance: BrushElementModel) => { + const bound = Bound.deserialize(instance.xywh); + const { lineWidth } = instance; + const transformed = transformPointsToNewBound( + instance.points.map(([x, y]) => ({ x, y })), + instance, + lineWidth / 2, + bound, + lineWidth / 2 + ); + + return { + points: transformed.points.map(p => [p.x, p.y]), + }; + }) + @ymap() + xywh: SerializedXYWH = '[0,0,0,0]'; + + @ymap() + rotate: number = 0; + + @ymap() + color: string = '#000000'; + + @ymap() + lineWidth: number = 4; + + override get type() { + return 'brush'; + } +} diff --git a/packages/blocks/src/surface-block/element-model/connector.ts b/packages/blocks/src/surface-block/element-model/connector.ts index 62ea07a0fb47..bc3ead71cd9c 100644 --- a/packages/blocks/src/surface-block/element-model/connector.ts +++ b/packages/blocks/src/surface-block/element-model/connector.ts @@ -1,6 +1,8 @@ import { DEFAULT_ROUGHNESS } from '../consts.js'; +import type { SerializedXYWH } from '../index.js'; import { type BaseProps, ElementModel } from './base.js'; import type { StrokeStyle } from './common.js'; +import { local, ymap } from './decorators.js'; export type PointStyle = 'None' | 'Arrow' | 'Triangle' | 'Circle' | 'Diamond'; @@ -30,70 +32,47 @@ type ConnectorElementProps = BaseProps & { }; export class ConnectorElementModel extends ElementModel { - static override default() { - return { - mode: ConnectorMode.Orthogonal, - strokeWidth: 4, - stroke: '#000000', - strokeStyle: 'solid', - roughness: DEFAULT_ROUGHNESS, - source: {}, - target: {}, - } as ConnectorElementProps; - } - get type() { return 'connector'; } - get mode() { - return this.yMap.get('mode') as ConnectorElementProps['mode']; - } + @local() + xywh: SerializedXYWH = '[0,0,0,0]'; - get strokeWidth() { - return this.yMap.get('strokeWidth') as ConnectorElementProps['strokeWidth']; - } + @local() + rotate: number = 0; - get stroke() { - return this.yMap.get('stroke') as ConnectorElementProps['stroke']; - } + @ymap() + mode: ConnectorMode = ConnectorMode.Orthogonal; - get strokeStyle() { - return this.yMap.get('strokeStyle') as ConnectorElementProps['strokeStyle']; - } + @ymap() + strokeWidth: number = 4; - get roughness() { - return ( - (this.yMap.get('roughness') as ConnectorElementProps['roughness']) ?? - DEFAULT_ROUGHNESS - ); - } + @ymap() + stroke: string = '#000000'; - get rough() { - return (this.yMap.get('rough') as ConnectorElementProps['rough']) ?? false; - } + @ymap() + strokeStyle: StrokeStyle = 'solid'; - get target() { - return this.yMap.get('target') as ConnectorElementProps['target']; - } + @ymap() + roughness: number = DEFAULT_ROUGHNESS; - get source() { - return this.yMap.get('source') as ConnectorElementProps['source']; - } + @ymap() + rough?: boolean; - get frontEndpointStyle() { - return ( - (this.yMap.get( - 'frontEndpointStyle' - ) as ConnectorElementProps['frontEndpointStyle']) ?? 'None' - ); - } + @ymap() + source: Connection = { + position: [0, 0], + }; - get rearEndpointStyle() { - return ( - (this.yMap.get( - 'rearEndpointStyle' - ) as ConnectorElementProps['rearEndpointStyle']) ?? 'Arrow' - ); - } + @ymap() + target: Connection = { + position: [0, 0], + }; + + @ymap() + frontEndpointStyle?: PointStyle; + + @ymap() + rearEndpointStyle?: PointStyle; } diff --git a/packages/blocks/src/surface-block/element-model/decorators.ts b/packages/blocks/src/surface-block/element-model/decorators.ts new file mode 100644 index 000000000000..f1652d1a9d75 --- /dev/null +++ b/packages/blocks/src/surface-block/element-model/decorators.ts @@ -0,0 +1,55 @@ +import type { ElementModel } from './base.js'; + +export function ymap(): PropertyDecorator { + return function yDecorator(target: unknown, prop: string | symbol) { + const yMap = (target as ElementModel).yMap; + + Object.defineProperty(target, prop, { + get() { + return yMap.get(prop as string); + }, + set(val) { + yMap.set(prop as string, val); + }, + }); + }; +} + +export function local(): PropertyDecorator { + return function localDecorator(target: unknown, prop: string | symbol) { + // @ts-ignore + let value; + + Object.defineProperty(target, prop, { + get() { + // @ts-ignore + return value; + }, + set(newVal) { + value = newVal; + }, + }); + }; +} + +const deriveSymbol = Symbol('derive'); + +function setDerivedMeta( + target: unknown, + prop: string | symbol, + fn: (instance: unknown) => Record +) { + // @ts-ignore + target[deriveSymbol] = target[deriveSymbol] ?? {}; + // @ts-ignore + target[deriveSymbol][prop] = fn; +} + +export function derive( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fn: (instance: any) => Record +): PropertyDecorator { + return function deriveDecorator(target: unknown, prop: string | symbol) { + setDerivedMeta(target, prop as string, fn); + }; +} diff --git a/packages/blocks/src/surface-block/element-model/group.ts b/packages/blocks/src/surface-block/element-model/group.ts index 0f2ed4fe566c..7c8d46fe65d2 100644 --- a/packages/blocks/src/surface-block/element-model/group.ts +++ b/packages/blocks/src/surface-block/element-model/group.ts @@ -2,8 +2,10 @@ import type { Y } from '@blocksuite/store'; import { Workspace } from '@blocksuite/store'; import { keys } from '../../_common/utils/iterable.js'; +import { Bound, type SerializedXYWH } from '../index.js'; import type { BaseProps } from './base.js'; import { ElementModel } from './base.js'; +import { ymap } from './decorators.js'; type GroupElementProps = BaseProps & { children: Y.Map; @@ -11,13 +13,6 @@ type GroupElementProps = BaseProps & { }; export class GroupElementModel extends ElementModel { - static override default() { - return { - children: new Workspace.Y.Map(), - title: new Workspace.Y.Text(), - } as GroupElementProps; - } - static override propsToYStruct(props: GroupElementProps) { if (props.title && !(props.title instanceof Workspace.Y.Text)) { props.title = new Workspace.Y.Text(props.title); @@ -36,6 +31,42 @@ export class GroupElementModel extends ElementModel { return props; } + @ymap() + children: Y.Map = new Workspace.Y.Map(); + + @ymap() + title: Y.Text = new Workspace.Y.Text(); + + get xywh() { + const childrenIds = this.childrenIds; + + if (childrenIds.length === 0) return '[0,0,0,0]'; + + const bound: Bound = childrenIds + .map( + id => + this.surfaceModel.getElementById(id) ?? + this.surfaceModel.page.getBlockById(id) + ) + .filter(el => el) + .reduce( + (prev, ele) => { + return prev.unite((ele as ElementModel).elementBound); + }, + new Bound(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, 0, 0) + ); + + return bound.serialize(); + } + + set xywh(_: SerializedXYWH) {} + + get rotate() { + return 0; + } + + set rotate(_: number) {} + get type() { return 'group'; } @@ -44,10 +75,6 @@ export class GroupElementModel extends ElementModel { return [...this.children.keys()]; } - get children() { - return this.yMap.get('children') as GroupElementProps['children']; - } - get childrenElements() { const elements = []; const keys = this.children.keys(); diff --git a/packages/blocks/src/surface-block/element-model/index.ts b/packages/blocks/src/surface-block/element-model/index.ts index 994490870ef3..83d74123e1c4 100644 --- a/packages/blocks/src/surface-block/element-model/index.ts +++ b/packages/blocks/src/surface-block/element-model/index.ts @@ -2,6 +2,7 @@ import { Workspace, type Y } from '@blocksuite/store'; import type { SurfaceBlockModel } from '../surface-model.js'; import { ElementModel } from './base.js'; +import { BrushElementModel } from './brush.js'; import { ConnectorElementModel } from './connector.js'; import { GroupElementModel } from './group.js'; import { ShapeElementModel } from './shape.js'; @@ -10,6 +11,7 @@ const elementsCtorMap = { group: GroupElementModel, connector: ConnectorElementModel, shape: ShapeElementModel, + brush: BrushElementModel, }; export function createElementModel( @@ -34,42 +36,12 @@ export function createElementModel( throw new Error(`Invalid element type: ${yMap.get('type')}`); } - const elementModel = new Ctor(yMap, model, stashed); - const proxy = new Proxy(elementModel as ElementModel, { - has(target, prop) { - return Reflect.has(target, prop); - }, - - get(target, prop) { - if (stashed.has(prop)) { - return stashed.get(prop); - } - - return Reflect.get(target, prop); - }, - - set(target, prop, value) { - if (stashed.has(prop)) { - stashed.set(prop, value); - options.onChange({ - id: elementModel.id, - props: { - [prop]: value, - }, - }); - - return true; - } - - target.yMap.set(prop as string, value); - - return true; - }, - - getPrototypeOf() { - return ElementModel.prototype; - }, - }); + const elementModel = new Ctor({ + yMap, + model, + stashedStore: stashed, + onchange: () => options.onChange({ id: elementModel.id, props: {} }), + }) as ElementModel; const dispose = onElementChange(yMap, props => { options.onChange({ id: elementModel.id, @@ -78,7 +50,7 @@ export function createElementModel( }); return { - model: proxy, + model: elementModel, dispose, }; } @@ -121,13 +93,20 @@ export function propsToYStruct(type: string, props: Record) { throw new Error(`Invalid element type: ${type}`); } - return (ctor.propsToYStruct ?? ElementModel.propsToYStruct)( - // @ts-ignore - Object.assign(ctor.default(), props) - ); + // @ts-ignore + return (ctor.propsToYStruct ?? ElementModel.propsToYStruct)(props); } -export function createYMapFromProps(props: Record) { +export function createModelFromProps( + props: Record, + model: SurfaceBlockModel, + options: { + onChange: (payload: { + id: string; + props: Record; + }) => void; + } +) { const type = props.type as string; const ctor = elementsCtorMap[type as keyof typeof elementsCtorMap]; @@ -136,12 +115,13 @@ export function createYMapFromProps(props: Record) { } const yMap = new Workspace.Y.Map(); + const elementModel = createElementModel(yMap, model, options); - props = propsToYStruct(type, Object.assign(ctor.default(), props)); + props = propsToYStruct(type, props); Object.keys(props).forEach(key => { yMap.set(key, props[key]); }); - return yMap; + return elementModel; } diff --git a/packages/blocks/src/surface-block/element-model/shape.ts b/packages/blocks/src/surface-block/element-model/shape.ts index 5f88abc1ecfd..37cd7b1f4b55 100644 --- a/packages/blocks/src/surface-block/element-model/shape.ts +++ b/packages/blocks/src/surface-block/element-model/shape.ts @@ -1,9 +1,12 @@ import { Workspace, type Y } from '@blocksuite/store'; import { DEFAULT_ROUGHNESS } from '../consts.js'; +import type { SerializedXYWH } from '../index.js'; import { type BaseProps, ElementModel } from './base.js'; -import { type FontStyle, FontWeight } from './common.js'; -import { FontFamily, type StrokeStyle } from './common.js'; +import type { FontWeight } from './common.js'; +import { type FontStyle } from './common.js'; +import { type StrokeStyle } from './common.js'; +import { ymap } from './decorators.js'; export type ShapeType = 'rect' | 'triangle' | 'ellipse' | 'diamond'; export type ShapeStyle = 'General' | 'Scribbled'; @@ -39,22 +42,6 @@ export type ShapeProps = BaseProps & { }; export class ShapeElementModel extends ElementModel { - static override default() { - return { - xywh: '[0,0,10,10]', - rotate: 0, - shapeType: 'rect', - shapeStyle: 'General', - radius: 0, - filled: false, - fillColor: '#ffffff', - strokeWidth: 4, - strokeColor: '#000000', - strokeStyle: 'solid', - roughness: DEFAULT_ROUGHNESS, - } as ShapeProps; - } - static override propsToYStruct(props: ShapeProps) { if (props.text && !(props.text instanceof Workspace.Y.Text)) { props.text = new Workspace.Y.Text(props.text); @@ -63,109 +50,67 @@ export class ShapeElementModel extends ElementModel { return props; } - override get type() { - return 'shape'; - } + @ymap() + xywh: SerializedXYWH = '[0,0,0,0]'; - get shapeType() { - const shapeType = this.yMap.get('shapeType') as ShapeProps['shapeType']; - return shapeType; - } + @ymap() + rotate: number = 0; - get radius() { - const radius = this.yMap.get('radius') as ShapeProps['radius']; - return radius; - } + @ymap() + shapeType: ShapeType = 'rect'; - get filled() { - const filled = this.yMap.get('filled') as ShapeProps['filled']; - return filled; - } + @ymap() + radius: number = 0; - get fillColor() { - const fillColor = this.yMap.get('fillColor') as ShapeProps['fillColor']; - return fillColor; - } + @ymap() + filled: boolean = false; - get strokeWidth() { - const strokeWidth = this.yMap.get( - 'strokeWidth' - ) as ShapeProps['strokeWidth']; - return strokeWidth; - } + @ymap() + fillColor: string = '#ffffff'; - get strokeColor() { - const strokeColor = this.yMap.get( - 'strokeColor' - ) as ShapeProps['strokeColor']; - return strokeColor; - } + @ymap() + strokeWidth: number = 4; - get strokeStyle() { - const strokeStyle = this.yMap.get( - 'strokeStyle' - ) as ShapeProps['strokeStyle']; - return strokeStyle; - } + @ymap() + strokeColor: string = '#000000'; - get shapeStyle() { - const shapeStyle = this.yMap.get('shapeStyle') as ShapeProps['shapeStyle']; - return shapeStyle; - } + @ymap() + strokeStyle: StrokeStyle = 'solid'; - get text() { - const text = this.yMap.get('text') as ShapeProps['text']; - return text; - } + @ymap() + shapeStyle: ShapeStyle = 'General'; - get color() { - const color = (this.yMap.get('color') as ShapeProps['color']) ?? '#000000'; - return color; - } + @ymap() + roughness: number = DEFAULT_ROUGHNESS; - get fontSize() { - const fontSize = - (this.yMap.get('fontSize') as ShapeProps['fontSize']) ?? - ShapeTextFontSize.MEDIUM; - return fontSize; - } + @ymap() + text?: Y.Text; - get fontFamily() { - const fontFamily = - (this.yMap.get('fontFamily') as ShapeProps['fontFamily']) ?? - FontFamily.Inter; - return fontFamily; - } + @ymap() + color?: string; - get fontWeight() { - return ( - (this.yMap.get('fontWeight') as ShapeProps['fontWeight']) ?? - FontWeight.Regular - ); - } + @ymap() + fontSize?: number; - get fontStyle() { - return (this.yMap.get('fontStyle') as ShapeProps['fontStyle']) ?? 'normal'; - } + @ymap() + fontFamily?: string; - get textAlign() { - const textAlign = - (this.yMap.get('textAlign') as ShapeProps['textAlign']) ?? 'center'; - return textAlign; - } + @ymap() + fontWeight?: FontWeight; - get textHorizontalAlign() { - const textHorizontalAlign = - (this.yMap.get( - 'textHorizontalAlign' - ) as ShapeProps['textHorizontalAlign']) ?? 'center'; - return textHorizontalAlign; - } + @ymap() + fontStyle?: FontStyle; - get textVerticalAlign() { - const textVerticalAlign = - (this.yMap.get('textVerticalAlign') as ShapeProps['textVerticalAlign']) ?? - 'center'; - return textVerticalAlign; + @ymap() + textAlign?: 'left' | 'center' | 'right'; + + @ymap() + textHorizontalAlign?: 'left' | 'center' | 'right'; + + @ymap() + textVerticalAlign?: 'top' | 'center' | 'bottom'; + + override get type() { + return 'shape'; } } diff --git a/packages/blocks/src/surface-block/element-model/text.ts b/packages/blocks/src/surface-block/element-model/text.ts new file mode 100644 index 000000000000..6c63e7f8987f --- /dev/null +++ b/packages/blocks/src/surface-block/element-model/text.ts @@ -0,0 +1,53 @@ +import type { Y } from '@blocksuite/store'; + +import type { SerializedXYWH } from '../index.js'; +import { type BaseProps, ElementModel } from './base.js'; +import type { FontFamily, FontStyle, FontWeight } from './common.js'; +import { ymap } from './decorators.js'; + +export type TextElementProps = BaseProps & { + text: Y.Text; + color: string; + fontSize: number; + fontFamily: FontFamily; + fontWeight?: FontWeight; + fontStyle?: FontStyle; + textAlign: 'left' | 'center' | 'right'; + hasMaxWidth?: boolean; +}; + +export class TextElementModel extends ElementModel { + @ymap() + xywh: SerializedXYWH = '[0,0,0,0]'; + + @ymap() + rotate: number = 0; + + @ymap() + text!: Y.Text; + + @ymap() + color!: string; + + @ymap() + fontSize!: number; + + @ymap() + fontFamily!: FontFamily; + + @ymap() + fontWeight?: FontWeight; + + @ymap() + fontStyle?: FontStyle; + + @ymap() + textAlign!: 'left' | 'center' | 'right'; + + @ymap() + hasMaxWidth?: boolean; + + get type() { + return 'text'; + } +} diff --git a/packages/blocks/src/surface-block/service/template.ts b/packages/blocks/src/surface-block/service/template.ts index 5465990ecc0e..33dc7129341c 100644 --- a/packages/blocks/src/surface-block/service/template.ts +++ b/packages/blocks/src/surface-block/service/template.ts @@ -96,7 +96,7 @@ export class TemplateJob { private _mergeSurfaceElements( from: Record>, - to: Y.Map + to: Y.Map> ) { const schema = this.model.page.workspace.schema.flavourSchemaMap.get('affine:surface'); diff --git a/packages/blocks/src/surface-block/surface-model.ts b/packages/blocks/src/surface-block/surface-model.ts index 82f751e3d3bc..13e1d3521163 100644 --- a/packages/blocks/src/surface-block/surface-model.ts +++ b/packages/blocks/src/surface-block/surface-model.ts @@ -16,7 +16,7 @@ import type { import type { GroupElementModel } from './element-model/group.js'; import { createElementModel, - createYMapFromProps, + createModelFromProps, propsToYStruct, } from './element-model/index.js'; import { generateElementId } from './index.js'; @@ -198,13 +198,15 @@ export class SurfaceBlockModel extends BaseBlockModel { switch (change?.action) { case 'add': - if (!this._elementModels.has(id) && element) { - this._elementModels.set( - id, - createElementModel(element, this, { - onChange: payload => this.elementUpdated.emit(payload), - }) - ); + if (element) { + if (!this._elementModels.has(id)) { + this._elementModels.set( + id, + createElementModel(element, this, { + onChange: payload => this.elementUpdated.emit(payload), + }) + ); + } this.elementAdded.emit({ id }); } break; @@ -431,10 +433,14 @@ export class SurfaceBlockModel extends BaseBlockModel { props.id = id; - const yMap = createYMapFromProps(props); + const elementModel = createModelFromProps(props, this, { + onChange: payload => this.elementUpdated.emit(payload), + }); + + this._elementModels.set(id, elementModel); this.page.transact(() => { - this.elements.getValue()!.set(id, yMap); + this.elements.getValue()!.set(id, elementModel.model.yMap); }); return id; diff --git a/packages/blocks/src/surface-block/surface-transformer.ts b/packages/blocks/src/surface-block/surface-transformer.ts index 7aaf1cfd2f24..f6e8988a1c94 100644 --- a/packages/blocks/src/surface-block/surface-transformer.ts +++ b/packages/blocks/src/surface-block/surface-transformer.ts @@ -88,7 +88,7 @@ export class SurfaceBlockTransformer extends BaseBlockTransformer; - const yMap = new Workspace.Y.Map(); + const yMap = new Workspace.Y.Map>(); Object.entries(elementsJSON).forEach(([key, value]) => { const element = this.elementFromJSON(value as Record); From fcf794dfc2effc5907a5915b2a1525abfe5b6359 Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Tue, 2 Jan 2024 15:28:07 +0800 Subject: [PATCH 09/25] fix: test --- .../block-portal/note/edgeless-note.ts | 4 +-- .../src/surface-block/element-model/base.ts | 12 ++++++- .../surface-block/element-model/decorators.ts | 29 +++++++++++++---- .../src/surface-block/element-model/index.ts | 15 +++------ .../src/surface-block/element-model/shape.ts | 8 ++--- .../blocks/src/surface-block/surface-model.ts | 18 ++++++++--- .../src/__tests__/edgeless/layer.spec.ts | 5 ++- .../__tests__/edgeless/surface-model.spec.ts | 32 ++++++++++++++++++- 8 files changed, 94 insertions(+), 29 deletions(-) diff --git a/packages/blocks/src/page-block/edgeless/components/block-portal/note/edgeless-note.ts b/packages/blocks/src/page-block/edgeless/components/block-portal/note/edgeless-note.ts index 9d34777a8057..f880a60b5c83 100644 --- a/packages/blocks/src/page-block/edgeless/components/block-portal/note/edgeless-note.ts +++ b/packages/blocks/src/page-block/edgeless/components/block-portal/note/edgeless-note.ts @@ -71,8 +71,8 @@ export class EdgelessNoteMask extends WithDisposable(ShadowlessElement) { override render() { const selected = - this.edgeless.selectionManager.has(this.model.id) && - this.edgeless.selectionManager.selections.some( + this.edgeless?.selectionManager.has(this.model.id) && + this.edgeless?.selectionManager.selections.some( sel => sel.elements.includes(this.model.id) && sel.editing ); diff --git a/packages/blocks/src/surface-block/element-model/base.ts b/packages/blocks/src/surface-block/element-model/base.ts index 5967feaba282..d2e57ea0591b 100644 --- a/packages/blocks/src/surface-block/element-model/base.ts +++ b/packages/blocks/src/surface-block/element-model/base.ts @@ -18,10 +18,14 @@ export abstract class ElementModel { return props; } + private _deferedInit!: { + key: string; + value: unknown; + }[]; private _stashed: Map; protected _onchange?: (props: Record) => void; - yMap!: Y.Map; + yMap: Y.Map; surfaceModel!: SurfaceBlockModel; abstract rotate: number; @@ -45,6 +49,12 @@ export abstract class ElementModel { this.surfaceModel = model; this._stashed = stashedStore as Map; this._onchange = onchange; + + this._deferedInit?.forEach(({ key, value }) => { + // @ts-ignore + this.yMap.set(key, value); + }); + this._deferedInit = []; } get deserializedXYWH() { diff --git a/packages/blocks/src/surface-block/element-model/decorators.ts b/packages/blocks/src/surface-block/element-model/decorators.ts index f1652d1a9d75..1149c760704a 100644 --- a/packages/blocks/src/surface-block/element-model/decorators.ts +++ b/packages/blocks/src/surface-block/element-model/decorators.ts @@ -1,15 +1,32 @@ import type { ElementModel } from './base.js'; +const state = { + skip: false, +}; + +export function skipAssign(value: boolean): void { + state.skip = value; +} + export function ymap(): PropertyDecorator { return function yDecorator(target: unknown, prop: string | symbol) { - const yMap = (target as ElementModel).yMap; - Object.defineProperty(target, prop, { - get() { - return yMap.get(prop as string); + get(this: ElementModel) { + return this.yMap.get(prop as string); }, - set(val) { - yMap.set(prop as string, val); + set(this: ElementModel, val) { + if (state.skip) { + return; + } + + if (this.yMap) { + this.yMap.set(prop as string, val); + } else { + // @ts-ignore + this._deferedInit = target._deferedInit ?? []; + // @ts-ignore + this._deferedInit.push({ key: prop as string, value: val }); + } }, }); }; diff --git a/packages/blocks/src/surface-block/element-model/index.ts b/packages/blocks/src/surface-block/element-model/index.ts index 83d74123e1c4..f6f10cd80f73 100644 --- a/packages/blocks/src/surface-block/element-model/index.ts +++ b/packages/blocks/src/surface-block/element-model/index.ts @@ -6,15 +6,18 @@ import { BrushElementModel } from './brush.js'; import { ConnectorElementModel } from './connector.js'; import { GroupElementModel } from './group.js'; import { ShapeElementModel } from './shape.js'; +import { TextElementModel } from './text.js'; const elementsCtorMap = { group: GroupElementModel, connector: ConnectorElementModel, shape: ShapeElementModel, brush: BrushElementModel, + text: TextElementModel, }; export function createElementModel( + type: string, yMap: Y.Map, model: SurfaceBlockModel, options: { @@ -28,9 +31,7 @@ export function createElementModel( dispose: () => void; } { const stashed = new Map(); - const Ctor = - elementsCtorMap[yMap.get('type') as keyof typeof elementsCtorMap] ?? - ElementModel; + const Ctor = elementsCtorMap[type as keyof typeof elementsCtorMap]; if (!Ctor) { throw new Error(`Invalid element type: ${yMap.get('type')}`); @@ -108,14 +109,8 @@ export function createModelFromProps( } ) { const type = props.type as string; - const ctor = elementsCtorMap[type as keyof typeof elementsCtorMap]; - - if (!ctor) { - throw new Error(`Invalid element type: ${type}`); - } - const yMap = new Workspace.Y.Map(); - const elementModel = createElementModel(yMap, model, options); + const elementModel = createElementModel(type, yMap, model, options); props = propsToYStruct(type, props); diff --git a/packages/blocks/src/surface-block/element-model/shape.ts b/packages/blocks/src/surface-block/element-model/shape.ts index 37cd7b1f4b55..fc9c734c98a7 100644 --- a/packages/blocks/src/surface-block/element-model/shape.ts +++ b/packages/blocks/src/surface-block/element-model/shape.ts @@ -51,7 +51,7 @@ export class ShapeElementModel extends ElementModel { } @ymap() - xywh: SerializedXYWH = '[0,0,0,0]'; + xywh: SerializedXYWH = '[0,0,100,100]'; @ymap() rotate: number = 0; @@ -66,13 +66,13 @@ export class ShapeElementModel extends ElementModel { filled: boolean = false; @ymap() - fillColor: string = '#ffffff'; + fillColor: string = '--affine-palette-shape-yellow'; @ymap() strokeWidth: number = 4; @ymap() - strokeColor: string = '#000000'; + strokeColor: string = '--affine-palette-line-yellow'; @ymap() strokeStyle: StrokeStyle = 'solid'; @@ -110,7 +110,7 @@ export class ShapeElementModel extends ElementModel { @ymap() textVerticalAlign?: 'top' | 'center' | 'bottom'; - override get type() { + get type() { return 'shape'; } } diff --git a/packages/blocks/src/surface-block/surface-model.ts b/packages/blocks/src/surface-block/surface-model.ts index 13e1d3521163..ba6c90045ff8 100644 --- a/packages/blocks/src/surface-block/surface-model.ts +++ b/packages/blocks/src/surface-block/surface-model.ts @@ -13,6 +13,7 @@ import type { Connection, ConnectorElementModel, } from './element-model/connector.js'; +import { skipAssign } from './element-model/decorators.js'; import type { GroupElementModel } from './element-model/group.js'; import { createElementModel, @@ -200,12 +201,19 @@ export class SurfaceBlockModel extends BaseBlockModel { case 'add': if (element) { if (!this._elementModels.has(id)) { + skipAssign(true); this._elementModels.set( id, - createElementModel(element, this, { - onChange: payload => this.elementUpdated.emit(payload), - }) + createElementModel( + element.get('type') as string, + element, + this, + { + onChange: payload => this.elementUpdated.emit(payload), + } + ) ); + skipAssign(false); } this.elementAdded.emit({ id }); } @@ -224,14 +232,16 @@ export class SurfaceBlockModel extends BaseBlockModel { }); }; + skipAssign(true); elementsYMap.forEach((val, key) => { this._elementModels.set( key, - createElementModel(val, this, { + createElementModel(val.get('type') as string, val, this, { onChange: payload => this.elementUpdated.emit(payload), }) ); }); + skipAssign(false); elementsYMap.observe(onElementsMapChange); this._disposables.push(() => { diff --git a/packages/presets/src/__tests__/edgeless/layer.spec.ts b/packages/presets/src/__tests__/edgeless/layer.spec.ts index 626e0ffb173a..d900fad797e9 100644 --- a/packages/presets/src/__tests__/edgeless/layer.spec.ts +++ b/packages/presets/src/__tests__/edgeless/layer.spec.ts @@ -15,7 +15,10 @@ import { setupEditor } from '../utils/setup.js'; beforeEach(async () => { const cleanup = await setupEditor('edgeless'); - return cleanup; + return async () => { + await wait(100); + cleanup(); + }; }); test('layer manager inital state', () => { diff --git a/packages/presets/src/__tests__/edgeless/surface-model.spec.ts b/packages/presets/src/__tests__/edgeless/surface-model.spec.ts index 2e6ab21ed588..40649a8cc2eb 100644 --- a/packages/presets/src/__tests__/edgeless/surface-model.spec.ts +++ b/packages/presets/src/__tests__/edgeless/surface-model.spec.ts @@ -1,4 +1,8 @@ -import type { GroupElementModel, SurfaceBlockModel } from '@blocksuite/blocks'; +import type { + GroupElementModel, + ShapeElementModel, + SurfaceBlockModel, +} from '@blocksuite/blocks'; import { beforeEach, describe, expect, test } from 'vitest'; import { wait } from '../utils/common.js'; @@ -59,6 +63,31 @@ describe('elements management', () => { }); }); +describe('element model', () => { + test('default value should work correctly', () => { + const id = model.addElement({ + type: 'shape', + }); + + const element = model.getElementById(id)! as ShapeElementModel; + + expect(element.index).toBe('a0'); + expect(element.strokeColor).toBe('--affine-palette-line-yellow'); + expect(element.strokeWidth).toBe(4); + }); + + test('defined prop should not be overwritten by default value', () => { + const id = model.addElement({ + type: 'shape', + strokeColor: '#fff', + }); + + const element = model.getElementById(id)! as ShapeElementModel; + + expect(element.strokeColor).toBe('#fff'); + }); +}); + describe('group', () => { test('should get group', () => { const id = model.addElement({ @@ -77,6 +106,7 @@ describe('group', () => { }); const group = model.getElementById(groupId); + expect(group).not.toBe(null); expect(model.getGroup(id)).toBe(group); expect(model.getGroup(id2)).toBe(group); }); From b3fbc9e57444db576f12eb18b8a6991bad9c12b5 Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Tue, 2 Jan 2024 15:28:36 +0800 Subject: [PATCH 10/25] feat: local --- .../src/surface-block/element-model/base.ts | 1 + .../surface-block/element-model/decorators.ts | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/blocks/src/surface-block/element-model/base.ts b/packages/blocks/src/surface-block/element-model/base.ts index d2e57ea0591b..76fb9f541a6b 100644 --- a/packages/blocks/src/surface-block/element-model/base.ts +++ b/packages/blocks/src/surface-block/element-model/base.ts @@ -24,6 +24,7 @@ export abstract class ElementModel { }[]; private _stashed: Map; protected _onchange?: (props: Record) => void; + protected _localStore: Map = new Map(); yMap: Y.Map; surfaceModel!: SurfaceBlockModel; diff --git a/packages/blocks/src/surface-block/element-model/decorators.ts b/packages/blocks/src/surface-block/element-model/decorators.ts index 1149c760704a..c4f04f809ad0 100644 --- a/packages/blocks/src/surface-block/element-model/decorators.ts +++ b/packages/blocks/src/surface-block/element-model/decorators.ts @@ -34,16 +34,19 @@ export function ymap(): PropertyDecorator { export function local(): PropertyDecorator { return function localDecorator(target: unknown, prop: string | symbol) { - // @ts-ignore - let value; - Object.defineProperty(target, prop, { - get() { - // @ts-ignore - return value; + get(this: ElementModel) { + return this._localStore.get(prop); }, - set(newVal) { - value = newVal; + set(this: ElementModel, newVal: unknown) { + const oldValue = this._localStore.get(prop); + + this._localStore.set(prop, newVal); + this._onchange?.({ + [prop]: { + oldValue, + }, + }); }, }); }; From 69d427ac0e2ee0455e25e581f09d9201264e082e Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Tue, 2 Jan 2024 15:39:16 +0800 Subject: [PATCH 11/25] fix: skip field assign --- .../blocks/src/surface-block/element-model/index.ts | 10 ++++++++++ packages/blocks/src/surface-block/surface-model.ts | 7 ++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/blocks/src/surface-block/element-model/index.ts b/packages/blocks/src/surface-block/element-model/index.ts index f6f10cd80f73..f9a66666b88e 100644 --- a/packages/blocks/src/surface-block/element-model/index.ts +++ b/packages/blocks/src/surface-block/element-model/index.ts @@ -4,6 +4,7 @@ import type { SurfaceBlockModel } from '../surface-model.js'; import { ElementModel } from './base.js'; import { BrushElementModel } from './brush.js'; import { ConnectorElementModel } from './connector.js'; +import { skipAssign } from './decorators.js'; import { GroupElementModel } from './group.js'; import { ShapeElementModel } from './shape.js'; import { TextElementModel } from './text.js'; @@ -25,6 +26,7 @@ export function createElementModel( id: string; props: Record; }) => void; + skipFieldInit?: boolean; } ): { model: ElementModel; @@ -37,12 +39,20 @@ export function createElementModel( throw new Error(`Invalid element type: ${yMap.get('type')}`); } + if (options.skipFieldInit) { + skipAssign(true); + } + const elementModel = new Ctor({ yMap, model, stashedStore: stashed, onchange: () => options.onChange({ id: elementModel.id, props: {} }), }) as ElementModel; + + if (options.skipFieldInit) { + skipAssign(false); + } const dispose = onElementChange(yMap, props => { options.onChange({ id: elementModel.id, diff --git a/packages/blocks/src/surface-block/surface-model.ts b/packages/blocks/src/surface-block/surface-model.ts index ba6c90045ff8..fa9055b35564 100644 --- a/packages/blocks/src/surface-block/surface-model.ts +++ b/packages/blocks/src/surface-block/surface-model.ts @@ -13,7 +13,6 @@ import type { Connection, ConnectorElementModel, } from './element-model/connector.js'; -import { skipAssign } from './element-model/decorators.js'; import type { GroupElementModel } from './element-model/group.js'; import { createElementModel, @@ -201,7 +200,6 @@ export class SurfaceBlockModel extends BaseBlockModel { case 'add': if (element) { if (!this._elementModels.has(id)) { - skipAssign(true); this._elementModels.set( id, createElementModel( @@ -210,10 +208,10 @@ export class SurfaceBlockModel extends BaseBlockModel { this, { onChange: payload => this.elementUpdated.emit(payload), + skipFieldInit: true, } ) ); - skipAssign(false); } this.elementAdded.emit({ id }); } @@ -232,16 +230,15 @@ export class SurfaceBlockModel extends BaseBlockModel { }); }; - skipAssign(true); elementsYMap.forEach((val, key) => { this._elementModels.set( key, createElementModel(val.get('type') as string, val, this, { onChange: payload => this.elementUpdated.emit(payload), + skipFieldInit: true, }) ); }); - skipAssign(false); elementsYMap.observe(onElementsMapChange); this._disposables.push(() => { From cac4e30460d8a10d31dc26d3f705e8defb56906f Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Tue, 2 Jan 2024 15:47:30 +0800 Subject: [PATCH 12/25] fix: circular deps --- packages/blocks/src/surface-block/element-model/base.ts | 9 +++------ packages/blocks/src/surface-block/element-model/group.ts | 3 ++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/blocks/src/surface-block/element-model/base.ts b/packages/blocks/src/surface-block/element-model/base.ts index 76fb9f541a6b..d88be5a04000 100644 --- a/packages/blocks/src/surface-block/element-model/base.ts +++ b/packages/blocks/src/surface-block/element-model/base.ts @@ -1,12 +1,9 @@ import { type Y } from '@blocksuite/store'; -import { - Bound, - deserializeXYWH, - getBoundsWithRotation, - type SerializedXYWH, -} from '../index.js'; import type { SurfaceBlockModel } from '../surface-model.js'; +import { Bound } from '../utils/bound.js'; +import { getBoundsWithRotation } from '../utils/math-utils.js'; +import { deserializeXYWH, type SerializedXYWH } from '../utils/xywh.js'; import { ymap } from './decorators.js'; export type BaseProps = { diff --git a/packages/blocks/src/surface-block/element-model/group.ts b/packages/blocks/src/surface-block/element-model/group.ts index 7c8d46fe65d2..8f5e8ca2a9cb 100644 --- a/packages/blocks/src/surface-block/element-model/group.ts +++ b/packages/blocks/src/surface-block/element-model/group.ts @@ -2,7 +2,8 @@ import type { Y } from '@blocksuite/store'; import { Workspace } from '@blocksuite/store'; import { keys } from '../../_common/utils/iterable.js'; -import { Bound, type SerializedXYWH } from '../index.js'; +import { Bound } from '../utils/bound.js'; +import { type SerializedXYWH } from '../utils/xywh.js'; import type { BaseProps } from './base.js'; import { ElementModel } from './base.js'; import { ymap } from './decorators.js'; From 3c903fa131153cbf706a416d1122f0fe9d17bfe0 Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Tue, 2 Jan 2024 15:58:05 +0800 Subject: [PATCH 13/25] fix: test --- .../src/surface-block/element-model/index.ts | 13 ++++++++++--- .../blocks/src/surface-block/surface-model.ts | 15 +++++++++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/blocks/src/surface-block/element-model/index.ts b/packages/blocks/src/surface-block/element-model/index.ts index f9a66666b88e..5a9ba8de75c1 100644 --- a/packages/blocks/src/surface-block/element-model/index.ts +++ b/packages/blocks/src/surface-block/element-model/index.ts @@ -19,6 +19,7 @@ const elementsCtorMap = { export function createElementModel( type: string, + id: string, yMap: Y.Map, model: SurfaceBlockModel, options: { @@ -47,7 +48,7 @@ export function createElementModel( yMap, model, stashedStore: stashed, - onchange: () => options.onChange({ id: elementModel.id, props: {} }), + onchange: () => options.onChange({ id, props: {} }), }) as ElementModel; if (options.skipFieldInit) { @@ -55,7 +56,7 @@ export function createElementModel( } const dispose = onElementChange(yMap, props => { options.onChange({ - id: elementModel.id, + id, props, }); }); @@ -119,8 +120,14 @@ export function createModelFromProps( } ) { const type = props.type as string; + const id = props.id as string; + + if (!id) { + throw new Error('Cannot find id in props'); + } + const yMap = new Workspace.Y.Map(); - const elementModel = createElementModel(type, yMap, model, options); + const elementModel = createElementModel(type, id, yMap, model, options); props = propsToYStruct(type, props); diff --git a/packages/blocks/src/surface-block/surface-model.ts b/packages/blocks/src/surface-block/surface-model.ts index fa9055b35564..1969d871df90 100644 --- a/packages/blocks/src/surface-block/surface-model.ts +++ b/packages/blocks/src/surface-block/surface-model.ts @@ -204,6 +204,7 @@ export class SurfaceBlockModel extends BaseBlockModel { id, createElementModel( element.get('type') as string, + element.get('id') as string, element, this, { @@ -233,10 +234,16 @@ export class SurfaceBlockModel extends BaseBlockModel { elementsYMap.forEach((val, key) => { this._elementModels.set( key, - createElementModel(val.get('type') as string, val, this, { - onChange: payload => this.elementUpdated.emit(payload), - skipFieldInit: true, - }) + createElementModel( + val.get('type') as string, + val.get('id') as string, + val, + this, + { + onChange: payload => this.elementUpdated.emit(payload), + skipFieldInit: true, + } + ) ); }); elementsYMap.observe(onElementsMapChange); From 97861c64b91cc270ecf6504c3b0ebd4689974eb9 Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Tue, 2 Jan 2024 16:31:01 +0800 Subject: [PATCH 14/25] fix: stash/pop --- .../src/surface-block/element-model/base.ts | 24 +++++++++++++++++-- .../__tests__/edgeless/surface-model.spec.ts | 21 ++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/blocks/src/surface-block/element-model/base.ts b/packages/blocks/src/surface-block/element-model/base.ts index d88be5a04000..3823e75af34d 100644 --- a/packages/blocks/src/surface-block/element-model/base.ts +++ b/packages/blocks/src/surface-block/element-model/base.ts @@ -20,7 +20,7 @@ export abstract class ElementModel { value: unknown; }[]; private _stashed: Map; - protected _onchange?: (props: Record) => void; + protected _onchange: (props: Record) => void; protected _localStore: Map = new Map(); yMap: Y.Map; @@ -96,7 +96,25 @@ export abstract class ElementModel { return; } - this._stashed.set(prop, this.yMap.get(prop as string)); + const Ctor = Object.getPrototypeOf(this).constructor as typeof ElementModel; + const curVal = this.yMap.get(prop as string); + + this._stashed.set(prop, curVal); + + Object.defineProperty(this, prop, { + configurable: true, + enumerable: true, + get: () => this._stashed.get(prop), + set: (value: unknown) => { + const converted = (Ctor.propsToYStruct ?? ElementModel.propsToYStruct)({ + [prop]: value, + }) as Record; + const oldValue = this._stashed.get(prop); + + this._stashed.set(prop, converted[prop]); + this._onchange({ [prop]: { oldValue } }); + }, + }); } pop(prop: keyof Props) { @@ -106,6 +124,8 @@ export abstract class ElementModel { const value = this._stashed.get(prop); this._stashed.delete(prop); + // @ts-ignore + delete this[prop]; this.yMap.set(prop as string, value); } } diff --git a/packages/presets/src/__tests__/edgeless/surface-model.spec.ts b/packages/presets/src/__tests__/edgeless/surface-model.spec.ts index 40649a8cc2eb..902a70d671bd 100644 --- a/packages/presets/src/__tests__/edgeless/surface-model.spec.ts +++ b/packages/presets/src/__tests__/edgeless/surface-model.spec.ts @@ -273,3 +273,24 @@ describe('connector', () => { expect(model.getConnectors(id2)).toEqual([]); }); }); + +describe('stash/pop', () => { + test('stash and pop should work correctly', () => { + const id = model.addElement({ + type: 'shape', + strokeWidth: 4, + }); + const elementModel = model.getElementById(id)! as ShapeElementModel; + + expect(elementModel.strokeWidth).toBe(4); + + elementModel.stash('strokeWidth'); + elementModel.strokeWidth = 10; + expect(elementModel.strokeWidth).toBe(10); + expect(elementModel.yMap.get('strokeWidth')).toBe(4); + + elementModel.pop('strokeWidth'); + expect(elementModel.strokeWidth).toBe(10); + expect(elementModel.yMap.get('strokeWidth')).toBe(10); + }); +}); From 697db0d9fcc02c907a351853287cd88165d112c2 Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Tue, 2 Jan 2024 16:31:49 +0800 Subject: [PATCH 15/25] chore: test command --- packages/presets/package.json | 3 ++- packages/presets/vitest.config.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/presets/package.json b/packages/presets/package.json index f6ca2eba5ab8..d5f1b4d79ed6 100644 --- a/packages/presets/package.json +++ b/packages/presets/package.json @@ -6,7 +6,8 @@ "repository": "toeverything/blocksuite", "scripts": { "build": "tsc --build --verbose", - "test": "vitest --run" + "test": "vitest --browser.headless --run", + "test:debug": "vitest" }, "keywords": [], "author": "toeverything", diff --git a/packages/presets/vitest.config.ts b/packages/presets/vitest.config.ts index 3bdbf5e87b57..395c331d71a1 100644 --- a/packages/presets/vitest.config.ts +++ b/packages/presets/vitest.config.ts @@ -12,7 +12,7 @@ export default defineConfig(_configEnv => include: ['src/__tests__/**/*.spec.ts'], browser: { enabled: true, - headless: true, + headless: false, name: 'chromium', provider: 'playwright', isolate: false, From 3618d587d712ca0361aed38183632ea9a9ad9ae7 Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Tue, 2 Jan 2024 17:00:51 +0800 Subject: [PATCH 16/25] fix: test --- .../blocks/src/surface-block/element-model/base.ts | 6 +----- .../src/surface-block/element-model/decorators.ts | 10 ++++++++-- .../blocks/src/surface-block/element-model/index.ts | 13 +++++-------- .../src/surface-block/elements/connector/consts.ts | 8 ++++++-- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/blocks/src/surface-block/element-model/base.ts b/packages/blocks/src/surface-block/element-model/base.ts index 3823e75af34d..6290d8836769 100644 --- a/packages/blocks/src/surface-block/element-model/base.ts +++ b/packages/blocks/src/surface-block/element-model/base.ts @@ -96,7 +96,6 @@ export abstract class ElementModel { return; } - const Ctor = Object.getPrototypeOf(this).constructor as typeof ElementModel; const curVal = this.yMap.get(prop as string); this._stashed.set(prop, curVal); @@ -106,12 +105,9 @@ export abstract class ElementModel { enumerable: true, get: () => this._stashed.get(prop), set: (value: unknown) => { - const converted = (Ctor.propsToYStruct ?? ElementModel.propsToYStruct)({ - [prop]: value, - }) as Record; const oldValue = this._stashed.get(prop); - this._stashed.set(prop, converted[prop]); + this._stashed.set(prop, value); this._onchange({ [prop]: { oldValue } }); }, }); diff --git a/packages/blocks/src/surface-block/element-model/decorators.ts b/packages/blocks/src/surface-block/element-model/decorators.ts index c4f04f809ad0..54211ed88bc1 100644 --- a/packages/blocks/src/surface-block/element-model/decorators.ts +++ b/packages/blocks/src/surface-block/element-model/decorators.ts @@ -2,10 +2,15 @@ import type { ElementModel } from './base.js'; const state = { skip: false, + creating: false, }; -export function skipAssign(value: boolean): void { - state.skip = value; +export function setCreateState( + creating: boolean, + skipFieldInit: boolean +): void { + state.skip = skipFieldInit; + state.creating = creating; } export function ymap(): PropertyDecorator { @@ -42,6 +47,7 @@ export function local(): PropertyDecorator { const oldValue = this._localStore.get(prop); this._localStore.set(prop, newVal); + if (state.creating) return; this._onchange?.({ [prop]: { oldValue, diff --git a/packages/blocks/src/surface-block/element-model/index.ts b/packages/blocks/src/surface-block/element-model/index.ts index 5a9ba8de75c1..b3dbd80631e8 100644 --- a/packages/blocks/src/surface-block/element-model/index.ts +++ b/packages/blocks/src/surface-block/element-model/index.ts @@ -4,7 +4,7 @@ import type { SurfaceBlockModel } from '../surface-model.js'; import { ElementModel } from './base.js'; import { BrushElementModel } from './brush.js'; import { ConnectorElementModel } from './connector.js'; -import { skipAssign } from './decorators.js'; +import { setCreateState } from './decorators.js'; import { GroupElementModel } from './group.js'; import { ShapeElementModel } from './shape.js'; import { TextElementModel } from './text.js'; @@ -40,20 +40,17 @@ export function createElementModel( throw new Error(`Invalid element type: ${yMap.get('type')}`); } - if (options.skipFieldInit) { - skipAssign(true); - } + setCreateState(true, options.skipFieldInit ?? false); const elementModel = new Ctor({ yMap, model, stashedStore: stashed, - onchange: () => options.onChange({ id, props: {} }), + onchange: props => options.onChange({ id, props }), }) as ElementModel; - if (options.skipFieldInit) { - skipAssign(false); - } + setCreateState(false, false); + const dispose = onElementChange(yMap, props => { options.onChange({ id, diff --git a/packages/blocks/src/surface-block/elements/connector/consts.ts b/packages/blocks/src/surface-block/elements/connector/consts.ts index a5723c30b96d..dd44e6aeb27c 100644 --- a/packages/blocks/src/surface-block/elements/connector/consts.ts +++ b/packages/blocks/src/surface-block/elements/connector/consts.ts @@ -12,6 +12,10 @@ export const ConnectorElementDefaultProps: IElementDefaultProps<'connector'> = { stroke: '#000000', strokeStyle: StrokeStyle.Solid, roughness: DEFAULT_ROUGHNESS, - source: {}, - target: {}, + source: { + position: [0, 0], + }, + target: { + position: [0, 0], + }, }; From d74aca61eafa6db6313058bd4ce1d1f8f8fac5f5 Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Tue, 2 Jan 2024 21:33:33 +0800 Subject: [PATCH 17/25] feat: derive decorator --- packages/blocks/src/index.ts | 1 + .../src/surface-block/element-model/base.ts | 34 +++++--- .../surface-block/element-model/decorators.ts | 85 +++++++++++++------ .../src/surface-block/element-model/index.ts | 24 ++++-- packages/blocks/src/surface-block/index.ts | 1 + .../__tests__/edgeless/surface-model.spec.ts | 68 ++++++++++++++- 6 files changed, 168 insertions(+), 45 deletions(-) diff --git a/packages/blocks/src/index.ts b/packages/blocks/src/index.ts index e0d437d9831d..c9c197453f16 100644 --- a/packages/blocks/src/index.ts +++ b/packages/blocks/src/index.ts @@ -77,6 +77,7 @@ export * from './paragraph-block/index.js'; export { Bound, type BrushElement, + BrushElementModel, CanvasElementType, type ConnectorElement, ConnectorElementModel, diff --git a/packages/blocks/src/surface-block/element-model/base.ts b/packages/blocks/src/surface-block/element-model/base.ts index 6290d8836769..81bd59a7d99b 100644 --- a/packages/blocks/src/surface-block/element-model/base.ts +++ b/packages/blocks/src/surface-block/element-model/base.ts @@ -4,7 +4,7 @@ import type { SurfaceBlockModel } from '../surface-model.js'; import { Bound } from '../utils/bound.js'; import { getBoundsWithRotation } from '../utils/math-utils.js'; import { deserializeXYWH, type SerializedXYWH } from '../utils/xywh.js'; -import { ymap } from './decorators.js'; +import { local, updateDerivedProp, ymap } from './decorators.js'; export type BaseProps = { index: string; @@ -15,13 +15,14 @@ export abstract class ElementModel { return props; } - private _deferedInit!: { - key: string; - value: unknown; - }[]; - private _stashed: Map; + /** + * When the ymap is not connected to the doc, the value cannot be accessed. + * But sometimes we need to access the value when creating the element model, those temporary values are stored here. + */ + protected _preserved: Map = new Map(); + protected _stashed: Map; + protected _local: Map = new Map(); protected _onchange: (props: Record) => void; - protected _localStore: Map = new Map(); yMap: Y.Map; surfaceModel!: SurfaceBlockModel; @@ -33,7 +34,13 @@ export abstract class ElementModel { abstract get type(): string; @ymap() - index: string = 'a0'; + index!: string; + + @local() + display: boolean = true; + + @local() + opacity: number = 1; constructor(options: { yMap: Y.Map; @@ -48,11 +55,9 @@ export abstract class ElementModel { this._stashed = stashedStore as Map; this._onchange = onchange; - this._deferedInit?.forEach(({ key, value }) => { - // @ts-ignore - this.yMap.set(key, value); - }); - this._deferedInit = []; + // base class property field is assigned before yMap is set + // so we need to manually assign the default value here + this.index = 'a0'; } get deserializedXYWH() { @@ -97,6 +102,7 @@ export abstract class ElementModel { } const curVal = this.yMap.get(prop as string); + const prototype = Object.getPrototypeOf(this); this._stashed.set(prop, curVal); @@ -109,6 +115,8 @@ export abstract class ElementModel { this._stashed.set(prop, value); this._onchange({ [prop]: { oldValue } }); + + updateDerivedProp(prototype, prop as string, this); }, }); } diff --git a/packages/blocks/src/surface-block/element-model/decorators.ts b/packages/blocks/src/surface-block/element-model/decorators.ts index 54211ed88bc1..7146a52e5b55 100644 --- a/packages/blocks/src/surface-block/element-model/decorators.ts +++ b/packages/blocks/src/surface-block/element-model/decorators.ts @@ -1,8 +1,10 @@ +import { keys } from '../../_common/utils/iterable.js'; import type { ElementModel } from './base.js'; const state = { skip: false, creating: false, + derive: false, }; export function setCreateState( @@ -13,42 +15,47 @@ export function setCreateState( state.creating = creating; } +function yDecorator(prototype: unknown, prop: string | symbol) { + Object.defineProperty(prototype, prop, { + get(this: ElementModel) { + return ( + this.yMap.get(prop as string) ?? this._preserved.get(prop as string) + ); + }, + set(this: ElementModel, val) { + if (state.skip) { + return; + } + + if (this.yMap) { + this.yMap.set(prop as string, val); + } + + if (!this.yMap.doc) { + this._preserved.set(prop as string, val); + } + + updateDerivedProp(prototype, prop as string, this); + }, + }); +} + export function ymap(): PropertyDecorator { - return function yDecorator(target: unknown, prop: string | symbol) { - Object.defineProperty(target, prop, { - get(this: ElementModel) { - return this.yMap.get(prop as string); - }, - set(this: ElementModel, val) { - if (state.skip) { - return; - } - - if (this.yMap) { - this.yMap.set(prop as string, val); - } else { - // @ts-ignore - this._deferedInit = target._deferedInit ?? []; - // @ts-ignore - this._deferedInit.push({ key: prop as string, value: val }); - } - }, - }); - }; + return yDecorator; } export function local(): PropertyDecorator { return function localDecorator(target: unknown, prop: string | symbol) { Object.defineProperty(target, prop, { get(this: ElementModel) { - return this._localStore.get(prop); + return this._local.get(prop); }, set(this: ElementModel, newVal: unknown) { - const oldValue = this._localStore.get(prop); + const oldValue = this._local.get(prop); - this._localStore.set(prop, newVal); + this._local.set(prop, newVal); if (state.creating) return; - this._onchange?.({ + this._onchange({ [prop]: { oldValue, }, @@ -71,6 +78,34 @@ function setDerivedMeta( target[deriveSymbol][prop] = fn; } +export function updateDerivedProp( + target: unknown, + prop: string | symbol, + receiver: unknown +) { + if (state.derive || state.creating) return; + + const deriveFn = getDerivedMeta(target, prop as string)!; + + if (deriveFn) { + state.derive = true; + const derived = deriveFn(receiver); + keys(derived).forEach(key => { + // @ts-ignore + receiver[key] = derived[key]; + }); + state.derive = false; + } +} + +export function getDerivedMeta( + target: unknown, + prop: string | symbol +): null | ((instance: unknown) => Record) { + // @ts-ignore + return target[deriveSymbol]?.[prop] ?? null; +} + export function derive( // eslint-disable-next-line @typescript-eslint/no-explicit-any fn: (instance: any) => Record diff --git a/packages/blocks/src/surface-block/element-model/index.ts b/packages/blocks/src/surface-block/element-model/index.ts index b3dbd80631e8..f57ccafd3546 100644 --- a/packages/blocks/src/surface-block/element-model/index.ts +++ b/packages/blocks/src/surface-block/element-model/index.ts @@ -116,21 +116,33 @@ export function createModelFromProps( }) => void; } ) { - const type = props.type as string; - const id = props.id as string; + const { type, id, ...rest } = props; if (!id) { throw new Error('Cannot find id in props'); } const yMap = new Workspace.Y.Map(); - const elementModel = createElementModel(type, id, yMap, model, options); + const elementModel = createElementModel( + type as string, + id as string, + yMap, + model, + options + ); + + props = propsToYStruct(type as string, props); - props = propsToYStruct(type, props); + yMap.set('type', type); + yMap.set('id', id); - Object.keys(props).forEach(key => { - yMap.set(key, props[key]); + Object.keys(rest).forEach(key => { + // @ts-ignore + elementModel.model[key] = props[key]; }); + // @ts-ignore + elementModel.model._preserved.clear(); + return elementModel; } diff --git a/packages/blocks/src/surface-block/index.ts b/packages/blocks/src/surface-block/index.ts index c4c1bf375f46..1a69b19a7235 100644 --- a/packages/blocks/src/surface-block/index.ts +++ b/packages/blocks/src/surface-block/index.ts @@ -10,6 +10,7 @@ export { export { GRID_GAP_MAX, GRID_GAP_MIN } from './consts.js'; export { type EdgelessBlockType } from './edgeless-types.js'; export { ElementModel } from './element-model/base.js'; +export { BrushElementModel } from './element-model/brush.js'; export { ConnectorElementModel } from './element-model/connector.js'; export { GroupElementModel } from './element-model/group.js'; export { ShapeElementModel } from './element-model/shape.js'; diff --git a/packages/presets/src/__tests__/edgeless/surface-model.spec.ts b/packages/presets/src/__tests__/edgeless/surface-model.spec.ts index 902a70d671bd..14505dcc9a72 100644 --- a/packages/presets/src/__tests__/edgeless/surface-model.spec.ts +++ b/packages/presets/src/__tests__/edgeless/surface-model.spec.ts @@ -1,9 +1,10 @@ import type { + BrushElementModel, GroupElementModel, ShapeElementModel, SurfaceBlockModel, } from '@blocksuite/blocks'; -import { beforeEach, describe, expect, test } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; import { wait } from '../utils/common.js'; import { setupEditor } from '../utils/setup.js'; @@ -293,4 +294,69 @@ describe('stash/pop', () => { expect(elementModel.strokeWidth).toBe(10); expect(elementModel.yMap.get('strokeWidth')).toBe(10); }); + + test('assign stashed property should emit event', async () => { + const id = model.addElement({ + type: 'shape', + strokeWidth: 4, + }); + const elementModel = model.getElementById(id)! as ShapeElementModel; + + elementModel.stash('strokeWidth'); + + const onchange = vi.fn(); + model.elementUpdated.once(({ id }) => onchange(id)); + + elementModel.strokeWidth = 10; + expect(onchange).toHaveBeenCalledWith(id); + }); +}); + +describe('derive prop', () => { + test('derived prop should work correctly', () => { + const id = model.addElement({ + type: 'brush', + points: [ + [0, 0], + [100, 100], + [120, 150], + ], + }); + const elementModel = model.getElementById(id)! as BrushElementModel; + + expect(elementModel.w).toBe(120 + elementModel.lineWidth); + expect(elementModel.h).toBe(150 + elementModel.lineWidth); + }); +}); + +describe('local', () => { + test('local prop should work correctly', () => { + const id = model.addElement({ + type: 'shape', + }); + const elementModel = model.getElementById(id)! as BrushElementModel; + + expect(elementModel.display).toBe(true); + + elementModel.display = false; + expect(elementModel.display).toBe(false); + + elementModel.opacity = 0.5; + expect(elementModel.opacity).toBe(0.5); + }); + + test('assign local property should emit event', () => { + const id = model.addElement({ + type: 'shape', + }); + const elementModel = model.getElementById(id)! as BrushElementModel; + + const onchange = vi.fn(); + + model.elementUpdated.once(({ id }) => onchange(id)); + elementModel.display = false; + + expect(elementModel.display).toBe(false); + expect(onchange).toHaveBeenCalledWith(id); + }); }); From 305328ada8fd639f0051f922feb4eb65d2a9ed41 Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Tue, 2 Jan 2024 21:45:07 +0800 Subject: [PATCH 18/25] fix: build --- packages/blocks/src/surface-block/surface-model.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/blocks/src/surface-block/surface-model.ts b/packages/blocks/src/surface-block/surface-model.ts index 1969d871df90..22e5f41ba7e5 100644 --- a/packages/blocks/src/surface-block/surface-model.ts +++ b/packages/blocks/src/surface-block/surface-model.ts @@ -1,7 +1,7 @@ import { Slot } from '@blocksuite/global/utils'; import type { MigrationRunner, Y } from '@blocksuite/store'; import { - BaseBlockModel, + BlockModel, Boxed, defineBlockSchema, Text, @@ -152,7 +152,7 @@ export const SurfaceBlockSchema = defineBlockSchema({ toModel: () => new SurfaceBlockModel(), }); -export class SurfaceBlockModel extends BaseBlockModel { +export class SurfaceBlockModel extends BlockModel { private _elementModels: Map< string, { dispose: () => void; model: ElementModel } From 1b8fc61f6425d7fa881cad5c5419ddac124a89d0 Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Tue, 2 Jan 2024 21:53:22 +0800 Subject: [PATCH 19/25] fix: circular --- packages/blocks/src/surface-block/element-model/brush.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/blocks/src/surface-block/element-model/brush.ts b/packages/blocks/src/surface-block/element-model/brush.ts index b4aff895cd17..867f46f1f589 100644 --- a/packages/blocks/src/surface-block/element-model/brush.ts +++ b/packages/blocks/src/surface-block/element-model/brush.ts @@ -2,9 +2,9 @@ import { Bound, getBoundFromPoints, inflateBound, - type SerializedXYWH, transformPointsToNewBound, -} from '../index.js'; +} from '../utils/bound.js'; +import { type SerializedXYWH } from '../utils/xywh.js'; import { type BaseProps, ElementModel } from './base.js'; import { derive, ymap } from './decorators.js'; From 1792c87ab092fee6378a1fec90158bbf809a37a6 Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Wed, 3 Jan 2024 10:28:05 +0800 Subject: [PATCH 20/25] test: add more test --- .../src/__tests__/edgeless/last-props.spec.ts | 2 +- .../__tests__/edgeless/surface-model.spec.ts | 54 +++++++++++++++++-- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/packages/presets/src/__tests__/edgeless/last-props.spec.ts b/packages/presets/src/__tests__/edgeless/last-props.spec.ts index 2c3fb1affb04..601eb9666894 100644 --- a/packages/presets/src/__tests__/edgeless/last-props.spec.ts +++ b/packages/presets/src/__tests__/edgeless/last-props.spec.ts @@ -14,9 +14,9 @@ describe('apply last props', () => { let surface!: SurfaceBlockComponent; beforeEach(async () => { + sessionStorage.removeItem('blocksuite:prop:record'); const cleanup = await setupEditor('edgeless'); surface = getSurface(window.page, window.editor); - sessionStorage.removeItem('blocksuite:prop:record'); return cleanup; }); diff --git a/packages/presets/src/__tests__/edgeless/surface-model.spec.ts b/packages/presets/src/__tests__/edgeless/surface-model.spec.ts index 14505dcc9a72..4d4eb088f44b 100644 --- a/packages/presets/src/__tests__/edgeless/surface-model.spec.ts +++ b/packages/presets/src/__tests__/edgeless/surface-model.spec.ts @@ -24,11 +24,11 @@ beforeEach(async () => { describe('elements management', () => { test('addElement should work correctly', () => { - model.addElement({ + const id = model.addElement({ type: 'shape', }); - expect(model.elementModels.length).toBe(1); + expect(model.elementModels[0].id).toBe(id); }); test('removeElement should work correctly', () => { @@ -80,12 +80,28 @@ describe('element model', () => { test('defined prop should not be overwritten by default value', () => { const id = model.addElement({ type: 'shape', - strokeColor: '#fff', + strokeColor: '--affine-palette-line-black', + }); + + const element = model.getElementById(id)! as ShapeElementModel; + + expect(element.strokeColor).toBe('--affine-palette-line-black'); + }); + + test('assign value to model property should update ymap directly', () => { + const id = model.addElement({ + type: 'shape', }); const element = model.getElementById(id)! as ShapeElementModel; - expect(element.strokeColor).toBe('#fff'); + expect(element.yMap.get('strokeColor')).toBe( + '--affine-palette-line-yellow' + ); + + element.strokeColor = '--affine-palette-line-black'; + expect(element.yMap.get('strokeColor')).toBe('--affine-palette-line-black'); + expect(element.strokeColor).toBe('--affine-palette-line-black'); }); }); @@ -106,10 +122,14 @@ describe('group', () => { }, }); const group = model.getElementById(groupId); + const shape = model.getElementById(id)!; + const shape2 = model.getElementById(id2)!; expect(group).not.toBe(null); expect(model.getGroup(id)).toBe(group); expect(model.getGroup(id2)).toBe(group); + expect(shape.group).toBe(group); + expect(shape2.group).toBe(group); }); test('should return null if group children are updated', () => { @@ -162,6 +182,32 @@ describe('group', () => { // @ts-ignore expect(model._elementToGroup.get(id2)).toBeUndefined(); }); + + test('children can be updated with a plain object', () => { + const id = model.addElement({ + type: 'shape', + }); + const id2 = model.addElement({ + type: 'shape', + }); + + const groupId = model.addElement({ + type: 'group', + children: { + [id]: true, + [id2]: true, + }, + }); + const group = model.getElementById(groupId) as GroupElementModel; + + model.updateElement(groupId, { + children: { + [id]: false, + }, + }); + + expect(group.childrenIds).toEqual([id]); + }); }); describe('connector', () => { From 5ef5fbe0cc69f6a33c59dc49f6265f0ae3b91f35 Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Wed, 3 Jan 2024 17:03:58 +0800 Subject: [PATCH 21/25] chore: naming --- .../src/surface-block/element-model/base.ts | 14 +++---- .../src/surface-block/element-model/brush.ts | 12 +++--- .../surface-block/element-model/connector.ts | 22 +++++----- .../surface-block/element-model/decorators.ts | 4 +- .../src/surface-block/element-model/group.ts | 6 +-- .../src/surface-block/element-model/index.ts | 2 +- .../src/surface-block/element-model/shape.ts | 42 +++++++++---------- .../src/surface-block/element-model/text.ts | 22 +++++----- 8 files changed, 62 insertions(+), 62 deletions(-) diff --git a/packages/blocks/src/surface-block/element-model/base.ts b/packages/blocks/src/surface-block/element-model/base.ts index 81bd59a7d99b..e95953af6a22 100644 --- a/packages/blocks/src/surface-block/element-model/base.ts +++ b/packages/blocks/src/surface-block/element-model/base.ts @@ -4,7 +4,7 @@ import type { SurfaceBlockModel } from '../surface-model.js'; import { Bound } from '../utils/bound.js'; import { getBoundsWithRotation } from '../utils/math-utils.js'; import { deserializeXYWH, type SerializedXYWH } from '../utils/xywh.js'; -import { local, updateDerivedProp, ymap } from './decorators.js'; +import { local, updateDerivedProp, yfield } from './decorators.js'; export type BaseProps = { index: string; @@ -22,7 +22,7 @@ export abstract class ElementModel { protected _preserved: Map = new Map(); protected _stashed: Map; protected _local: Map = new Map(); - protected _onchange: (props: Record) => void; + protected _onChange: (props: Record) => void; yMap: Y.Map; surfaceModel!: SurfaceBlockModel; @@ -33,7 +33,7 @@ export abstract class ElementModel { abstract get type(): string; - @ymap() + @yfield() index!: string; @local() @@ -46,14 +46,14 @@ export abstract class ElementModel { yMap: Y.Map; model: SurfaceBlockModel; stashedStore: Map; - onchange: (props: Record) => void; + onChange: (props: Record) => void; }) { - const { yMap, model, stashedStore, onchange } = options; + const { yMap, model, stashedStore, onChange } = options; this.yMap = yMap; this.surfaceModel = model; this._stashed = stashedStore as Map; - this._onchange = onchange; + this._onChange = onChange; // base class property field is assigned before yMap is set // so we need to manually assign the default value here @@ -114,7 +114,7 @@ export abstract class ElementModel { const oldValue = this._stashed.get(prop); this._stashed.set(prop, value); - this._onchange({ [prop]: { oldValue } }); + this._onChange({ [prop]: { oldValue } }); updateDerivedProp(prototype, prop as string, this); }, diff --git a/packages/blocks/src/surface-block/element-model/brush.ts b/packages/blocks/src/surface-block/element-model/brush.ts index 867f46f1f589..dd63208a2d8c 100644 --- a/packages/blocks/src/surface-block/element-model/brush.ts +++ b/packages/blocks/src/surface-block/element-model/brush.ts @@ -6,7 +6,7 @@ import { } from '../utils/bound.js'; import { type SerializedXYWH } from '../utils/xywh.js'; import { type BaseProps, ElementModel } from './base.js'; -import { derive, ymap } from './decorators.js'; +import { derive, yfield } from './decorators.js'; export type BrushProps = BaseProps & { /** @@ -27,7 +27,7 @@ export class BrushElementModel extends ElementModel { xywh: boundWidthLineWidth.serialize(), }; }) - @ymap() + @yfield() points: number[][] = []; @derive((instance: BrushElementModel) => { @@ -45,16 +45,16 @@ export class BrushElementModel extends ElementModel { points: transformed.points.map(p => [p.x, p.y]), }; }) - @ymap() + @yfield() xywh: SerializedXYWH = '[0,0,0,0]'; - @ymap() + @yfield() rotate: number = 0; - @ymap() + @yfield() color: string = '#000000'; - @ymap() + @yfield() lineWidth: number = 4; override get type() { diff --git a/packages/blocks/src/surface-block/element-model/connector.ts b/packages/blocks/src/surface-block/element-model/connector.ts index bc3ead71cd9c..73da71540ffc 100644 --- a/packages/blocks/src/surface-block/element-model/connector.ts +++ b/packages/blocks/src/surface-block/element-model/connector.ts @@ -2,7 +2,7 @@ import { DEFAULT_ROUGHNESS } from '../consts.js'; import type { SerializedXYWH } from '../index.js'; import { type BaseProps, ElementModel } from './base.js'; import type { StrokeStyle } from './common.js'; -import { local, ymap } from './decorators.js'; +import { local, yfield } from './decorators.js'; export type PointStyle = 'None' | 'Arrow' | 'Triangle' | 'Circle' | 'Diamond'; @@ -42,37 +42,37 @@ export class ConnectorElementModel extends ElementModel { @local() rotate: number = 0; - @ymap() + @yfield() mode: ConnectorMode = ConnectorMode.Orthogonal; - @ymap() + @yfield() strokeWidth: number = 4; - @ymap() + @yfield() stroke: string = '#000000'; - @ymap() + @yfield() strokeStyle: StrokeStyle = 'solid'; - @ymap() + @yfield() roughness: number = DEFAULT_ROUGHNESS; - @ymap() + @yfield() rough?: boolean; - @ymap() + @yfield() source: Connection = { position: [0, 0], }; - @ymap() + @yfield() target: Connection = { position: [0, 0], }; - @ymap() + @yfield() frontEndpointStyle?: PointStyle; - @ymap() + @yfield() rearEndpointStyle?: PointStyle; } diff --git a/packages/blocks/src/surface-block/element-model/decorators.ts b/packages/blocks/src/surface-block/element-model/decorators.ts index 7146a52e5b55..a5bced7dd6f8 100644 --- a/packages/blocks/src/surface-block/element-model/decorators.ts +++ b/packages/blocks/src/surface-block/element-model/decorators.ts @@ -40,7 +40,7 @@ function yDecorator(prototype: unknown, prop: string | symbol) { }); } -export function ymap(): PropertyDecorator { +export function yfield(): PropertyDecorator { return yDecorator; } @@ -55,7 +55,7 @@ export function local(): PropertyDecorator { this._local.set(prop, newVal); if (state.creating) return; - this._onchange({ + this._onChange({ [prop]: { oldValue, }, diff --git a/packages/blocks/src/surface-block/element-model/group.ts b/packages/blocks/src/surface-block/element-model/group.ts index 8f5e8ca2a9cb..0252a288c1b3 100644 --- a/packages/blocks/src/surface-block/element-model/group.ts +++ b/packages/blocks/src/surface-block/element-model/group.ts @@ -6,7 +6,7 @@ import { Bound } from '../utils/bound.js'; import { type SerializedXYWH } from '../utils/xywh.js'; import type { BaseProps } from './base.js'; import { ElementModel } from './base.js'; -import { ymap } from './decorators.js'; +import { yfield } from './decorators.js'; type GroupElementProps = BaseProps & { children: Y.Map; @@ -32,10 +32,10 @@ export class GroupElementModel extends ElementModel { return props; } - @ymap() + @yfield() children: Y.Map = new Workspace.Y.Map(); - @ymap() + @yfield() title: Y.Text = new Workspace.Y.Text(); get xywh() { diff --git a/packages/blocks/src/surface-block/element-model/index.ts b/packages/blocks/src/surface-block/element-model/index.ts index f57ccafd3546..1f75ec4a3eed 100644 --- a/packages/blocks/src/surface-block/element-model/index.ts +++ b/packages/blocks/src/surface-block/element-model/index.ts @@ -46,7 +46,7 @@ export function createElementModel( yMap, model, stashedStore: stashed, - onchange: props => options.onChange({ id, props }), + onChange: props => options.onChange({ id, props }), }) as ElementModel; setCreateState(false, false); diff --git a/packages/blocks/src/surface-block/element-model/shape.ts b/packages/blocks/src/surface-block/element-model/shape.ts index fc9c734c98a7..85c1ce3f7fca 100644 --- a/packages/blocks/src/surface-block/element-model/shape.ts +++ b/packages/blocks/src/surface-block/element-model/shape.ts @@ -6,7 +6,7 @@ import { type BaseProps, ElementModel } from './base.js'; import type { FontWeight } from './common.js'; import { type FontStyle } from './common.js'; import { type StrokeStyle } from './common.js'; -import { ymap } from './decorators.js'; +import { yfield } from './decorators.js'; export type ShapeType = 'rect' | 'triangle' | 'ellipse' | 'diamond'; export type ShapeStyle = 'General' | 'Scribbled'; @@ -50,64 +50,64 @@ export class ShapeElementModel extends ElementModel { return props; } - @ymap() + @yfield() xywh: SerializedXYWH = '[0,0,100,100]'; - @ymap() + @yfield() rotate: number = 0; - @ymap() + @yfield() shapeType: ShapeType = 'rect'; - @ymap() + @yfield() radius: number = 0; - @ymap() + @yfield() filled: boolean = false; - @ymap() + @yfield() fillColor: string = '--affine-palette-shape-yellow'; - @ymap() + @yfield() strokeWidth: number = 4; - @ymap() + @yfield() strokeColor: string = '--affine-palette-line-yellow'; - @ymap() + @yfield() strokeStyle: StrokeStyle = 'solid'; - @ymap() + @yfield() shapeStyle: ShapeStyle = 'General'; - @ymap() + @yfield() roughness: number = DEFAULT_ROUGHNESS; - @ymap() + @yfield() text?: Y.Text; - @ymap() + @yfield() color?: string; - @ymap() + @yfield() fontSize?: number; - @ymap() + @yfield() fontFamily?: string; - @ymap() + @yfield() fontWeight?: FontWeight; - @ymap() + @yfield() fontStyle?: FontStyle; - @ymap() + @yfield() textAlign?: 'left' | 'center' | 'right'; - @ymap() + @yfield() textHorizontalAlign?: 'left' | 'center' | 'right'; - @ymap() + @yfield() textVerticalAlign?: 'top' | 'center' | 'bottom'; get type() { diff --git a/packages/blocks/src/surface-block/element-model/text.ts b/packages/blocks/src/surface-block/element-model/text.ts index 6c63e7f8987f..48c95c70e51f 100644 --- a/packages/blocks/src/surface-block/element-model/text.ts +++ b/packages/blocks/src/surface-block/element-model/text.ts @@ -3,7 +3,7 @@ import type { Y } from '@blocksuite/store'; import type { SerializedXYWH } from '../index.js'; import { type BaseProps, ElementModel } from './base.js'; import type { FontFamily, FontStyle, FontWeight } from './common.js'; -import { ymap } from './decorators.js'; +import { yfield } from './decorators.js'; export type TextElementProps = BaseProps & { text: Y.Text; @@ -17,34 +17,34 @@ export type TextElementProps = BaseProps & { }; export class TextElementModel extends ElementModel { - @ymap() + @yfield() xywh: SerializedXYWH = '[0,0,0,0]'; - @ymap() + @yfield() rotate: number = 0; - @ymap() + @yfield() text!: Y.Text; - @ymap() + @yfield() color!: string; - @ymap() + @yfield() fontSize!: number; - @ymap() + @yfield() fontFamily!: FontFamily; - @ymap() + @yfield() fontWeight?: FontWeight; - @ymap() + @yfield() fontStyle?: FontStyle; - @ymap() + @yfield() textAlign!: 'left' | 'center' | 'right'; - @ymap() + @yfield() hasMaxWidth?: boolean; get type() { From 0c8860f69733d0b6173646426a1d989ae8f5166a Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Wed, 3 Jan 2024 17:21:05 +0800 Subject: [PATCH 22/25] fix: element model typing --- packages/blocks/src/surface-block/surface-model.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/blocks/src/surface-block/surface-model.ts b/packages/blocks/src/surface-block/surface-model.ts index 22e5f41ba7e5..e8facc7f42b7 100644 --- a/packages/blocks/src/surface-block/surface-model.ts +++ b/packages/blocks/src/surface-block/surface-model.ts @@ -438,7 +438,9 @@ export class SurfaceBlockModel extends BlockModel { return this._elementModels.get(id)?.model ?? null; } - addElement(props: Record) { + addElement>( + props: Partial & { type: string } + ) { if (this.page.readonly) { throw new Error('Cannot add element in readonly mode'); } @@ -470,7 +472,10 @@ export class SurfaceBlockModel extends BlockModel { }); } - updateElement(id: string, props: Record) { + updateElement>( + id: string, + props: Partial + ) { if (this.page.readonly) { throw new Error('Cannot update element in readonly mode'); } @@ -482,7 +487,10 @@ export class SurfaceBlockModel extends BlockModel { } this.page.transact(() => { - props = propsToYStruct(elementModel.type, props); + props = propsToYStruct( + elementModel.type, + props as Record + ) as T; Object.entries(props).forEach(([key, value]) => { // @ts-ignore elementModel[key] = value; From c623aecc4a7f444a4d1401ff39903cbd6afcee19 Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Wed, 3 Jan 2024 17:23:22 +0800 Subject: [PATCH 23/25] fix: typing --- packages/blocks/src/surface-block/surface-model.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/blocks/src/surface-block/surface-model.ts b/packages/blocks/src/surface-block/surface-model.ts index e8facc7f42b7..ca46cdd3d013 100644 --- a/packages/blocks/src/surface-block/surface-model.ts +++ b/packages/blocks/src/surface-block/surface-model.ts @@ -447,6 +447,7 @@ export class SurfaceBlockModel extends BlockModel { const id = generateElementId(); + // @ts-ignore props.id = id; const elementModel = createModelFromProps(props, this, { From 0b28005d9e966f5ca7799387e44f3dc17d5cb326 Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Wed, 3 Jan 2024 17:35:59 +0800 Subject: [PATCH 24/25] fix: naming --- packages/blocks/src/surface-block/element-model/base.ts | 2 +- packages/blocks/src/surface-block/element-model/group.ts | 2 +- packages/blocks/src/surface-block/element-model/index.ts | 6 +++--- packages/blocks/src/surface-block/element-model/shape.ts | 2 +- packages/blocks/src/surface-block/surface-model.ts | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/blocks/src/surface-block/element-model/base.ts b/packages/blocks/src/surface-block/element-model/base.ts index e95953af6a22..054a845ad047 100644 --- a/packages/blocks/src/surface-block/element-model/base.ts +++ b/packages/blocks/src/surface-block/element-model/base.ts @@ -11,7 +11,7 @@ export type BaseProps = { }; export abstract class ElementModel { - static propsToYStruct(props: Record) { + static propsToY(props: Record) { return props; } diff --git a/packages/blocks/src/surface-block/element-model/group.ts b/packages/blocks/src/surface-block/element-model/group.ts index 0252a288c1b3..592b8c9bb0cb 100644 --- a/packages/blocks/src/surface-block/element-model/group.ts +++ b/packages/blocks/src/surface-block/element-model/group.ts @@ -14,7 +14,7 @@ type GroupElementProps = BaseProps & { }; export class GroupElementModel extends ElementModel { - static override propsToYStruct(props: GroupElementProps) { + static override propsToY(props: GroupElementProps) { if (props.title && !(props.title instanceof Workspace.Y.Text)) { props.title = new Workspace.Y.Text(props.title); } diff --git a/packages/blocks/src/surface-block/element-model/index.ts b/packages/blocks/src/surface-block/element-model/index.ts index 1f75ec4a3eed..e40b3a7d9be6 100644 --- a/packages/blocks/src/surface-block/element-model/index.ts +++ b/packages/blocks/src/surface-block/element-model/index.ts @@ -95,7 +95,7 @@ function onElementChange( }; } -export function propsToYStruct(type: string, props: Record) { +export function propsToY(type: string, props: Record) { const ctor = elementsCtorMap[type as keyof typeof elementsCtorMap]; if (!ctor) { @@ -103,7 +103,7 @@ export function propsToYStruct(type: string, props: Record) { } // @ts-ignore - return (ctor.propsToYStruct ?? ElementModel.propsToYStruct)(props); + return (ctor.propsToY ?? ElementModel.propsToY)(props); } export function createModelFromProps( @@ -131,7 +131,7 @@ export function createModelFromProps( options ); - props = propsToYStruct(type as string, props); + props = propsToY(type as string, props); yMap.set('type', type); yMap.set('id', id); diff --git a/packages/blocks/src/surface-block/element-model/shape.ts b/packages/blocks/src/surface-block/element-model/shape.ts index 85c1ce3f7fca..fb388cca08b0 100644 --- a/packages/blocks/src/surface-block/element-model/shape.ts +++ b/packages/blocks/src/surface-block/element-model/shape.ts @@ -42,7 +42,7 @@ export type ShapeProps = BaseProps & { }; export class ShapeElementModel extends ElementModel { - static override propsToYStruct(props: ShapeProps) { + static override propsToY(props: ShapeProps) { if (props.text && !(props.text instanceof Workspace.Y.Text)) { props.text = new Workspace.Y.Text(props.text); } diff --git a/packages/blocks/src/surface-block/surface-model.ts b/packages/blocks/src/surface-block/surface-model.ts index ca46cdd3d013..9976c29ea964 100644 --- a/packages/blocks/src/surface-block/surface-model.ts +++ b/packages/blocks/src/surface-block/surface-model.ts @@ -17,7 +17,7 @@ import type { GroupElementModel } from './element-model/group.js'; import { createElementModel, createModelFromProps, - propsToYStruct, + propsToY, } from './element-model/index.js'; import { generateElementId } from './index.js'; import { SurfaceBlockTransformer } from './surface-transformer.js'; @@ -488,7 +488,7 @@ export class SurfaceBlockModel extends BlockModel { } this.page.transact(() => { - props = propsToYStruct( + props = propsToY( elementModel.type, props as Record ) as T; From 4ce47c548cf4dbda4176a254a4ca7a9a89a7c8f6 Mon Sep 17 00:00:00 2001 From: Hongtao Lye Date: Wed, 3 Jan 2024 17:45:37 +0800 Subject: [PATCH 25/25] fix: test --- packages/blocks/src/__tests__/database/database.unit.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/blocks/src/__tests__/database/database.unit.spec.ts b/packages/blocks/src/__tests__/database/database.unit.spec.ts index f157e5afcddb..873be93a7566 100644 --- a/packages/blocks/src/__tests__/database/database.unit.spec.ts +++ b/packages/blocks/src/__tests__/database/database.unit.spec.ts @@ -4,7 +4,7 @@ import '../../database-block/table/define.js'; import type { BlockModel, Page } from '@blocksuite/store'; import { Generator, Schema, Workspace } from '@blocksuite/store'; -import { beforeEach, describe, expect, test } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; import { numberPureColumnConfig } from '../../database-block/common/columns/number/define.js'; import { richTextPureColumnConfig } from '../../database-block/common/columns/rich-text/define.js'; @@ -58,6 +58,8 @@ describe('DatabaseManager', () => { ]; beforeEach(async () => { + vi.useFakeTimers({ toFake: ['requestIdleCallback'] }); + page = await createTestPage(); pageBlockId = page.addBlock('affine:page', {