diff --git a/blocksuite/framework/std/src/__tests__/editor-host.unit.spec.ts b/blocksuite/framework/std/src/__tests__/editor-host.unit.spec.ts index d35e5087dd..6e292960b1 100644 --- a/blocksuite/framework/std/src/__tests__/editor-host.unit.spec.ts +++ b/blocksuite/framework/std/src/__tests__/editor-host.unit.spec.ts @@ -11,6 +11,7 @@ import { HeadingBlockSchemaExtension, NoteBlockSchemaExtension, RootBlockSchemaExtension, + SurfaceBlockSchemaExtension, } from './test-schema.js'; import { testSpecs } from './test-spec.js'; @@ -20,6 +21,7 @@ const extensions = [ RootBlockSchemaExtension, NoteBlockSchemaExtension, HeadingBlockSchemaExtension, + SurfaceBlockSchemaExtension, ]; function createTestOptions() { diff --git a/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/surface.unit.spec.ts/convert-decorator-convert-decorator-1.png b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/surface.unit.spec.ts/convert-decorator-convert-decorator-1.png new file mode 100644 index 0000000000..f88c55c1ca Binary files /dev/null and b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/surface.unit.spec.ts/convert-decorator-convert-decorator-1.png differ diff --git a/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/surface.unit.spec.ts/derive-decorator-derived-decorator-should-work-correctly-1.png b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/surface.unit.spec.ts/derive-decorator-derived-decorator-should-work-correctly-1.png new file mode 100644 index 0000000000..f88c55c1ca Binary files /dev/null and b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/surface.unit.spec.ts/derive-decorator-derived-decorator-should-work-correctly-1.png differ diff --git a/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/surface.unit.spec.ts/stash-pop-stash-and-pop-should-work-correctly-1.png b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/surface.unit.spec.ts/stash-pop-stash-and-pop-should-work-correctly-1.png new file mode 100644 index 0000000000..f88c55c1ca Binary files /dev/null and b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/surface.unit.spec.ts/stash-pop-stash-and-pop-should-work-correctly-1.png differ diff --git a/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/surface.unit.spec.ts/stash-pop-stashed-property-should-also-trigger-derive-decorator-1.png b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/surface.unit.spec.ts/stash-pop-stashed-property-should-also-trigger-derive-decorator-1.png new file mode 100644 index 0000000000..f88c55c1ca Binary files /dev/null and b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/surface.unit.spec.ts/stash-pop-stashed-property-should-also-trigger-derive-decorator-1.png differ diff --git a/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/surface.unit.spec.ts/surface-basic-created-slot-should-be-called-1.png b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/surface.unit.spec.ts/surface-basic-created-slot-should-be-called-1.png new file mode 100644 index 0000000000..f88c55c1ca Binary files /dev/null and b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/surface.unit.spec.ts/surface-basic-created-slot-should-be-called-1.png differ diff --git a/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/surface.unit.spec.ts/surface-basic-delete-observer-should-be-called-1.png b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/surface.unit.spec.ts/surface-basic-delete-observer-should-be-called-1.png new file mode 100644 index 0000000000..f88c55c1ca Binary files /dev/null and b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/surface.unit.spec.ts/surface-basic-delete-observer-should-be-called-1.png differ diff --git a/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/surface.unit.spec.ts/surface-basic-update-slot-should-be-called-1.png b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/surface.unit.spec.ts/surface-basic-update-slot-should-be-called-1.png new file mode 100644 index 0000000000..f88c55c1ca Binary files /dev/null and b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/surface.unit.spec.ts/surface-basic-update-slot-should-be-called-1.png differ diff --git a/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/view.unit.spec.ts/gfx-element-view-basic-local-element-view-should-be-created-1.png b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/view.unit.spec.ts/gfx-element-view-basic-local-element-view-should-be-created-1.png new file mode 100644 index 0000000000..06188403ba Binary files /dev/null and b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/view.unit.spec.ts/gfx-element-view-basic-local-element-view-should-be-created-1.png differ diff --git a/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/view.unit.spec.ts/gfx-element-view-basic-query-gfx-block-view-should-work-1.png b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/view.unit.spec.ts/gfx-element-view-basic-query-gfx-block-view-should-work-1.png new file mode 100644 index 0000000000..f88c55c1ca Binary files /dev/null and b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/view.unit.spec.ts/gfx-element-view-basic-query-gfx-block-view-should-work-1.png differ diff --git a/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/view.unit.spec.ts/gfx-element-view-basic-view-should-be-created-1.png b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/view.unit.spec.ts/gfx-element-view-basic-view-should-be-created-1.png new file mode 100644 index 0000000000..f88c55c1ca Binary files /dev/null and b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/view.unit.spec.ts/gfx-element-view-basic-view-should-be-created-1.png differ diff --git a/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/view.unit.spec.ts/gfx-element-view-basic-view-should-be-removed-1.png b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/view.unit.spec.ts/gfx-element-view-basic-view-should-be-removed-1.png new file mode 100644 index 0000000000..f88c55c1ca Binary files /dev/null and b/blocksuite/framework/std/src/__tests__/gfx/__screenshots__/view.unit.spec.ts/gfx-element-view-basic-view-should-be-removed-1.png differ diff --git a/blocksuite/framework/std/src/__tests__/gfx/surface.unit.spec.ts b/blocksuite/framework/std/src/__tests__/gfx/surface.unit.spec.ts new file mode 100644 index 0000000000..a056465c7d --- /dev/null +++ b/blocksuite/framework/std/src/__tests__/gfx/surface.unit.spec.ts @@ -0,0 +1,358 @@ +import { + createAutoIncrementIdGenerator, + TestWorkspace, +} from '@blocksuite/store/test'; +import { describe, expect, test, vi } from 'vitest'; + +import { effects } from '../../effects.js'; +import type { TestShapeElement } from '../test-gfx-element.js'; +import { + RootBlockSchemaExtension, + type SurfaceBlockModel, + SurfaceBlockSchemaExtension, +} from '../test-schema.js'; + +effects(); + +const extensions = [RootBlockSchemaExtension, SurfaceBlockSchemaExtension]; + +function createTestOptions() { + const idGenerator = createAutoIncrementIdGenerator(); + return { id: 'test-collection', idGenerator }; +} + +const commonSetup = () => { + const collection = new TestWorkspace(createTestOptions()); + + collection.meta.initialize(); + const doc = collection.createDoc('home'); + const store = doc.getStore({ extensions }); + doc.load(); + + const rootId = store.addBlock('test:page'); + const surfaceId = store.addBlock('test:surface', {}, rootId); + + const surfaceBlock = store.getBlock(surfaceId)!; + + return { + surfaceId, + surfaceModel: surfaceBlock.model as SurfaceBlockModel, + }; +}; + +describe('surface basic', () => { + test('addElement should work correctly', () => { + const { surfaceModel: model } = commonSetup(); + const id = model.addElement({ + type: 'testShape', + }); + + expect(model.elementModels[0].id).toBe(id); + }); + + test('removeElement should work correctly', () => { + const { surfaceModel: model } = commonSetup(); + const id = model.addElement({ + type: 'testShape', + }); + + model.deleteElement(id); + + expect(model.elementModels.length).toBe(0); + }); + + test('updateElement should work correctly', () => { + const { surfaceModel: model } = commonSetup(); + const id = model.addElement({ + type: 'testShape', + }); + + model.updateElement(id, { xywh: '[10,10,200,200]' }); + + expect(model.elementModels[0].xywh).toBe('[10,10,200,200]'); + }); + + test('getElementById should return element', () => { + const { surfaceModel: model } = commonSetup(); + + const id = model.addElement({ + type: 'testShape', + }); + + expect(model.getElementById(id)).not.toBeNull(); + }); + + test('getElementById should return null if not found', () => { + const { surfaceModel: model } = commonSetup(); + + expect(model.getElementById('not-found')).toBeNull(); + }); + + test('created observer should be called', () => { + const { surfaceModel } = commonSetup(); + + let expectPayload; + const elementAddedCallback = vi.fn(payload => (expectPayload = payload)); + + surfaceModel.elementAdded.subscribe(elementAddedCallback); + + const shapeId = surfaceModel.addElement({ + type: 'testShape', + rotate: 0, + xywh: '[0, 0, 10, 10]', + }); + + expect(elementAddedCallback).toHaveBeenCalled(); + expect(expectPayload).toMatchObject({ + id: shapeId, + }); + }); + + test('update and props observer should be called', () => { + const { surfaceModel } = commonSetup(); + + const shapeId = surfaceModel.addElement({ + type: 'testShape', + rotate: 0, + xywh: '[0, 0, 10, 10]', + }); + const shapeModel = surfaceModel.getElementById(shapeId)!; + + let expectPayload; + const elementUpdatedCallback = vi.fn(payload => (expectPayload = payload)); + let propsUpdatedPayload; + const propsUpdatedCallback = vi.fn(payload => { + propsUpdatedPayload = payload; + }); + + surfaceModel.elementUpdated.subscribe(elementUpdatedCallback); + shapeModel.propsUpdated.subscribe(propsUpdatedCallback); + + surfaceModel.updateElement(shapeId, { + rotate: 10, + }); + + expect(elementUpdatedCallback).toHaveBeenCalled(); + expect(propsUpdatedCallback).toHaveBeenCalled(); + expect(expectPayload).toMatchObject({ + id: shapeId, + props: { + rotate: 10, + }, + oldValues: { + rotate: 0, + }, + }); + expect(propsUpdatedPayload).toMatchObject({ + key: 'rotate', + }); + }); + + test('delete observer should be called', () => { + const { surfaceModel } = commonSetup(); + + const shapeId = surfaceModel.addElement({ + type: 'testShape', + rotate: 0, + xywh: '[0, 0, 10, 10]', + }); + + let expectPayload; + const deletedCallback = vi.fn(payload => (expectPayload = payload)); + + surfaceModel.elementRemoved.subscribe(deletedCallback); + surfaceModel.deleteElement(shapeId); + + expect(deletedCallback).toHaveBeenCalled(); + expect(expectPayload).toMatchObject({ + id: shapeId, + type: 'testShape', + }); + }); +}); + +describe('element model', () => { + test('default value should work correctly', () => { + const { surfaceModel: model } = commonSetup(); + const id = model.addElement({ + type: 'testShape', + }); + + const element = model.getElementById(id)! as TestShapeElement; + + expect(element.rotate).toBe(0); + expect(element.xywh).toBe('[0,0,10,10]'); + }); + + test('defined prop should not be overwritten by default value', () => { + const { surfaceModel: model } = commonSetup(); + const id = model.addElement({ + type: 'testShape', + rotate: 20, + }); + + const element = model.getElementById(id)! as TestShapeElement; + + expect(element.rotate).toBe(20); + }); + + test('assign value to model property should update ymap directly', () => { + const { surfaceModel: model } = commonSetup(); + const id = model.addElement({ + type: 'testShape', + }); + + const element = model.getElementById(id)! as TestShapeElement; + + expect(element.yMap.get('rotate')).toBe(0); + element.rotate = 30; + expect(element.yMap.get('rotate')).toBe(30); + }); +}); + +describe('stash/pop', () => { + const { surfaceModel: model } = commonSetup(); + test('stash and pop should work correctly', () => { + const id = model.addElement({ + type: 'testShape', + }); + const elementModel = model.getElementById(id)! as TestShapeElement; + + expect(elementModel.rotate).toBe(0); + + elementModel.stash('rotate'); + elementModel.rotate = 10; + expect(elementModel.rotate).toBe(10); + expect(elementModel.yMap.get('rotate')).toBe(0); + + elementModel.pop('rotate'); + expect(elementModel.rotate).toBe(10); + expect(elementModel.yMap.get('rotate')).toBe(10); + + elementModel.rotate = 6; + expect(elementModel.rotate).toBe(6); + expect(elementModel.yMap.get('rotate')).toBe(6); + }); + + test('assign stashed property should emit event', () => { + const id = model.addElement({ + type: 'testShape', + rotate: 4, + }); + const elementModel = model.getElementById(id)! as TestShapeElement; + + elementModel.stash('rotate'); + + const onchange = vi.fn(); + const subscription = model.elementUpdated.subscribe(({ id }) => { + subscription.unsubscribe(); + onchange(id); + }); + + elementModel.rotate = 10; + expect(onchange).toHaveBeenCalledWith(id); + }); + + test('stashed property should also trigger derive decorator', () => { + const id = model.addElement({ + type: 'testShape', + rotate: 20, + }); + const elementModel = model.getElementById(id)! as TestShapeElement; + + elementModel.stash('shapeType'); + elementModel.shapeType = 'triangle'; + + // rotation should be 0 'cause of derive decorator + expect(elementModel.rotate).toBe(0); + }); + + test('non-field property should not allow stash/pop, and should fail silently ', () => { + const id = model.addElement({ + type: 'testShape', + }); + const elementModel = model.getElementById(id)! as TestShapeElement; + + // opacity is a local property, so it should not be stashed + elementModel.stash('opacity'); + expect(elementModel['_stashed'].has('opacity')).toBe(false); + + // pop the `opacity` should not affect yMap + elementModel.opacity = 0.5; + elementModel.pop('opacity'); + expect(elementModel.yMap.has('opacity')).toBe(false); + }); +}); + +describe('derive decorator', () => { + test('derived decorator should work correctly', () => { + const { surfaceModel: model } = commonSetup(); + const id = model.addElement({ + type: 'testShape', + rotate: 20, + }); + const elementModel = model.getElementById(id)! as TestShapeElement; + + elementModel.shapeType = 'triangle'; + + expect(elementModel.rotate).toBe(0); + }); +}); + +describe('local decorator', () => { + test('local decorator should work correctly', () => { + const { surfaceModel: model } = commonSetup(); + const id = model.addElement({ + type: 'testShape', + }); + const elementModel = model.getElementById(id)! as TestShapeElement; + + 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 { surfaceModel: model } = commonSetup(); + const id = model.addElement({ + type: 'testShape', + }); + const elementModel = model.getElementById(id)! as TestShapeElement; + + const onchange = vi.fn(); + const subscription = model.elementUpdated.subscribe(({ id }) => { + subscription.unsubscribe(); + onchange(id); + }); + + const onPropChange = vi.fn(); + elementModel.propsUpdated.subscribe(({ key }) => { + onPropChange(key); + }); + + elementModel.display = false; + + expect(elementModel.display).toBe(false); + expect(onchange).toHaveBeenCalledWith(id); + expect(onPropChange).toHaveBeenCalledWith('display'); + }); +}); + +describe('convert decorator', () => { + test('convert decorator', () => { + const { surfaceModel: model } = commonSetup(); + const id = model.addElement({ + type: 'testShape', + }); + const elementModel = model.getElementById(id)! as TestShapeElement; + + // @ts-expect-error test needed + elementModel.shapeType = 'otherImpossibleType'; + + expect(elementModel.shapeType).toBe('rect'); + }); +}); diff --git a/blocksuite/framework/std/src/__tests__/gfx/view.unit.spec.ts b/blocksuite/framework/std/src/__tests__/gfx/view.unit.spec.ts new file mode 100644 index 0000000000..ad645edfd8 --- /dev/null +++ b/blocksuite/framework/std/src/__tests__/gfx/view.unit.spec.ts @@ -0,0 +1,134 @@ +import { + createAutoIncrementIdGenerator, + TestWorkspace, +} from '@blocksuite/store/test'; +import { describe, expect, test } from 'vitest'; + +import { effects } from '../../effects.js'; +import { GfxControllerIdentifier } from '../../gfx/identifiers.js'; +import { TestEditorContainer } from '../test-editor.js'; +import { TestLocalElement } from '../test-gfx-element.js'; +import { + RootBlockSchemaExtension, + type SurfaceBlockModel, + SurfaceBlockSchemaExtension, + TestGfxBlockSchemaExtension, +} from '../test-schema.js'; +import { testSpecs } from '../test-spec.js'; + +effects(); + +const extensions = [ + RootBlockSchemaExtension, + SurfaceBlockSchemaExtension, + TestGfxBlockSchemaExtension, +]; + +function createTestOptions() { + const idGenerator = createAutoIncrementIdGenerator(); + return { id: 'test-collection', idGenerator }; +} + +const commonSetup = async () => { + const collection = new TestWorkspace(createTestOptions()); + + collection.meta.initialize(); + const doc = collection.createDoc('home'); + const store = doc.getStore({ extensions }); + doc.load(); + + const rootId = store.addBlock('test:page'); + const surfaceId = store.addBlock('test:surface', {}, rootId); + + const surfaceBlock = store.getBlock(surfaceId)!; + + const editorContainer = new TestEditorContainer(); + editorContainer.doc = store; + editorContainer.specs = testSpecs; + document.body.append(editorContainer); + + await editorContainer.updateComplete; + + const gfx = editorContainer.std.get(GfxControllerIdentifier); + + return { + gfx, + surfaceId, + rootId, + surfaceModel: surfaceBlock.model as SurfaceBlockModel, + }; +}; + +describe('gfx element view basic', () => { + test('view should be created', async () => { + const { gfx, surfaceModel } = await commonSetup(); + + const id = surfaceModel.addElement({ + type: 'testShape', + }); + const shapeView = gfx.view.get(id); + + expect(shapeView).not.toBeNull(); + expect(shapeView!.model.id).toBe(id); + expect(shapeView!.isConnected).toBe(true); + }); + + test('view should be removed', async () => { + const { gfx, surfaceModel } = await commonSetup(); + + const id = surfaceModel.addElement({ + type: 'testShape', + }); + const shapeView = gfx.view.get(id); + + expect(shapeView).not.toBeNull(); + expect(shapeView!.model.id).toBe(id); + + surfaceModel.deleteElement(id); + expect(gfx.view.get(id)).toBeNull(); + expect(shapeView!.isConnected).toBe(false); + }); + + test('query gfx block view should work', async () => { + const { gfx, surfaceId, rootId } = await commonSetup(); + + const waitGfxViewConnected = (id: string) => { + const { promise, resolve } = Promise.withResolvers(); + const subscription = gfx.std.view.viewUpdated.subscribe(payload => { + if ( + payload.id === id && + payload.type === 'block' && + payload.method === 'add' + ) { + subscription.unsubscribe(); + resolve(); + } + }); + + return promise; + }; + const id = gfx.std.store.addBlock('test:gfx-block', undefined, surfaceId); + await waitGfxViewConnected(id); + const gfxBlockView = gfx.view.get(id); + expect(gfxBlockView).not.toBeNull(); + + const rootView = gfx.view.get(rootId); + // root is not a gfx block, so it should be null + expect(rootView).toBeNull(); + }); + + test('local element view should be created', async () => { + const { gfx, surfaceModel } = await commonSetup(); + const localElement = new TestLocalElement(surfaceModel); + localElement.id = 'test-local-element'; + + surfaceModel.addLocalElement(localElement); + + const localView = gfx.view.get(localElement); + expect(localView).not.toBeNull(); + expect(localView!.isConnected).toBe(true); + + surfaceModel.deleteLocalElement(localElement); + expect(localView!.isConnected).toBe(false); + }); +}); diff --git a/blocksuite/framework/std/src/__tests__/test-block.ts b/blocksuite/framework/std/src/__tests__/test-block.ts index 5d008b0c65..3c5cea5ae9 100644 --- a/blocksuite/framework/std/src/__tests__/test-block.ts +++ b/blocksuite/framework/std/src/__tests__/test-block.ts @@ -1,11 +1,13 @@ import { html } from 'lit'; import { customElement } from 'lit/decorators.js'; -import { BlockComponent } from '../view/index.js'; +import { BlockComponent, GfxBlockComponent } from '../view/index.js'; import type { HeadingBlockModel, NoteBlockModel, RootBlockModel, + SurfaceBlockModel, + TestGfxBlockModel, } from './test-schema.js'; @customElement('test-root-block') @@ -39,3 +41,21 @@ export class HeadingH2BlockComponent extends BlockComponent { return html`
${this.model.text}
`; } } + +@customElement('test-surface-block') +export class SurfaceBlockComponent extends BlockComponent { + override renderBlock() { + return html` +
${this.renderChildren(this.model)}
+ `; + } +} + +@customElement('test-gfx-block') +export class TestGfxBlockComponent extends GfxBlockComponent { + override renderGfxBlock() { + return html` +
${this.renderChildren(this.model)}
+ `; + } +} diff --git a/blocksuite/framework/std/src/__tests__/test-gfx-element.ts b/blocksuite/framework/std/src/__tests__/test-gfx-element.ts new file mode 100644 index 0000000000..83b5716a40 --- /dev/null +++ b/blocksuite/framework/std/src/__tests__/test-gfx-element.ts @@ -0,0 +1,44 @@ +import type { SerializedXYWH } from '@blocksuite/global/gfx'; + +import { + convert, + derive, + field, + GfxLocalElementModel, + GfxPrimitiveElementModel, +} from '../gfx/index.js'; + +export class TestShapeElement extends GfxPrimitiveElementModel { + get type() { + return 'testShape'; + } + + @field() + accessor rotate: number = 0; + + @field() + accessor xywh: SerializedXYWH = '[0,0,10,10]'; + + @convert(val => { + if (['rect', 'triangle'].includes(val)) { + return val; + } + + return 'rect'; + }) + @derive(val => { + if (val === 'triangle') { + return { + rotate: 0, + }; + } + + return {}; + }) + @field() + accessor shapeType: 'rect' | 'triangle' = 'rect'; +} + +export class TestLocalElement extends GfxLocalElementModel { + override type: string = 'testLocal'; +} diff --git a/blocksuite/framework/std/src/__tests__/test-schema.ts b/blocksuite/framework/std/src/__tests__/test-schema.ts index 5bd854c31f..af5198801e 100644 --- a/blocksuite/framework/std/src/__tests__/test-schema.ts +++ b/blocksuite/framework/std/src/__tests__/test-schema.ts @@ -1,8 +1,17 @@ +import type { SerializedXYWH } from '@blocksuite/global/gfx'; import { BlockModel, BlockSchemaExtension, defineBlockSchema, } from '@blocksuite/store'; +import * as Y from 'yjs'; + +import { SurfaceBlockModel as BaseSurfaceModel } from '../gfx/index.js'; +import { + GfxCompatibleBlockModel, + type GfxCompatibleProps, +} from '../gfx/model/gfx-block-model.js'; +import { TestShapeElement } from './test-gfx-element.js'; export const RootBlockSchema = defineBlockSchema({ flavour: 'test:page', @@ -15,7 +24,7 @@ export const RootBlockSchema = defineBlockSchema({ metadata: { version: 2, role: 'root', - children: ['test:note'], + children: ['test:note', 'test:surface'], }, }); @@ -61,3 +70,58 @@ export const HeadingBlockSchemaExtension = export class HeadingBlockModel extends BlockModel< ReturnType<(typeof HeadingBlockSchema)['model']['props']> > {} + +export const SurfaceBlockSchema = defineBlockSchema({ + flavour: 'test:surface', + props: internal => ({ + elements: internal.Boxed>>(new Y.Map()), + }), + metadata: { + version: 1, + role: 'hub', + parent: ['test:page'], + }, + toModel: () => new SurfaceBlockModel(), +}); + +export const SurfaceBlockSchemaExtension = + BlockSchemaExtension(SurfaceBlockSchema); + +export class SurfaceBlockModel extends BaseSurfaceModel { + override _init() { + this._extendElement({ + testShape: TestShapeElement, + }); + super._init(); + } +} + +type GfxTestBlockProps = { + xywh: SerializedXYWH; + rotate: number; + index: string; +} & GfxCompatibleProps; + +export const TestGfxBlockSchema = defineBlockSchema({ + flavour: 'test:gfx-block', + props: () => + ({ + xywh: '[0,0,10,10]' as SerializedXYWH, + rotate: 0, + index: 'a0', + lockedBySelf: false, + }) as GfxTestBlockProps, + metadata: { + version: 1, + role: 'content', + parent: ['test:surface'], + }, + toModel: () => new TestGfxBlockModel(), +}); + +export const TestGfxBlockSchemaExtension = + BlockSchemaExtension(TestGfxBlockSchema); + +export class TestGfxBlockModel extends GfxCompatibleBlockModel( + BlockModel +) {} diff --git a/blocksuite/framework/std/src/__tests__/test-spec.ts b/blocksuite/framework/std/src/__tests__/test-spec.ts index 3f5871481c..8f814cb4d2 100644 --- a/blocksuite/framework/std/src/__tests__/test-spec.ts +++ b/blocksuite/framework/std/src/__tests__/test-spec.ts @@ -20,4 +20,8 @@ export const testSpecs: ExtensionType[] = [ return literal`test-h2-block`; }), + + BlockViewExtension('test:surface', literal`test-surface-block`), + + BlockViewExtension('test:gfx-block', literal`test-gfx-block`), ]; diff --git a/blocksuite/integration-test/src/__tests__/edgeless/surface-model.spec.ts b/blocksuite/integration-test/src/__tests__/edgeless/surface-model.spec.ts index 596d4af24c..091f28eeb8 100644 --- a/blocksuite/integration-test/src/__tests__/edgeless/surface-model.spec.ts +++ b/blocksuite/integration-test/src/__tests__/edgeless/surface-model.spec.ts @@ -2,10 +2,8 @@ import type { SurfaceBlockModel } from '@blocksuite/affine/blocks/surface'; import type { BrushElementModel, GroupElementModel, - ShapeElementModel, } from '@blocksuite/affine/model'; -import { DefaultTheme } from '@blocksuite/affine/model'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, test } from 'vitest'; import { wait } from '../utils/common.js'; import { setupEditor } from '../utils/setup.js'; @@ -23,88 +21,19 @@ beforeEach(async () => { return cleanup; }); -describe('elements management', () => { - test('addElement should work correctly', () => { - const id = model.addElement({ - type: 'shape', - }); - - expect(model.elementModels[0].id).toBe(id); - }); - - test('removeElement should work correctly', () => { - const id = model.addElement({ - type: 'shape', - }); - - model.deleteElement(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('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(DefaultTheme.shapeStrokeColor); - expect(element.strokeWidth).toBe(4); - }); - - test('defined prop should not be overwritten by default value', () => { - const id = model.addElement({ - type: 'shape', - 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.yMap.get('strokeColor')).toBe(DefaultTheme.shapeStrokeColor); - - element.strokeColor = '--affine-palette-line-black'; - expect(element.yMap.get('strokeColor')).toBe('--affine-palette-line-black'); - expect(element.strokeColor).toBe('--affine-palette-line-black'); - }); -}); - describe('group', () => { + test('empty group should have all zero xywh', () => { + const id = model.addElement({ + type: 'group', + }); + const group = model.getElementById(id)! as GroupElementModel; + + expect(group.x).toBe(0); + expect(group.y).toBe(0); + expect(group.w).toBe(0); + expect(group.h).toBe(0); + }); + test('should get group', () => { const id = model.addElement({ type: 'shape', @@ -323,185 +252,6 @@ describe('connector', () => { }); }); -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); - - elementModel.strokeWidth = 6; - expect(elementModel.strokeWidth).toBe(6); - expect(elementModel.yMap.get('strokeWidth')).toBe(6); - }); - - test('assign stashed property should emit event', () => { - const id = model.addElement({ - type: 'shape', - strokeWidth: 4, - }); - const elementModel = model.getElementById(id)! as ShapeElementModel; - - elementModel.stash('strokeWidth'); - - const onchange = vi.fn(); - const subscription = model.elementUpdated.subscribe(({ id }) => { - subscription.unsubscribe(); - onchange(id); - }); - - elementModel.strokeWidth = 10; - expect(onchange).toHaveBeenCalledWith(id); - }); - - test('stashed property should also trigger derive decorator', () => { - const id = model.addElement({ - type: 'brush', - points: [ - [0, 0], - [100, 100], - [120, 150], - ], - }); - const elementModel = model.getElementById(id)! as BrushElementModel; - - elementModel.stash('points'); - elementModel.points = [ - [0, 0], - [50, 50], - [135, 145], - [150, 170], - [200, 180], - ]; - const points = elementModel.points; - - expect(elementModel.w).toBe(200 + elementModel.lineWidth); - expect(elementModel.h).toBe(180 + elementModel.lineWidth); - - expect(elementModel.yMap.get('points')).not.toEqual(points); - expect(elementModel.w).toBe(200 + elementModel.lineWidth); - expect(elementModel.h).toBe(180 + elementModel.lineWidth); - }); - - test('non-field property should not allow stash/pop, and should failed silently ', () => { - const id = model.addElement({ - type: 'group', - }); - const elementModel = model.getElementById(id)! as GroupElementModel; - - elementModel.stash('xywh'); - elementModel.xywh = '[10,10,200,200]'; - - expect(elementModel['_stashed'].has('xywh')).toBe(false); - - elementModel.pop('xywh'); - - expect(elementModel['_stashed'].has('xywh')).toBe(false); - expect(elementModel.yMap.has('xywh')).toBe(false); - }); -}); - -describe('derive decorator', () => { - test('derived decorator 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 decorator', () => { - test('local decorator 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(); - - const subscription = model.elementUpdated.subscribe(({ id }) => { - subscription.unsubscribe(); - onchange(id); - }); - elementModel.display = false; - - expect(elementModel.display).toBe(false); - expect(onchange).toHaveBeenCalledWith(id); - }); -}); - -describe('convert decorator', () => { - test('convert decorator', () => { - const id = model.addElement({ - type: 'brush', - points: [ - [50, 25], - [200, 200], - [300, 300], - ], - }); - const elementModel = model.getElementById(id)! as BrushElementModel; - const halfLineWidth = elementModel.lineWidth / 2; - const xOffset = 50 - halfLineWidth; - const yOffset = 25 - halfLineWidth; - - expect(elementModel.points).toEqual([ - [50 - xOffset, 25 - yOffset], - [200 - xOffset, 200 - yOffset], - [300 - xOffset, 300 - yOffset], - ]); - }); -}); - -describe('basic property', () => { - test('empty group should have all zero xywh', () => { - const id = model.addElement({ - type: 'group', - }); - const group = model.getElementById(id)! as GroupElementModel; - - expect(group.x).toBe(0); - expect(group.y).toBe(0); - expect(group.w).toBe(0); - expect(group.h).toBe(0); - }); -}); - describe('brush', () => { test('same lineWidth should have same xywh', () => { const id = model.addElement({