import type { EdgelessRootBlockComponent } from '@blocksuite/affine/blocks/root'; import type { CanvasRenderer, SurfaceElementModel, } from '@blocksuite/affine/blocks/surface'; import { ungroupCommand } from '@blocksuite/affine/gfx/group'; import type { GroupElementModel, NoteBlockModel, } from '@blocksuite/affine/model'; import { generateKeyBetween } from '@blocksuite/affine/std/gfx'; import type { BlockComponent } from '@blocksuite/std'; import type { BlockModel, Store } from '@blocksuite/store'; import { beforeEach, describe, expect, test } from 'vitest'; import * as Y from 'yjs'; import { wait } from '../utils/common.js'; import { addNote as _addNote, getDocRootBlock, getSurface, } from '../utils/edgeless.js'; import { setupEditor } from '../utils/setup.js'; let service!: EdgelessRootBlockComponent['service']; const addNote = (doc: Store, props: Record = {}) => { return _addNote(doc, { index: service.layer.generateIndex(), ...props, }); }; beforeEach(async () => { const cleanup = await setupEditor('edgeless'); service = getDocRootBlock(window.doc, window.editor, 'edgeless').service; return async () => { await wait(100); cleanup(); }; }); test('layer manager initial state', () => { expect(service.layer).toBeDefined(); expect(service.layer.layers.length).toBe(0); expect(service.layer.canvasLayers.length).toBe(1); }); describe('add new edgeless blocks or canvas elements should update layer automatically', () => { test('add note, note, shape sequentially', async () => { addNote(doc); addNote(doc); service.crud.addElement('shape', { shapeType: 'rect', }); await wait(); expect(service.layer.layers.length).toBe(2); }); test('add note, shape, note sequentially', async () => { addNote(doc); service.crud.addElement('shape', { shapeType: 'rect', }); addNote(doc); await wait(); expect(service.layer.layers.length).toBe(3); }); }); test('delete element should update layer automatically', () => { const id = addNote(doc); const canvasElId = service.crud.addElement('shape', { shapeType: 'rect', }); service.removeElement(id); expect(service.layer.layers.length).toBe(1); service.removeElement(canvasElId!); expect(service.layer.layers.length).toBe(0); }); test('change element should update layer automatically', async () => { const id = addNote(doc); const canvasElId = service.crud.addElement('shape', { shapeType: 'rect', }); await wait(); service.crud.updateElement(id, { index: service.layer.getReorderedIndex( service.crud.getElementById(id)!, 'forward' ), }); expect(service.layer.layers[service.layer.layers.length - 1].type).toBe( 'block' ); service.crud.updateElement(canvasElId!, { index: service.layer.getReorderedIndex( service.crud.getElementById(canvasElId!)!, 'forward' ), }); expect(service.layer.layers[service.layer.layers.length - 1].type).toBe( 'canvas' ); }); test('new added canvas elements should be placed in the topmost canvas layer', async () => { addNote(doc); service.crud.addElement('shape', { shapeType: 'rect', }); await wait(); expect(service.layer.layers.length).toBe(2); expect(service.layer.layers[1].type).toBe('canvas'); }); test("there should be at lease one layer in canvasLayers property even there's no canvas element", () => { addNote(doc); expect(service.layer.canvasLayers.length).toBe(1); }); test('if the topmost layer is canvas layer, the length of canvasLayers array should equal to the counts of canvas layers', () => { addNote(doc); service.crud.addElement('shape', { shapeType: 'rect', }); addNote(doc); service.crud.addElement('shape', { shapeType: 'rect', }); expect(service.layer.layers.length).toBe(4); expect(service.layer.canvasLayers.length).toBe( service.layer.layers.filter(layer => layer.type === 'canvas').length ); }); test('a new layer should be created in canvasLayers prop when the topmost layer is not canvas layer', () => { service.crud.addElement('shape', { shapeType: 'rect', }); addNote(doc); service.crud.addElement('shape', { shapeType: 'rect', }); addNote(doc); expect(service.layer.canvasLayers.length).toBe(3); }); test('layer zindex should update correctly when elements changed', async () => { addNote(doc); const noteId = addNote(doc); const note = service.crud.getElementById(noteId); addNote(doc); service.crud.addElement('shape', { shapeType: 'rect', }); const topShapeId = service.crud.addElement('shape', { shapeType: 'rect', }); const topShape = service.crud.getElementById(topShapeId!)!; await wait(); const assertInitialState = () => { expect(service.layer.layers[0].type).toBe('block'); expect(service.layer.layers[0].zIndex).toBe(1); expect(service.layer.layers[1].type).toBe('canvas'); expect(service.layer.layers[1].zIndex).toBe(4); }; assertInitialState(); service.doc.captureSync(); service.crud.updateElement(noteId, { index: service.layer.getReorderedIndex(note!, 'front'), }); await wait(); service.doc.captureSync(); const assert2StepState = () => { expect(service.layer.layers[1].type).toBe('canvas'); expect(service.layer.layers[1].zIndex).toBe(3); expect(service.layer.layers[2].type).toBe('block'); expect(service.layer.layers[2].zIndex).toBe(4); }; assert2StepState(); service.crud.updateElement(topShapeId!, { index: service.layer.getReorderedIndex(topShape!, 'front'), }); await wait(); service.doc.captureSync(); expect(service.layer.layers[3].type).toBe('canvas'); expect(service.layer.layers[3].zIndex).toBe(5); service.doc.undo(); await wait(); assert2StepState(); service.doc.undo(); await wait(); assertInitialState(); }); test('blocks should rerender when their z-index changed', async () => { const blocks = [addNote(doc), addNote(doc), addNote(doc), addNote(doc)]; const assertBlocksContent = () => { const blocks = Array.from( document.querySelectorAll( 'affine-edgeless-root gfx-viewport > [data-block-id]' ) ); expect(blocks.length).toBe(5); blocks.forEach(element => { expect(element.children.length).toBeGreaterThan(0); }); }; await wait(); assertBlocksContent(); service.crud.addElement('shape', { shapeType: 'rect', index: generateKeyBetween( service.crud.getElementById(blocks[1])!.index, service.crud.getElementById(blocks[2])!.index ), }); await wait(); assertBlocksContent(); }); describe('layer reorder functionality', () => { let ids: string[] = []; beforeEach(() => { ids = [ service.crud.addElement('shape', { shapeType: 'rect', })!, addNote(doc), service.crud.addElement('shape', { shapeType: 'rect', })!, addNote(doc), ]; }); test('forward', async () => { service.crud.updateElement(ids[0], { index: service.layer.getReorderedIndex( service.crud.getElementById(ids[0])!, 'forward' ), }); expect( service.layer.layers.findIndex(layer => layer.set.has(service.crud.getElementById(ids[0]) as any) ) ).toBe(1); expect( service.layer.layers.findIndex(layer => layer.set.has(service.crud.getElementById(ids[1]) as any) ) ).toBe(0); await wait(); service.crud.updateElement(ids[1], { index: service.layer.getReorderedIndex( service.crud.getElementById(ids[1])!, 'forward' ), }); expect( service.layer.layers.findIndex(layer => layer.set.has(service.crud.getElementById(ids[0]) as any) ) ).toBe(0); expect( service.layer.layers.findIndex(layer => layer.set.has(service.crud.getElementById(ids[1]) as any) ) ).toBe(1); }); test('front', async () => { service.crud.updateElement(ids[0], { index: service.layer.getReorderedIndex( service.crud.getElementById(ids[0])!, 'front' ), }); await wait(); expect( service.layer.layers.findIndex(layer => layer.set.has(service.crud.getElementById(ids[0]) as any) ) ).toBe(3); service.crud.updateElement(ids[1], { index: service.layer.getReorderedIndex( service.crud.getElementById(ids[1])!, 'front' ), }); expect( service.layer.layers.findIndex(layer => layer.set.has(service.crud.getElementById(ids[1]) as any) ) ).toBe(3); }); test('backward', async () => { service.crud.updateElement(ids[3], { index: service.layer.getReorderedIndex( service.crud.getElementById(ids[3])!, 'backward' ), }); expect( service.layer.layers.findIndex(layer => layer.set.has(service.crud.getElementById(ids[3]) as any) ) ).toBe(1); expect( service.layer.layers.findIndex(layer => layer.set.has(service.crud.getElementById(ids[2]) as any) ) ).toBe(2); await wait(); service.crud.updateElement(ids[2], { index: service.layer.getReorderedIndex( service.crud.getElementById(ids[2])!, 'backward' ), }); expect( service.layer.layers.findIndex(layer => layer.set.has(service.crud.getElementById(ids[3]) as any) ) ).toBe(3); expect( service.layer.layers.findIndex(layer => layer.set.has(service.crud.getElementById(ids[2]) as any) ) ).toBe(2); }); test('back', async () => { service.crud.updateElement(ids[3], { index: service.layer.getReorderedIndex( service.crud.getElementById(ids[3])!, 'back' ), }); expect( service.layer.layers.findIndex(layer => layer.set.has(service.crud.getElementById(ids[3]) as any) ) ).toBe(0); await wait(); service.crud.updateElement(ids[2], { index: service.layer.getReorderedIndex( service.crud.getElementById(ids[2])!, 'back' ), }); expect( service.layer.layers.findIndex(layer => layer.set.has(service.crud.getElementById(ids[2]) as any) ) ).toBe(0); }); }); describe('group related functionality', () => { const createGroup = ( service: EdgelessRootBlockComponent['service'], childIds: string[] ) => { const children = new Y.Map(); childIds.forEach(id => children.set(id, true)); return service.crud.addElement('group', { children, }); }; test("new added group should effect it children's layer", async () => { const edgeless = getDocRootBlock(doc, editor, 'edgeless'); const elements = [ service.crud.addElement('shape', { shapeType: 'rect', })!, addNote(doc), service.crud.addElement('shape', { shapeType: 'rect', })!, addNote(doc), service.crud.addElement('shape', { shapeType: 'rect', })!, ]; await wait(0); expect( edgeless.querySelectorAll('.indexable-canvas').length ).toBe(2); Array.from( edgeless.querySelectorAll('.indexable-canvas') ).forEach(canvas => { const rect = canvas.getBoundingClientRect(); expect(rect.width).toBeGreaterThan(0); expect(rect.height).toBeGreaterThan(0); }); createGroup( service, elements.filter((_, idx) => idx !== 1 && idx !== 3) ); expect(service.layer.layers.length).toBe(2); expect(service.layer.layers[0].type).toBe('block'); expect(service.layer.layers[0].set.size).toBe(2); expect(service.layer.layers[1].type).toBe('canvas'); expect(service.layer.layers[1].set.size).toBe(4); expect( edgeless.querySelectorAll('.indexable-canvas').length ).toBe(0); const topCanvas = edgeless.querySelector( 'affine-surface canvas' ) as HTMLCanvasElement; expect( Number( ( edgeless.querySelector( `[data-block-id="${elements[1]}"]` ) as HTMLElement ).style.zIndex ) ).toBeLessThan(Number(topCanvas.style.zIndex)); expect( Number( ( edgeless.querySelector( `[data-block-id="${elements[3]}"]` ) as HTMLElement ).style.zIndex ) ).toBeLessThan(Number(topCanvas.style.zIndex)); }); test("change group index should update its children's layer", () => { const elements = [ service.crud.addElement('shape', { shapeType: 'rect', })!, addNote(doc), service.crud.addElement('shape', { shapeType: 'rect', })!, addNote(doc), service.crud.addElement('shape', { shapeType: 'rect', })!, ]; const groupId = createGroup( service, elements.filter((_, idx) => idx !== 1 && idx !== 3) )!; const group = service.crud.getElementById(groupId)!; expect(service.layer.layers.length).toBe(2); group.index = service.layer.getReorderedIndex(group, 'back'); expect(service.layer.layers[0].type).toBe('canvas'); expect(service.layer.layers[0].set.size).toBe(4); expect(service.layer.layers[0].elements[0]).toBe(group); group.index = service.layer.getReorderedIndex(group, 'front'); expect(service.layer.layers[1].type).toBe('canvas'); expect(service.layer.layers[1].set.size).toBe(4); expect(service.layer.layers[1].elements[0]).toBe(group); }); test('should keep relative index order of elements after group, ungroup, undo, redo', () => { const edgeless = getDocRootBlock(doc, editor, 'edgeless'); const elementIds = [ service.crud.addElement('shape', { shapeType: 'rect', })!, addNote(doc), service.crud.addElement('shape', { shapeType: 'rect', })!, addNote(doc), service.crud.addElement('shape', { shapeType: 'rect', })!, ]; service.doc.captureSync(); const elements = elementIds.map(id => service.crud.getElementById(id)!); const isKeptRelativeOrder = () => { return elements.every((element, idx) => { if (idx === 0) return true; return elements[idx - 1].index < element.index; }); }; expect(isKeptRelativeOrder()).toBeTruthy(); const groupId = createGroup(edgeless.service, elementIds)!; expect(isKeptRelativeOrder()).toBeTruthy(); service.std.command.exec(ungroupCommand, { group: service.crud.getElementById(groupId) as GroupElementModel, }); expect(isKeptRelativeOrder()).toBeTruthy(); service.doc.undo(); expect(isKeptRelativeOrder()).toBeTruthy(); service.doc.redo(); expect(isKeptRelativeOrder()).toBeTruthy(); }); }); describe('compare function', () => { const SORT_ORDER = { AFTER: 1, BEFORE: -1, SAME: 0, }; const createGroup = ( service: EdgelessRootBlockComponent['service'], childIds: string[] // eslint-disable-next-line sonarjs/no-identical-functions ) => { const children = new Y.Map(); childIds.forEach(id => children.set(id, true)); return service.crud.addElement('group', { children, }); }; test('compare same element', () => { const shapeId = service.crud.addElement('shape', { shapeType: 'rect', })!; const shapeEl = service.crud.getElementById(shapeId)!; expect(service.layer.compare(shapeEl, shapeEl)).toBe(SORT_ORDER.SAME); const groupId = createGroup(service, [shapeId])!; const groupEl = service.crud.getElementById(groupId)!; expect(service.layer.compare(groupEl, groupEl)).toBe(SORT_ORDER.SAME); const noteId = addNote(doc); const note = service.crud.getElementById(noteId)! as NoteBlockModel; expect(service.layer.compare(note, note)).toBe(SORT_ORDER.SAME); }); test('compare a group and its child', () => { const shapeId = service.crud.addElement('shape', { shapeType: 'rect', })!; const shapeEl = service.crud.getElementById(shapeId)!; const noteId = addNote(doc); const note = service.crud.getElementById(noteId)! as NoteBlockModel; const groupId = createGroup(service, [shapeId, noteId])!; const groupEl = service.crud.getElementById(groupId)!; expect(service.layer.compare(groupEl, shapeEl)).toBe(SORT_ORDER.BEFORE); expect(service.layer.compare(shapeEl, groupEl)).toBe(SORT_ORDER.AFTER); expect(service.layer.compare(groupEl, note)).toBe(SORT_ORDER.BEFORE); expect(service.layer.compare(note, groupEl)).toBe(SORT_ORDER.AFTER); }); test('compare two different elements', () => { const shape1Id = service.crud.addElement('shape', { shapeType: 'rect', })!; const shape1 = service.crud.getElementById(shape1Id)!; const shape2Id = service.crud.addElement('shape', { shapeType: 'rect', })!; const shape2 = service.crud.getElementById(shape2Id)!; const note1Id = addNote(doc); const note1 = service.crud.getElementById(note1Id)! as NoteBlockModel; const note2Id = addNote(doc); const note2 = service.crud.getElementById(note2Id)! as NoteBlockModel; expect(service.layer.compare(shape1, shape2)).toBe(SORT_ORDER.BEFORE); expect(service.layer.compare(shape2, shape1)).toBe(SORT_ORDER.AFTER); expect(service.layer.compare(note1, note2)).toBe(SORT_ORDER.BEFORE); expect(service.layer.compare(note2, note1)).toBe(SORT_ORDER.AFTER); expect(service.layer.compare(shape1, note1)).toBe(SORT_ORDER.BEFORE); expect(service.layer.compare(note1, shape1)).toBe(SORT_ORDER.AFTER); expect(service.layer.compare(shape2, note2)).toBe(SORT_ORDER.BEFORE); expect(service.layer.compare(note2, shape2)).toBe(SORT_ORDER.AFTER); }); test('compare nested elements', () => { const shape1Id = service.crud.addElement('shape', { shapeType: 'rect', })!; const shape2Id = service.crud.addElement('shape', { shapeType: 'rect', })!; const note1Id = addNote(doc); const note2Id = addNote(doc); const group1Id = createGroup(service, [ shape1Id, shape2Id, note1Id, note2Id, ])!; const group2Id = createGroup(service, [group1Id])!; const shape1 = service.crud.getElementById(shape1Id)!; const shape2 = service.crud.getElementById(shape2Id)!; const note1 = service.crud.getElementById(note1Id)! as NoteBlockModel; const note2 = service.crud.getElementById(note2Id)! as NoteBlockModel; const group1 = service.crud.getElementById(group1Id)!; const group2 = service.crud.getElementById(group2Id)!; // assert nested group to group expect(service.layer.compare(group2, group1)).toBe(SORT_ORDER.BEFORE); expect(service.layer.compare(group1, group2)).toBe(SORT_ORDER.AFTER); // assert element in the same group expect(service.layer.compare(shape1, shape2)).toBe(SORT_ORDER.BEFORE); expect(service.layer.compare(shape2, shape1)).toBe(SORT_ORDER.AFTER); expect(service.layer.compare(note1, note2)).toBe(SORT_ORDER.BEFORE); expect(service.layer.compare(note2, note1)).toBe(SORT_ORDER.AFTER); // assert group and its nested element expect(service.layer.compare(group2, shape1)).toBe(SORT_ORDER.BEFORE); expect(service.layer.compare(shape1, group2)).toBe(SORT_ORDER.AFTER); expect(service.layer.compare(group1, shape2)).toBe(SORT_ORDER.BEFORE); expect(service.layer.compare(shape2, group1)).toBe(SORT_ORDER.AFTER); expect(service.layer.compare(group2, note1)).toBe(SORT_ORDER.BEFORE); expect(service.layer.compare(note1, group2)).toBe(SORT_ORDER.AFTER); expect(service.layer.compare(group1, note2)).toBe(SORT_ORDER.BEFORE); expect(service.layer.compare(note2, group1)).toBe(SORT_ORDER.AFTER); }); test('compare two nested elements', () => { const groupAShapeId = service.crud.addElement('shape', { shapeType: 'rect', })!; const groupANoteId = addNote(doc); const groupAId = createGroup(service, [ createGroup(service, [groupAShapeId, groupANoteId])!, ])!; const groupAShape = service.crud.getElementById(groupAShapeId)!; const groupANote = service.crud.getElementById(groupANoteId)!; const groupA = service.crud.getElementById(groupAId)!; const groupBShapeId = service.crud.addElement('shape', { shapeType: 'rect', })!; const groupBNoteId = addNote(doc); const groupBId = createGroup(service, [ createGroup(service, [groupBShapeId, groupBNoteId])!, ])!; const groupBShape = service.crud.getElementById(groupBShapeId)!; const groupBNote = service.crud.getElementById(groupBNoteId)!; const groupB = service.crud.getElementById(groupBId)!; expect(service.layer.compare(groupAShape, groupBShape)).toBe( SORT_ORDER.BEFORE ); expect(service.layer.compare(groupBShape, groupAShape)).toBe( SORT_ORDER.AFTER ); expect(service.layer.compare(groupANote, groupBNote)).toBe( SORT_ORDER.BEFORE ); expect(service.layer.compare(groupBNote, groupANote)).toBe( SORT_ORDER.AFTER ); expect(service.layer.compare(groupB, groupA)).toBe(SORT_ORDER.AFTER); groupB.index = service.layer.getReorderedIndex(groupB, 'back'); expect(service.layer.compare(groupB, groupA)).toBe(SORT_ORDER.BEFORE); expect(service.layer.compare(groupAShape, groupBShape)).toBe( SORT_ORDER.AFTER ); expect(service.layer.compare(groupBShape, groupAShape)).toBe( SORT_ORDER.BEFORE ); expect(service.layer.compare(groupANote, groupBNote)).toBe( SORT_ORDER.AFTER ); expect(service.layer.compare(groupBNote, groupANote)).toBe( SORT_ORDER.BEFORE ); groupA.index = service.layer.getReorderedIndex(groupA, 'back'); expect(service.layer.compare(groupA, groupB)).toBe(SORT_ORDER.BEFORE); expect(service.layer.compare(groupAShape, groupBShape)).toBe( SORT_ORDER.BEFORE ); expect(service.layer.compare(groupBShape, groupAShape)).toBe( SORT_ORDER.AFTER ); expect(service.layer.compare(groupANote, groupBNote)).toBe( SORT_ORDER.BEFORE ); expect(service.layer.compare(groupBNote, groupANote)).toBe( SORT_ORDER.AFTER ); }); }); test('indexed canvas should be inserted into edgeless portal when switch to edgeless mode', async () => { let surface = getSurface(doc, editor); service.crud.addElement('shape', { shapeType: 'rect', })!; addNote(doc); await wait(); service.crud.addElement('shape', { shapeType: 'rect', })!; editor.mode = 'page'; await wait(); editor.mode = 'edgeless'; await wait(); surface = getSurface(doc, editor); const edgeless = getDocRootBlock(doc, editor, 'edgeless'); expect(edgeless.querySelectorAll('.indexable-canvas').length).toBe(1); const indexedCanvas = edgeless.querySelectorAll( '.indexable-canvas' )[0] as HTMLCanvasElement; expect(indexedCanvas.width).toBe( (surface.renderer as CanvasRenderer).canvas.width ); expect(indexedCanvas.height).toBe( (surface.renderer as CanvasRenderer).canvas.height ); expect(indexedCanvas.width).not.toBe(0); expect(indexedCanvas.height).not.toBe(0); }); test('the actual rendering z-index should satisfy the logic order of their indexes', async () => { editor.mode = 'page'; await wait(); const indexes = [ 'ao', 'b0D', 'ar', 'as', 'at', 'au', 'av', 'b0Y', 'b0V', 'b0H', 'b0M', 'b0T', 'b0f', 'b0fV', 'b0g', 'b0i', 'b0fl', ]; indexes.forEach(index => { addNote(doc, { index, }); }); await wait(); editor.mode = 'edgeless'; await wait(500); const edgeless = getDocRootBlock(doc, editor, 'edgeless'); const blocks = Array.from( edgeless.querySelectorAll('gfx-viewport > [data-block-id]') ) as BlockComponent[]; expect(blocks.length).toBe(indexes.length + 1); blocks .filter(block => block.flavour !== 'affine:surface') .forEach((block, index) => { if (index === blocks.length - 1) return; const model = block.model as BlockModel<{ index: string }>; const nextModel = blocks[index + 1].model as BlockModel<{ index: string; }>; const zIndex = Number(block.style.zIndex); const nextZIndex = Number(blocks[index + 1].style.zIndex); expect(model.props.index <= nextModel.props.index).equals( zIndex <= nextZIndex ); }); }); describe('index generator', () => { let preinsertedShape: SurfaceElementModel; let preinsertedNote: NoteBlockModel; beforeEach(() => { const shapeId = service.crud.addElement('shape', { shapeType: 'rect', })!; const noteId = addNote(doc); preinsertedShape = service.crud.getElementById( shapeId )! as SurfaceElementModel; preinsertedNote = service.crud.getElementById(noteId)! as NoteBlockModel; }); test('generator should remember the index it generated', () => { const generator = service.layer.createIndexGenerator(); const shape1 = generator(); const block1 = generator(); const shape2 = generator(); const block2 = generator(); expect(block2 > shape2).toBeTruthy(); expect(shape2 > block1).toBeTruthy(); expect(block1 > shape1).toBeTruthy(); expect(shape1 > preinsertedNote.index).toBeTruthy(); expect(shape1 > preinsertedShape.index).toBeTruthy(); }); });