refactor(editor): rename presets to integration test (#10340)

This commit is contained in:
Saul-Mirone
2025-02-21 06:26:03 +00:00
parent f79324b6a1
commit f3218ab3bc
116 changed files with 156 additions and 210 deletions
@@ -0,0 +1,153 @@
import { LocalShapeElementModel } from '@blocksuite/affine-model';
import { Text } from '@blocksuite/store';
import { beforeEach, describe, expect, test } from 'vitest';
import { addNote, getSurface } from '../utils/edgeless.js';
import { setupEditor } from '../utils/setup.js';
beforeEach(async () => {
const cleanup = await setupEditor('edgeless');
return cleanup;
});
test('basic assert', () => {
expect(window.doc).toBeDefined();
expect(window.editor).toBeDefined();
expect(window.editor.mode).toBe('edgeless');
expect(getSurface(window.doc, window.editor)).toBeDefined();
});
describe('doc / note empty checker', () => {
test('a paragraph is empty if it dose not contain text and child blocks', () => {
const noteId = addNote(doc);
const paragraphId = doc.addBlock('affine:paragraph', {}, noteId);
const paragraph = doc.getBlock(paragraphId)?.model;
expect(paragraph?.isEmpty()).toBe(true);
});
test('a paragraph is not empty if it contains text', () => {
const noteId = addNote(doc);
const paragraphId = doc.addBlock(
'affine:paragraph',
{
text: new Text('hello'),
},
noteId
);
const paragraph = doc.getBlock(paragraphId)?.model;
expect(paragraph?.isEmpty()).toBe(false);
});
test('a paragraph is not empty if it contains children blocks', () => {
const noteId = addNote(doc);
const paragraphId = doc.addBlock('affine:paragraph', {}, noteId);
const paragraph = doc.getBlock(paragraphId)?.model;
// sub paragraph
doc.addBlock('affine:paragraph', {}, paragraphId);
expect(paragraph?.isEmpty()).toBe(false);
});
test('a note is empty if it dose not contain any blocks', () => {
const noteId = addNote(doc);
const note = doc.getBlock(noteId)!.model;
note.children.forEach(child => {
doc.deleteBlock(child);
});
expect(note.children.length).toBe(0);
expect(note.isEmpty()).toBe(true);
});
test('a note is empty if it only contains a empty paragraph', () => {
// `addNote` will create a empty paragraph
const noteId = addNote(doc);
const note = doc.getBlock(noteId)!.model;
expect(note.isEmpty()).toBe(true);
});
test('a note is not empty if it contains multi blocks', () => {
const noteId = addNote(doc);
const note = doc.getBlock(noteId)!.model;
doc.addBlock('affine:paragraph', {}, noteId);
expect(note.isEmpty()).toBe(false);
});
test('a surface is empty if it dose not contains any element or blocks', () => {
const surface = getSurface(doc, editor).model;
expect(surface.isEmpty()).toBe(true);
const shapeId = surface.addElement({
type: 'shape',
});
expect(surface.isEmpty()).toBe(false);
surface.deleteElement(shapeId);
expect(surface.isEmpty()).toBe(true);
const frameId = doc.addBlock('affine:frame', {}, surface.id);
const frame = doc.getBlock(frameId)!.model;
expect(surface.isEmpty()).toBe(false);
doc.deleteBlock(frame);
expect(surface.isEmpty()).toBe(true);
});
test('a surface is empty if it only contains local elements', () => {
const surface = getSurface(doc, editor).model;
const localShape = new LocalShapeElementModel(surface);
surface.addLocalElement(localShape);
expect(surface.isEmpty()).toBe(true);
});
test('a just initialized doc is empty', () => {
expect(doc.isEmpty).toBe(true);
expect(editor.rootModel.isEmpty()).toBe(true);
});
test('a doc is empty if it only contains a note', () => {
addNote(doc);
expect(doc.isEmpty).toBe(true);
addNote(doc);
expect(
doc.isEmpty,
'a doc is not empty if it contains multi-notes'
).toBeFalsy();
});
test('a note is empty if its children array is empty', () => {
const noteId = addNote(doc);
const note = doc.getBlock(noteId)?.model;
note?.children.forEach(child => doc.deleteBlock(child));
expect(note?.children.length === 0).toBe(true);
expect(note?.isEmpty()).toBe(true);
});
test('a doc is empty if its only contains an empty note and an empty surface', () => {
const noteId = addNote(doc);
const note = doc.getBlock(noteId)!.model;
expect(doc.isEmpty).toBe(true);
const newNoteId = addNote(doc);
const newNote = doc.getBlock(newNoteId)!.model;
expect(doc.isEmpty).toBe(false);
doc.deleteBlock(newNote);
expect(doc.isEmpty).toBe(true);
const newParagraphId = doc.addBlock('affine:paragraph', {}, note);
const newParagraph = doc.getBlock(newParagraphId)!.model;
expect(doc.isEmpty).toBe(false);
doc.deleteBlock(newParagraph);
expect(doc.isEmpty).toBe(true);
const surface = getSurface(doc, editor).model;
expect(doc.isEmpty).toBe(true);
const shapeId = surface.addElement({
type: 'shape',
});
expect(doc.isEmpty).toBe(false);
surface.deleteElement(shapeId);
expect(doc.isEmpty).toBe(true);
});
});
@@ -0,0 +1,113 @@
import '@toeverything/theme/style.css';
import {
ColorScheme,
type EdgelessRootBlockComponent,
ThemeProvider,
} from '@blocksuite/blocks';
import { beforeEach, describe, expect, test } from 'vitest';
import { getDocRootBlock } from '../utils/edgeless.js';
import { setupEditor } from '../utils/setup.js';
describe('theme service', () => {
let edgeless!: EdgelessRootBlockComponent;
beforeEach(async () => {
const cleanup = await setupEditor('edgeless');
edgeless = getDocRootBlock(doc, editor, 'edgeless');
edgeless.gfx.tool.setTool('default');
const themeService = edgeless.gfx.std.get(ThemeProvider);
themeService.theme$.value = ColorScheme.Light;
return cleanup;
});
test('switches theme', () => {
const themeService = edgeless.gfx.std.get(ThemeProvider);
expect(themeService.theme).toBe(ColorScheme.Light);
themeService.theme$.value = ColorScheme.Dark;
expect(themeService.theme).toBe(ColorScheme.Dark);
themeService.theme$.value = ColorScheme.Light;
expect(themeService.theme).toBe(ColorScheme.Light);
});
test('generates a color property', () => {
const themeService = edgeless.gfx.std.get(ThemeProvider);
expect(themeService.theme).toBe(ColorScheme.Light);
expect(themeService.generateColorProperty('--affine-hover-color')).toBe(
'var(--affine-hover-color)'
);
expect(themeService.generateColorProperty('--affine-transparent')).toBe(
'transparent'
);
expect(themeService.generateColorProperty('transparent')).toBe(
'transparent'
);
expect(
themeService.generateColorProperty({ dark: 'white', light: 'black' })
).toBe('black');
themeService.theme$.value = ColorScheme.Dark;
expect(themeService.theme).toBe(ColorScheme.Dark);
expect(
themeService.generateColorProperty({ dark: 'white', light: 'black' })
).toBe('white');
expect(themeService.generateColorProperty({ normal: 'grey' })).toBe('grey');
expect(themeService.generateColorProperty('', 'blue')).toBe('blue');
});
test('gets a color value', () => {
const themeService = edgeless.gfx.std.get(ThemeProvider);
expect(themeService.theme).toBe(ColorScheme.Light);
expect(themeService.getColorValue('--affine-transparent')).toBe(
'--affine-transparent'
);
expect(
themeService.getColorValue('--affine-transparent', 'transparent', true)
).toBe('transparent');
expect(
themeService.getColorValue('--affine-hover-color', 'transparent', true)
).toBe('rgba(0, 0, 0, 0.04)');
expect(
themeService.getColorValue('--affine-tooltip', undefined, true)
).toBe('rgba(0, 0, 0, 1)');
expect(
themeService.getColorValue(
{ dark: 'white', light: 'black' },
undefined,
true
)
).toBe('black');
expect(
themeService.getColorValue({ dark: 'white', light: '' }, undefined, true)
).toBe('transparent');
expect(
themeService.getColorValue({ normal: 'grey' }, undefined, true)
).toBe('grey');
themeService.theme$.value = ColorScheme.Dark;
expect(themeService.theme).toBe(ColorScheme.Dark);
expect(
themeService.getColorValue('--affine-hover-color', 'transparent', true)
).toEqual('rgba(255, 255, 255, 0.1)');
expect(
themeService.getColorValue('--affine-tooltip', undefined, true)
).toEqual('rgba(234, 234, 234, 1)'); // #eaeaea
});
});
@@ -0,0 +1,128 @@
import type {
AffineFrameTitleWidget,
EdgelessRootBlockComponent,
FrameBlockComponent,
FrameBlockModel,
} from '@blocksuite/blocks';
import { assertType } from '@blocksuite/global/utils';
import { Text } from '@blocksuite/store';
import { beforeEach, describe, expect, test } from 'vitest';
import { wait } from '../utils/common.js';
import { getDocRootBlock } from '../utils/edgeless.js';
import { setupEditor } from '../utils/setup.js';
describe('frame', () => {
let service!: EdgelessRootBlockComponent['service'];
beforeEach(async () => {
const cleanup = await setupEditor('edgeless');
service = getDocRootBlock(window.doc, window.editor, 'edgeless').service;
return cleanup;
});
test('frame should have title', async () => {
const frame = service.doc.addBlock(
'affine:frame',
{
xywh: '[0,0,300,300]',
title: new Text('Frame 1'),
},
service.surface.id
);
await wait();
const frameTitleWidget = service.std.view.getWidget(
'affine-frame-title-widget',
doc.root!.id
) as AffineFrameTitleWidget | null;
const frameTitle = frameTitleWidget?.getFrameTitle(frame);
const rect = frameTitle?.getBoundingClientRect();
expect(frameTitle).toBeTruthy();
expect(rect).toBeTruthy();
expect(rect!.width).toBeGreaterThan(0);
expect(rect!.height).toBeGreaterThan(0);
const [titleX, titleY] = service.viewport.toModelCoord(rect!.x, rect!.y);
expect(titleX).toBeCloseTo(0);
expect(titleY).toBeLessThan(0);
const nestedFrame = service.doc.addBlock(
'affine:frame',
{
xywh: '[20,20,200,200]',
title: new Text('Frame 2'),
},
service.surface.id
);
await wait();
const nestedTitle = frameTitleWidget?.getFrameTitle(nestedFrame);
expect(nestedTitle).toBeTruthy();
if (!nestedTitle) return;
const nestedTitleRect = nestedTitle.getBoundingClientRect()!;
const [nestedTitleX, nestedTitleY] = service.viewport.toModelCoord(
nestedTitleRect.x,
nestedTitleRect.y
);
expect(nestedTitleX).toBeGreaterThan(20);
expect(nestedTitleY).toBeGreaterThan(20);
});
test('frame should have externalXYWH after moving viewport to contains frame', async () => {
const frameId = service.doc.addBlock(
'affine:frame',
{
xywh: '[1800,1800,200,200]',
title: new Text('Frame 1'),
},
service.surface.id
);
await wait();
const frame = service.doc.getBlock(frameId);
expect(frame).toBeTruthy();
assertType<FrameBlockComponent>(frame);
service.viewport.setCenter(900, 900);
expect(frame?.model.externalXYWH).toBeDefined();
});
test('descendant of frame should not contain itself', async () => {
const frameIds = [1, 2, 3].map(i => {
return service.doc.addBlock(
'affine:frame',
{
xywh: '[0,0,300,300]',
title: new Text(`Frame ${i}`),
},
service.surface.id
);
});
await wait();
const frames = frameIds.map(
id => service.doc.getBlock(id)?.model as FrameBlockModel
);
frames.forEach(frame => {
expect(frame.descendantElements).toHaveLength(0);
});
frames[0].addChild(frames[1]);
frames[1].addChild(frames[2]);
frames[2].addChild(frames[0]);
await wait();
expect(frames[0].descendantElements).toHaveLength(2);
expect(frames[1].descendantElements).toHaveLength(1);
expect(frames[2].descendantElements).toHaveLength(0);
});
});
@@ -0,0 +1,377 @@
import type { MindmapElementModel } from '@blocksuite/affine-model';
import {
type EdgelessRootBlockComponent,
type GroupElementModel,
LayoutType,
NoteDisplayMode,
} from '@blocksuite/blocks';
import { assertExists } from '@blocksuite/global/utils';
import { beforeEach, describe, expect, test } from 'vitest';
import * as Y from 'yjs';
import { wait } from '../utils/common.js';
import { addNote, getDocRootBlock } from '../utils/edgeless.js';
import { setupEditor } from '../utils/setup.js';
describe('group', () => {
let service!: EdgelessRootBlockComponent['service'];
beforeEach(async () => {
const cleanup = await setupEditor('edgeless');
service = getDocRootBlock(window.doc, window.editor, 'edgeless').service;
return cleanup;
});
test('group with no children will be removed automatically', () => {
const map = new Y.Map<boolean>();
const ids = Array.from({ length: 2 })
.map(() => {
const id = service.crud.addElement('shape', {
shapeType: 'rect',
})!;
map.set(id, true);
return id;
})
.concat(
Array.from({ length: 2 }).map(() => {
const id = addNote(doc);
map.set(id, true);
return id;
})
);
service.crud.addElement('group', { children: map });
doc.captureSync();
expect(service.elements.length).toBe(3);
service.removeElement(ids[0]);
service.removeElement(ids[1]);
doc.captureSync();
expect(service.elements.length).toBe(1);
service.removeElement(ids[2]);
service.removeElement(ids[3]);
doc.captureSync();
expect(service.elements.length).toBe(0);
doc.undo();
expect(service.elements.length).toBe(1);
doc.redo();
expect(service.elements.length).toBe(0);
});
test('remove group should remove its children at the same time', () => {
const map = new Y.Map<boolean>();
const doc = service.doc;
const noteId = addNote(doc);
const shapeId = service.crud.addElement('shape', {
shapeType: 'rect',
});
assertExists(shapeId);
map.set(noteId, true);
map.set(shapeId, true);
const groupId = service.crud.addElement('group', { children: map });
assertExists(groupId);
expect(service.elements.length).toBe(2);
expect(doc.getBlock(noteId)).toBeDefined();
doc.captureSync();
service.removeElement(groupId);
expect(service.elements.length).toBe(0);
expect(doc.getBlock(noteId)).toBeUndefined();
doc.undo();
expect(doc.getBlock(noteId)).toBeDefined();
expect(service.elements.length).toBe(2);
});
test("group's xywh should update automatically when children change", async () => {
const shape1 = service.crud.addElement('shape', {
shapeType: 'rect',
xywh: '[0,0,100,100]',
});
assertExists(shape1);
const shape2 = service.crud.addElement('shape', {
shapeType: 'rect',
xywh: '[100,100,100,100]',
});
assertExists(shape2);
const note1 = addNote(doc, {
displayMode: NoteDisplayMode.DocAndEdgeless,
xywh: '[200,200,800,100]',
edgeless: {
style: {
borderRadius: 8,
borderSize: 4,
borderStyle: 'solid',
shadowType: '--affine-note-shadow-box',
},
collapse: true,
collapsedHeight: 100,
},
});
const children = new Y.Map<boolean>();
children.set(shape1, true);
children.set(shape2, true);
children.set(note1, true);
const groupId = service.crud.addElement('group', { children });
assertExists(groupId);
const group = service.crud.getElementById(groupId) as GroupElementModel;
const assertInitial = () => {
expect(group.x).toBe(0);
expect(group.y).toBe(0);
expect(group.w).toBe(1000);
expect(group.h).toBe(300);
};
doc.captureSync();
await wait();
assertInitial();
service.removeElement(note1);
await wait();
expect(group.x).toBe(0);
expect(group.y).toBe(0);
expect(group.w).toBe(200);
expect(group.h).toBe(200);
doc.captureSync();
doc.undo();
await wait();
assertInitial();
service.crud.updateElement(note1, {
xywh: '[300,300,800,100]',
});
await wait();
expect(group.x).toBe(0);
expect(group.y).toBe(0);
expect(group.w).toBe(1100);
expect(group.h).toBe(400);
doc.captureSync();
doc.undo();
await wait();
assertInitial();
service.removeElement(shape1);
await wait();
expect(group.x).toBe(100);
expect(group.y).toBe(100);
expect(group.w).toBe(900);
expect(group.h).toBe(200);
doc.captureSync();
doc.undo();
await wait();
assertInitial();
service.crud.updateElement(shape1, {
xywh: '[100,100,100,100]',
});
await wait();
expect(group.x).toBe(100);
expect(group.y).toBe(100);
expect(group.w).toBe(900);
expect(group.h).toBe(200);
doc.captureSync();
doc.undo();
await wait();
assertInitial();
});
test('empty group should have all zero xywh', () => {
const map = new Y.Map<boolean>();
const groupId = service.crud.addElement('group', { children: map });
assertExists(groupId);
const group = service.crud.getElementById(groupId) as GroupElementModel;
expect(group.x).toBe(0);
expect(group.y).toBe(0);
expect(group.w).toBe(0);
expect(group.h).toBe(0);
});
test('descendant of group should not contain itself', () => {
const groupIds = [1, 2, 3].map(_ => {
return service.crud.addElement('group', {
children: new Y.Map<boolean>(),
}) as string;
});
const groups = groupIds.map(
id => service.crud.getElementById(id) as GroupElementModel
);
groups.forEach(group => {
expect(group.descendantElements).toHaveLength(0);
});
groups[0].addChild(groups[1]);
groups[1].addChild(groups[2]);
groups[2].addChild(groups[0]);
expect(groups[0].descendantElements).toHaveLength(2);
expect(groups[1].descendantElements).toHaveLength(1);
expect(groups[2].descendantElements).toHaveLength(0);
});
});
describe('mindmap', () => {
let service!: EdgelessRootBlockComponent['service'];
beforeEach(async () => {
const cleanup = await setupEditor('edgeless');
service = getDocRootBlock(window.doc, window.editor, 'edgeless').service;
return cleanup;
});
test('delete the root node should remove all children', async () => {
const tree = {
text: 'root',
children: [
{
text: 'leaf1',
},
{
text: 'leaf2',
},
{
text: 'leaf3',
children: [
{
text: 'leaf4',
},
],
},
],
};
const mindmapId = service.crud.addElement('mindmap', { children: tree });
assertExists(mindmapId);
const mindmap = () =>
service.crud.getElementById(mindmapId) as MindmapElementModel;
expect(service.surface.elementModels.length).toBe(6);
doc.captureSync();
service.removeElement(mindmap().tree.element);
await wait();
expect(service.surface.elementModels.length).toBe(0);
doc.captureSync();
await wait();
doc.undo();
expect(service.surface.elementModels.length).toBe(6);
await wait();
service.removeElement(mindmap().tree.children[2].element);
await wait();
expect(service.surface.elementModels.length).toBe(4);
await wait();
doc.undo();
await wait();
expect(service.surface.elementModels.length).toBe(6);
});
test('mindmap should layout automatically when creating', async () => {
const tree = {
text: 'root',
children: [
{
text: 'leaf1',
},
{
text: 'leaf2',
},
{
text: 'leaf3',
children: [
{
text: 'leaf4',
},
],
},
],
};
const mindmapId = service.crud.addElement('mindmap', {
type: LayoutType.RIGHT,
children: tree,
});
assertExists(mindmapId);
const mindmap = () =>
service.crud.getElementById(mindmapId) as MindmapElementModel;
doc.captureSync();
await wait();
const root = mindmap().tree.element;
const children = mindmap().tree.children.map(child => child.element);
const leaf4 = mindmap().tree.children[2].children[0].element;
expect(children[0].x).greaterThan(root.x + root.w);
expect(children[1].x).greaterThan(root.x + root.w);
expect(children[2].x).greaterThan(root.x + root.w);
expect(children[1].y).greaterThan(children[0].y + children[0].h);
expect(children[2].y).greaterThan(children[1].y + children[1].h);
expect(leaf4.x).greaterThan(children[2].x + children[2].w);
});
test('deliberately creating a circular reference should be resolved correctly', async () => {
const tree = {
text: 'root',
children: [
{
text: 'leaf1',
},
{
text: 'leaf2',
},
{
text: 'leaf3',
children: [
{
text: 'leaf4',
},
],
},
],
};
const mindmapId = service.crud.addElement('mindmap', {
type: LayoutType.RIGHT,
children: tree,
});
assertExists(mindmapId);
const mindmap = () =>
service.crud.getElementById(mindmapId) as MindmapElementModel;
doc.captureSync();
await wait();
// create a circular reference
doc.transact(() => {
const root = mindmap().tree;
const leaf3 = root.children[2];
const leaf4 = root.children[2].children[0];
mindmap().children.set(leaf3.id, {
index: leaf3.detail.index,
parent: leaf4.id,
});
});
doc.captureSync();
await wait();
// the circular referenced node should be removed
expect(mindmap().nodeMap.size).toBe(3);
});
});
@@ -0,0 +1,276 @@
import type { BlockStdScope } from '@blocksuite/block-std';
import {
type BrushElementModel,
type ConnectorElementModel,
DEFAULT_NOTE_SHADOW,
DefaultTheme,
type EdgelessRootBlockComponent,
type EdgelessTextBlockModel,
EditPropsStore,
FontFamily,
type FrameBlockModel,
getSurfaceBlock,
LayoutType,
type MindmapElementModel,
MindmapStyle,
type NoteBlockModel,
NoteShadow,
type ShapeElementModel,
ShapeType,
type TextElementModel,
} from '@blocksuite/blocks';
import { assertExists } from '@blocksuite/global/utils';
import { beforeEach, describe, expect, test } from 'vitest';
import { getDocRootBlock } from '../utils/edgeless.js';
import { setupEditor } from '../utils/setup.js';
describe('apply last props', () => {
let edgelessRoot!: EdgelessRootBlockComponent;
let service!: EdgelessRootBlockComponent['service'];
let std!: BlockStdScope;
beforeEach(async () => {
sessionStorage.removeItem('blocksuite:prop:record');
const cleanup = await setupEditor('edgeless');
edgelessRoot = getDocRootBlock(window.doc, window.editor, 'edgeless');
service = edgelessRoot.service;
std = edgelessRoot.std;
return cleanup;
});
test('shapes', () => {
// rect shape
const rectId = service.crud.addElement('shape', {
shapeType: ShapeType.Rect,
});
assertExists(rectId);
const rectShape = service.crud.getElementById(rectId) as ShapeElementModel;
expect(rectShape.fillColor).toBe(DefaultTheme.shapeFillColor);
service.crud.updateElement(rectId, {
fillColor: DefaultTheme.FillColorMap.Orange,
});
expect(
std.get(EditPropsStore).lastProps$.value[`shape:${ShapeType.Rect}`]
.fillColor
).toBe(DefaultTheme.FillColorMap.Orange);
// diamond shape
const diamondId = service.crud.addElement('shape', {
shapeType: ShapeType.Diamond,
});
assertExists(diamondId);
const diamondShape = service.crud.getElementById(
diamondId
) as ShapeElementModel;
expect(diamondShape.fillColor).toBe(DefaultTheme.FillColorMap.Yellow);
service.crud.updateElement(diamondId, {
fillColor: DefaultTheme.FillColorMap.Blue,
});
expect(
std.get(EditPropsStore).lastProps$.value[`shape:${ShapeType.Diamond}`]
.fillColor
).toBe(DefaultTheme.FillColorMap.Blue);
// rounded rect shape
const roundedRectId = service.crud.addElement('shape', {
shapeType: ShapeType.Rect,
radius: 0.1,
});
assertExists(roundedRectId);
const roundedRectShape = service.crud.getElementById(
roundedRectId
) as ShapeElementModel;
expect(roundedRectShape.fillColor).toBe(DefaultTheme.FillColorMap.Yellow);
service.crud.updateElement(roundedRectId, {
fillColor: DefaultTheme.FillColorMap.Green,
});
expect(
std.get(EditPropsStore).lastProps$.value['shape:roundedRect'].fillColor
).toBe(DefaultTheme.FillColorMap.Green);
// apply last props
const rectId2 = service.crud.addElement('shape', {
shapeType: ShapeType.Rect,
});
assertExists(rectId2);
const rectShape2 = service.crud.getElementById(
rectId2
) as ShapeElementModel;
expect(rectShape2.fillColor).toBe(DefaultTheme.FillColorMap.Orange);
const diamondId2 = service.crud.addElement('shape', {
shapeType: ShapeType.Diamond,
});
assertExists(diamondId2);
const diamondShape2 = service.crud.getElementById(
diamondId2
) as ShapeElementModel;
expect(diamondShape2.fillColor).toBe(DefaultTheme.FillColorMap.Blue);
const roundedRectId2 = service.crud.addElement('shape', {
shapeType: ShapeType.Rect,
radius: 0.1,
});
assertExists(roundedRectId2);
const roundedRectShape2 = service.crud.getElementById(
roundedRectId2
) as ShapeElementModel;
expect(roundedRectShape2.fillColor).toBe(DefaultTheme.FillColorMap.Green);
});
test('connector', () => {
const id = service.crud.addElement('connector', { mode: 0 });
assertExists(id);
const connector = service.crud.getElementById(id) as ConnectorElementModel;
expect(connector.stroke).toBe(DefaultTheme.connectorColor);
expect(connector.strokeWidth).toBe(2);
expect(connector.strokeStyle).toBe('solid');
expect(connector.frontEndpointStyle).toBe('None');
expect(connector.rearEndpointStyle).toBe('Arrow');
service.crud.updateElement(id, { strokeWidth: 10 });
const id2 = service.crud.addElement('connector', { mode: 1 });
assertExists(id2);
const connector2 = service.crud.getElementById(
id2
) as ConnectorElementModel;
expect(connector2.strokeWidth).toBe(10);
service.crud.updateElement(id2, {
labelStyle: {
color: DefaultTheme.black,
fontFamily: FontFamily.Kalam,
},
});
const id3 = service.crud.addElement('connector', { mode: 1 });
assertExists(id3);
const connector3 = service.crud.getElementById(
id3
) as ConnectorElementModel;
expect(connector3.strokeWidth).toBe(10);
expect(connector3.labelStyle.color).toEqual(DefaultTheme.black);
expect(connector3.labelStyle.fontFamily).toBe(FontFamily.Kalam);
});
test('brush', () => {
const id = service.crud.addElement('brush', {});
assertExists(id);
const brush = service.crud.getElementById(id) as BrushElementModel;
expect(brush.color).toEqual(DefaultTheme.black);
expect(brush.lineWidth).toBe(4);
service.crud.updateElement(id, { lineWidth: 10 });
const secondBrush = service.crud.getElementById(
service.crud.addElement('brush', {}) as string
) as BrushElementModel;
expect(secondBrush.lineWidth).toBe(10);
});
test('text', () => {
const id = service.crud.addElement('text', {});
assertExists(id);
const text = service.crud.getElementById(id) as TextElementModel;
expect(text.fontSize).toBe(24);
service.crud.updateElement(id, { fontSize: 36 });
const secondText = service.crud.getElementById(
service.crud.addElement('text', {}) as string
) as TextElementModel;
expect(secondText.fontSize).toBe(36);
});
test('mindmap', () => {
const id = service.crud.addElement('mindmap', {});
assertExists(id);
const mindmap = service.crud.getElementById(id) as MindmapElementModel;
expect(mindmap.layoutType).toBe(LayoutType.RIGHT);
expect(mindmap.style).toBe(MindmapStyle.ONE);
service.crud.updateElement(id, {
layoutType: LayoutType.BALANCE,
style: MindmapStyle.THREE,
});
const id2 = service.crud.addElement('mindmap', {});
assertExists(id2);
const mindmap2 = service.crud.getElementById(id2) as MindmapElementModel;
expect(mindmap2.layoutType).toBe(LayoutType.BALANCE);
expect(mindmap2.style).toBe(MindmapStyle.THREE);
});
test('edgeless-text', () => {
const surface = getSurfaceBlock(doc);
const id = service.crud.addBlock('affine:edgeless-text', {}, surface!.id);
assertExists(id);
const text = service.crud.getElementById(id) as EdgelessTextBlockModel;
expect(text.color).toBe(DefaultTheme.textColor);
expect(text.fontFamily).toBe(FontFamily.Inter);
service.crud.updateElement(id, {
color: DefaultTheme.StrokeColorMap.Green,
fontFamily: FontFamily.OrelegaOne,
});
const id2 = service.crud.addBlock('affine:edgeless-text', {}, surface!.id);
assertExists(id2);
const text2 = service.crud.getElementById(id2) as EdgelessTextBlockModel;
expect(text2.color).toBe(DefaultTheme.StrokeColorMap.Green);
expect(text2.fontFamily).toBe(FontFamily.OrelegaOne);
});
test('note', () => {
const id = service.crud.addBlock('affine:note', {}, doc.root!.id);
assertExists(id);
const note = service.crud.getElementById(id) as NoteBlockModel;
expect(note.background).toEqual(DefaultTheme.noteBackgrounColor);
expect(note.edgeless.style.shadowType).toBe(DEFAULT_NOTE_SHADOW);
service.crud.updateElement(id, {
background: DefaultTheme.NoteBackgroundColorMap.Purple,
edgeless: {
style: {
shadowType: NoteShadow.Film,
},
},
});
const id2 = service.crud.addBlock('affine:note', {}, doc.root!.id);
assertExists(id2);
const note2 = service.crud.getElementById(id2) as NoteBlockModel;
expect(note2.background).toEqual(
DefaultTheme.NoteBackgroundColorMap.Purple
);
expect(note2.edgeless.style.shadowType).toBe(NoteShadow.Film);
});
test('frame', () => {
const surface = getSurfaceBlock(doc);
const id = service.crud.addBlock('affine:frame', {}, surface!.id);
assertExists(id);
const note = service.crud.getElementById(id) as FrameBlockModel;
expect(note.background).toBe('transparent');
service.crud.updateElement(id, {
background: DefaultTheme.StrokeColorMap.Purple,
});
const id2 = service.crud.addBlock('affine:frame', {}, surface!.id);
assertExists(id2);
const frame2 = service.crud.getElementById(id2) as FrameBlockModel;
expect(frame2.background).toBe(DefaultTheme.StrokeColorMap.Purple);
service.crud.updateElement(id2, {
background: { normal: '#def4e740' },
});
const id3 = service.crud.addBlock('affine:frame', {}, surface!.id);
assertExists(id3);
const frame3 = service.crud.getElementById(id3) as FrameBlockModel;
expect(frame3.background).toEqual({ normal: '#def4e740' });
service.crud.updateElement(id3, {
background: { light: '#a381aa23', dark: '#6e907452' },
});
const id4 = service.crud.addBlock('affine:frame', {}, surface!.id);
assertExists(id4);
const frame4 = service.crud.getElementById(id4) as FrameBlockModel;
expect(frame4.background).toEqual({
light: '#a381aa23',
dark: '#6e907452',
});
});
});
@@ -0,0 +1,885 @@
import type { BlockComponent } from '@blocksuite/block-std';
import { generateKeyBetween } from '@blocksuite/block-std/gfx';
import type {
EdgelessRootBlockComponent,
GroupElementModel,
NoteBlockModel,
SurfaceElementModel,
} from '@blocksuite/blocks';
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<string, unknown> = {}) => {
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<boolean>();
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<HTMLCanvasElement>('.indexable-canvas').length
).toBe(2);
Array.from(
edgeless.querySelectorAll<HTMLCanvasElement>('.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<HTMLCanvasElement>('.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.ungroup(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<boolean>();
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.canvas.width);
expect(indexedCanvas.height).toBe(surface.renderer.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.index <= nextModel.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();
});
});
@@ -0,0 +1,468 @@
import type { MindmapElementModel } from '@blocksuite/affine-model';
import type { GfxController } from '@blocksuite/block-std/gfx';
import { LayoutType, type MindMapView } from '@blocksuite/blocks';
import { Bound } from '@blocksuite/global/utils';
import { beforeEach, describe, expect, test } from 'vitest';
import { click, pointermove, wait } from '../utils/common.js';
import { getDocRootBlock } from '../utils/edgeless.js';
import { setupEditor } from '../utils/setup.js';
describe('mindmap', () => {
let gfx: GfxController;
const moveAndClick = ({ x, y, w, h }: Bound) => {
const { left, top } = gfx.viewport;
x += left;
y += top;
// trigger enter event
pointermove(editor.host!, { x: x + w / 2, y: y + h / 2 });
// trigger move event
pointermove(editor.host!, { x: x + w / 2 + 1, y: y + h / 2 + 1 });
click(editor.host!, { x: x + w / 2, y: y + h / 2 });
};
const move = ({ x, y, w, h }: Bound) => {
x += gfx.viewport.left;
y += gfx.viewport.top;
pointermove(editor.host!, { x: x + w / 2, y: y + h / 2 });
};
beforeEach(async () => {
const cleanup = await setupEditor('edgeless');
gfx = getDocRootBlock(window.doc, window.editor, 'edgeless').gfx;
return cleanup;
});
test('delete the root node should remove all children', async () => {
const tree = {
text: 'root',
children: [
{
text: 'leaf1',
},
{
text: 'leaf2',
},
{
text: 'leaf3',
children: [
{
text: 'leaf4',
},
],
},
],
};
const mindmapId = gfx.surface!.addElement({
type: 'mindmap',
children: tree,
});
const mindmap = () => gfx.getElementById(mindmapId) as MindmapElementModel;
expect(gfx.surface!.elementModels.length).toBe(6);
doc.captureSync();
gfx.deleteElement(mindmap().tree.element);
await wait();
expect(gfx.surface!.elementModels.length).toBe(0);
doc.captureSync();
await wait();
doc.undo();
expect(gfx.surface!.elementModels.length).toBe(6);
await wait();
gfx.deleteElement(mindmap().tree.children[2].element);
await wait();
expect(gfx.surface!.elementModels.length).toBe(4);
await wait();
doc.undo();
await wait();
expect(gfx.surface!.elementModels.length).toBe(6);
});
test('mindmap should layout automatically when creating', async () => {
const tree = {
text: 'root',
children: [
{
text: 'leaf1',
},
{
text: 'leaf2',
},
{
text: 'leaf3',
children: [
{
text: 'leaf4',
},
],
},
],
};
const mindmapId = gfx.surface!.addElement({
type: 'mindmap',
layoutType: LayoutType.RIGHT,
children: tree,
});
const mindmap = () => gfx.getElementById(mindmapId) as MindmapElementModel;
doc.captureSync();
await wait();
const root = mindmap().tree.element;
const children = mindmap().tree.children.map(child => child.element);
const leaf4 = mindmap().tree.children[2].children[0].element;
expect(children[0].x).greaterThan(root.x + root.w);
expect(children[1].x).greaterThan(root.x + root.w);
expect(children[2].x).greaterThan(root.x + root.w);
expect(children[1].y).greaterThan(children[0].y + children[0].h);
expect(children[2].y).greaterThan(children[1].y + children[1].h);
expect(leaf4.x).greaterThan(children[2].x + children[2].w);
});
test('deliberately creating a circular reference should be resolved correctly', async () => {
const tree = {
text: 'root',
children: [
{
text: 'leaf1',
},
{
text: 'leaf2',
},
{
text: 'leaf3',
children: [
{
text: 'leaf4',
},
],
},
],
};
const mindmapId = gfx.surface!.addElement({
type: 'mindmap',
layoutType: LayoutType.RIGHT,
children: tree,
});
const mindmap = () => gfx.getElementById(mindmapId) as MindmapElementModel;
doc.captureSync();
await wait();
// create a circular reference
doc.transact(() => {
const root = mindmap().tree;
const leaf3 = root.children[2];
const leaf4 = root.children[2].children[0];
mindmap().children.set(leaf3.id, {
index: leaf3.detail.index,
parent: leaf4.id,
});
});
doc.captureSync();
await wait();
// the circular referenced node should be removed
expect(mindmap().nodeMap.size).toBe(3);
});
test('mindmap collapse and expand should work correctly', async () => {
const tree = {
text: 'root',
children: [
{
text: 'leaf1',
},
{
text: 'leaf2',
},
{
text: 'leaf3',
children: [
{
text: 'leaf4',
},
],
},
],
};
// click to active the editor
click(editor.host!, { x: 50, y: 50 });
const mindmapId = gfx.surface!.addElement({
type: 'mindmap',
layoutType: LayoutType.RIGHT,
children: tree,
});
const mindmap = () => gfx.getElementById(mindmapId) as MindmapElementModel;
const mindmapView = () => gfx.view.get(mindmapId) as MindMapView;
doc.captureSync();
await wait(100);
// collapse the root node
{
const rootButton = mindmapView().getCollapseButton(mindmap().tree)!;
moveAndClick(gfx.viewport.toViewBound(rootButton.elementBound));
await wait(500);
expect(rootButton.hidden).toBe(false);
expect(rootButton.opacity).toBe(1);
expect(mindmap().tree.detail.collapsed).toBe(true);
expect(mindmap().getNodeByPath([0, 0])!.element.hidden).toBe(true);
expect(mindmap().getNodeByPath([0, 1])!.element.hidden).toBe(true);
expect(mindmap().getNodeByPath([0, 2])!.element.hidden).toBe(true);
expect(mindmap().getNodeByPath([0, 2, 0])!.element.hidden).toBe(true);
doc.captureSync();
await wait();
doc.undo();
await wait();
expect(mindmap().tree.detail.collapsed).toBe(undefined);
expect(mindmap().getNodeByPath([0, 0])!.element.hidden).toBe(false);
expect(mindmap().getNodeByPath([0, 1])!.element.hidden).toBe(false);
expect(mindmap().getNodeByPath([0, 2])!.element.hidden).toBe(false);
expect(mindmap().getNodeByPath([0, 2, 0])!.element.hidden).toBe(false);
}
// collapse a child node
{
const node = mindmap().getNodeByPath([0, 2])!;
const childButton = mindmapView().getCollapseButton(node)!;
moveAndClick(gfx.viewport.toViewBound(childButton.elementBound));
await wait(500);
expect(childButton.hidden).toBe(false);
expect(childButton.opacity).toBe(1);
expect(mindmap().getNodeByPath([0, 2])!.element.hidden).toBe(false);
expect(mindmap().getNodeByPath([0, 2])!.detail.collapsed).toBe(true);
expect(mindmap().getNodeByPath([0, 2, 0])!.element.hidden).toBe(true);
doc.captureSync();
await wait();
doc.undo();
await wait();
expect(mindmap().getNodeByPath([0, 2])!.detail.collapsed).toBe(undefined);
expect(mindmap().getNodeByPath([0, 2, 0])!.element.hidden).toBe(false);
}
// collapse root node and collapse a child node
{
const childButton = mindmapView().getCollapseButton(
mindmap().getNodeByPath([0, 2])!
)!;
// collapse child node
moveAndClick(gfx.viewport.toViewBound(childButton.elementBound));
await wait(500);
doc.captureSync();
await wait();
const rootButton = mindmapView().getCollapseButton(mindmap().tree)!;
// collapse root node
moveAndClick(gfx.viewport.toViewBound(rootButton.elementBound));
await wait(500);
// child button should be hidden
expect(childButton.hidden).toBe(true);
expect(childButton.opacity).toBe(0);
// expand root node
doc.undo();
await wait();
// child button should be visible
expect(childButton.hidden).toBe(false);
expect(childButton.opacity).toBe(1);
// child nodes should still be collapsed
expect(mindmap().getNodeByPath([0, 2])!.detail.collapsed).toBe(true);
expect(mindmap().getNodeByPath([0, 2, 0])!.element.hidden).toBe(true);
// expand child node
doc.undo();
await wait();
// child button should be visible
expect(mindmap().getNodeByPath([0, 2])!.detail.collapsed).toBe(undefined);
expect(mindmap().getNodeByPath([0, 2, 0])!.element.hidden).toBe(false);
}
});
test("selected node's collapse button should be visible", async () => {
const tree = {
text: 'root',
children: [
{
text: 'leaf1',
},
{
text: 'leaf2',
},
{
text: 'leaf3',
children: [
{
text: 'leaf4',
},
],
},
],
};
// click to active the editor
click(editor.host!, { x: 50, y: 50 });
const mindmapId = gfx.surface!.addElement({
type: 'mindmap',
layoutType: LayoutType.RIGHT,
children: tree,
});
const mindmap = () => gfx.getElementById(mindmapId) as MindmapElementModel;
const mindmapView = () => gfx.view.get(mindmapId) as MindMapView;
await wait();
gfx.selection.set({ elements: [mindmap().tree.id] });
const rootButton = mindmapView().getCollapseButton(mindmap().tree)!;
expect(rootButton.hidden).toBe(false);
expect(rootButton.opacity).toBe(1);
gfx.selection.set({ elements: [mindmap().getNodeByPath([0, 2])!.id] });
const childButton = mindmapView().getCollapseButton(
mindmap().getNodeByPath([0, 2])!
)!;
expect(childButton.hidden).toBe(false);
expect(childButton.opacity).toBe(1);
});
test('move near to the collapsed button should show the button', async () => {
const tree = {
text: 'root',
children: [
{
text: 'leaf1',
},
{
text: 'leaf2',
},
{
text: 'leaf3',
children: [
{
text: 'leaf4',
},
],
},
],
};
// click to active the editor
click(editor.host!, { x: 50, y: 50 });
const mindmapId = gfx.surface!.addElement({
type: 'mindmap',
layoutType: LayoutType.RIGHT,
children: tree,
});
const mindmap = () => gfx.getElementById(mindmapId) as MindmapElementModel;
const mindmapView = () => gfx.view.get(mindmapId) as MindMapView;
await wait();
const rootButton = mindmapView().getCollapseButton(mindmap().tree)!;
move(gfx.viewport.toViewBound(rootButton.elementBound).moveDelta(10, 10));
await wait();
expect(rootButton.opacity).toBe(1);
const childButton = mindmapView().getCollapseButton(
mindmap().getNodeByPath([0, 2])!
)!;
move(gfx.viewport.toViewBound(childButton.elementBound).moveDelta(10, 10));
await wait();
expect(childButton.opacity).toBe(1);
move(new Bound(0, 0, 0, 0));
await wait();
expect(childButton.opacity).toBe(0);
expect(rootButton.opacity).toBe(0);
});
test("collapsed node's button should be always visible except its ancestor is collapsed", async () => {
const tree = {
text: 'root',
children: [
{
text: 'leaf1',
},
{
text: 'leaf2',
},
{
text: 'leaf3',
children: [
{
text: 'leaf4',
},
],
},
],
};
// click to active the editor
click(editor.host!, { x: 50, y: 50 });
const mindmapId = gfx.surface!.addElement({
type: 'mindmap',
layoutType: LayoutType.RIGHT,
children: tree,
});
const mindmap = () => gfx.getElementById(mindmapId) as MindmapElementModel;
const mindmapView = () => gfx.view.get(mindmapId) as MindMapView;
doc.captureSync();
await wait();
const childButton = mindmapView().getCollapseButton(
mindmap().getNodeByPath([0, 2])!
)!;
// collapse the child node
moveAndClick(gfx.viewport.toViewBound(childButton.elementBound));
// move out of the button, the button should still be visible
move(new Bound(0, 0, 0, 0));
await wait();
expect(childButton.hidden).toBe(false);
expect(childButton.opacity).toBe(1);
const rootButton = mindmapView().getCollapseButton(mindmap().tree)!;
// collapse the root node
moveAndClick(gfx.viewport.toViewBound(rootButton.elementBound));
// move out of the button, the root button should be visible
move(new Bound(0, 0, 0, 0));
await wait();
expect(rootButton.hidden).toBe(false);
expect(rootButton.opacity).toBe(1);
// the collapsed child button should be hidden
expect(childButton.hidden).toBe(true);
expect(childButton.opacity).toBe(0);
});
});
@@ -0,0 +1,519 @@
import type {
BrushElementModel,
GroupElementModel,
ShapeElementModel,
SurfaceBlockModel,
} from '@blocksuite/blocks';
import { DefaultTheme } from '@blocksuite/blocks';
import { beforeEach, describe, expect, test, vi } 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 = doc.getBlockByFlavour('affine:surface') as SurfaceBlockModel[];
model = models[0];
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('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);
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 children property is updated', () => {
const id = model.addElement({
type: 'shape',
});
const id2 = model.addElement({
type: 'shape',
});
const id3 = model.addElement({
type: 'shape',
});
const groupId = model.addElement({
type: 'group',
children: {
[id]: true,
[id2]: true,
[id3]: true,
},
});
const group = model.getElementById(groupId) as GroupElementModel;
model.doc.transact(() => {
group.children.delete(id);
group.children.delete(id2);
});
expect(model.getElementById(groupId)).toBe(group);
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.deleteElement(groupId);
expect(model.getGroup(id)).toBeNull();
expect(model.getGroup(id2)).toBeNull();
});
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.childIds).toEqual([id]);
});
});
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).map(el => el.id)).toEqual([connector.id]);
expect(model.getConnectors(id2).map(el => el.id)).toEqual([connector.id]);
});
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)!;
const connectors = [connector.id, connector2.id];
expect(model.getConnectors(id).map(c => c.id)).toEqual(connectors);
expect(model.getConnectors(id2).map(c => c.id)).toEqual(connectors);
});
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.deleteElement(connectorId);
await wait();
expect(model.getConnectors(id)).toEqual([]);
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);
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();
model.elementUpdated.once(({ id }) => 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();
model.elementUpdated.once(({ id }) => 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({
type: 'brush',
lineWidth: 2,
points: [
[0, 0],
[100, 100],
[120, 150],
],
});
const brush = model.getElementById(id) as BrushElementModel;
const oldBrushXYWH = brush.xywh;
brush.lineWidth = 4;
expect(brush.xywh).not.toBe(oldBrushXYWH);
brush.lineWidth = 2;
expect(brush.xywh).toBe(oldBrushXYWH);
});
});
@@ -0,0 +1,307 @@
import {
type EdgelessRootBlockComponent,
EdgelessRootService,
type FrameBlockComponent,
type SurfaceRefBlockComponent,
} from '@blocksuite/blocks';
import type { DocSnapshot } from '@blocksuite/store';
import { beforeEach, describe, expect, test } from 'vitest';
import { wait } from '../utils/common.js';
import { addNote, getDocRootBlock } from '../utils/edgeless.js';
import { importFromSnapshot } from '../utils/misc.js';
import { setupEditor } from '../utils/setup.js';
describe('basic', () => {
let service: EdgelessRootBlockComponent['service'];
let edgelessRoot: EdgelessRootBlockComponent;
let noteAId = '';
let noteBId = '';
let shapeAId = '';
let shapeBId = '';
let frameId = '';
beforeEach(async () => {
const cleanup = await setupEditor('edgeless');
edgelessRoot = getDocRootBlock(doc, editor, 'edgeless');
service = edgelessRoot.service;
noteAId = addNote(doc, {
index: service.generateIndex(),
});
shapeAId = service.crud.addElement('shape', {
type: 'rect',
xywh: '[0, 0, 100, 100]',
index: service.generateIndex(),
})!;
noteBId = addNote(doc, {
index: service.generateIndex(),
});
shapeBId = service.crud.addElement('shape', {
type: 'rect',
xywh: '[100, 0, 100, 100]',
index: service.generateIndex(),
})!;
frameId = service.crud.addBlock(
'affine:frame',
{
xywh: '[0, 0, 800, 200]',
index: service.generateIndex(),
},
service.surface.id
);
return cleanup;
});
test('surface-ref should be rendered in page mode', async () => {
const surfaceRefId = doc.addBlock(
'affine:surface-ref',
{
reference: frameId,
},
noteAId
);
editor.mode = 'page';
await wait();
expect(
document.querySelector(
`affine-surface-ref[data-block-id="${surfaceRefId}"]`
)
).instanceOf(Element);
});
test('surface-ref should be rendered as empty surface-ref-block-edgeless component page mode', async () => {
const surfaceRefId = doc.addBlock(
'affine:surface-ref',
{
reference: frameId,
},
noteAId
);
await wait();
const refBlock = document.querySelector(
`affine-edgeless-surface-ref[data-block-id="${surfaceRefId}"]`
)! as HTMLElement;
expect(refBlock).instanceOf(Element);
expect(refBlock.innerText).toBe('');
});
test('content in frame should be rendered in the correct order', async () => {
const surfaceRefId = doc.addBlock(
'affine:surface-ref',
{
reference: frameId,
},
noteAId
);
editor.mode = 'page';
await wait();
const surfaceRef = document.querySelector(
`affine-surface-ref[data-block-id="${surfaceRefId}"]`
) as HTMLElement;
const refBlocks = Array.from(
surfaceRef.querySelectorAll('affine-edgeless-note')
) as HTMLElement[];
const stackingCanvas = Array.from(
surfaceRef.querySelectorAll('.indexable-canvas')!
) as HTMLCanvasElement[];
expect(refBlocks.length).toBe(2);
expect(stackingCanvas.length).toBe(2);
expect(stackingCanvas[0].style.zIndex > refBlocks[0].style.zIndex).toBe(
true
);
});
test('content in group should be rendered in the correct order', async () => {
const groupId = service.crud.addElement('group', {
children: {
[shapeAId]: true,
[shapeBId]: true,
[noteAId]: true,
[noteBId]: true,
},
});
const surfaceRefId = doc.addBlock(
'affine:surface-ref',
{
reference: groupId,
},
noteAId
);
editor.mode = 'page';
await wait();
const surfaceRef = document.querySelector(
`affine-surface-ref[data-block-id="${surfaceRefId}"]`
) as HTMLElement;
const refBlocks = Array.from(
surfaceRef.querySelectorAll('affine-edgeless-note')
) as HTMLElement[];
const stackingCanvas = Array.from(
surfaceRef.querySelectorAll('.indexable-canvas')
) as HTMLCanvasElement[];
expect(refBlocks.length).toBe(2);
expect(stackingCanvas.length).toBe(2);
expect(stackingCanvas[1].style.zIndex > refBlocks[0].style.zIndex).toBe(
true
);
});
test('frame should be rendered in surface-ref viewport', async () => {
const surfaceRefId = doc.addBlock(
'affine:surface-ref',
{
reference: frameId,
},
noteAId
);
editor.mode = 'page';
await wait();
const surfaceRef = document.querySelector(
`affine-surface-ref[data-block-id="${surfaceRefId}"]`
) as SurfaceRefBlockComponent;
const edgeless = surfaceRef.previewEditor!.std.get(EdgelessRootService);
const frame = surfaceRef.querySelector(
'affine-frame'
) as FrameBlockComponent;
expect(
edgeless.viewport.isInViewport(frame.model.elementBound)
).toBeTruthy();
});
test('group should be rendered in surface-ref viewport', async () => {
const groupId = service.crud.addElement('group', {
children: {
[shapeAId]: true,
[shapeBId]: true,
[noteAId]: true,
[noteBId]: true,
},
})!;
const surfaceRefId = doc.addBlock(
'affine:surface-ref',
{
reference: groupId,
},
noteAId
);
editor.mode = 'page';
await wait();
const surfaceRef = document.querySelector(
`affine-surface-ref[data-block-id="${surfaceRefId}"]`
) as SurfaceRefBlockComponent;
const edgeless = surfaceRef.previewEditor!.std.get(EdgelessRootService);
const group = edgeless.crud.getElementById(groupId)!;
expect(edgeless.viewport.isInViewport(group.elementBound)).toBeTruthy();
});
test('viewport of surface-ref should be updated when the reference xywh updated', async () => {
const surfaceRefId = doc.addBlock(
'affine:surface-ref',
{
reference: frameId,
},
noteAId
);
editor.mode = 'page';
await wait();
const surfaceRef = document.querySelector(
`affine-surface-ref[data-block-id="${surfaceRefId}"]`
) as SurfaceRefBlockComponent;
const edgeless = surfaceRef.previewEditor!.std.get(EdgelessRootService);
const frame = surfaceRef.querySelector(
'affine-frame'
) as FrameBlockComponent;
const oldViewport = edgeless.viewport.viewportBounds;
frame.model.xywh = '[100, 100, 800, 200]';
await wait();
expect(edgeless.viewport.viewportBounds).not.toEqual(oldViewport);
});
test('view in edgeless mode button', async () => {
const groupId = service.crud.addElement('group', {
children: {
[shapeAId]: true,
[shapeBId]: true,
[noteAId]: true,
[noteBId]: true,
},
});
const surfaceRefId = doc.addBlock(
'affine:surface-ref',
{
reference: groupId,
},
noteAId
);
editor.mode = 'page';
await wait();
const surfaceRef = document.querySelector(
`affine-surface-ref[data-block-id="${surfaceRefId}"]`
) as HTMLElement;
expect(surfaceRef).instanceOf(Element);
(surfaceRef as SurfaceRefBlockComponent).viewInEdgeless();
await wait();
});
});
import snapshot from '../snapshots/edgeless/surface-ref.spec.ts/surface-ref.json' assert { type: 'json' };
describe('clipboard', () => {
test('import surface-ref snapshot should render content correctly', async () => {
await setupEditor('page');
const pageRoot = getDocRootBlock(doc, editor, 'page');
const pageRootService = pageRoot.service;
const newDoc = await importFromSnapshot(
pageRootService.collection,
snapshot as DocSnapshot
);
expect(newDoc).toBeTruthy();
editor.doc = newDoc!;
await wait();
const surfaceRefs = newDoc!.getBlocksByFlavour('affine:surface-ref');
expect(surfaceRefs).toHaveLength(2);
const surfaceRefBlocks = surfaceRefs.map(({ id }) =>
editor.std.view.getBlock(id)
) as SurfaceRefBlockComponent[];
expect(surfaceRefBlocks[0].querySelector('.ref-placeholder')).toBeFalsy();
expect(surfaceRefBlocks[1].querySelector('.ref-placeholder')).toBeFalsy();
});
});
@@ -0,0 +1,43 @@
import {
EdgelessTemplatePanel,
type Template,
type TemplateManager,
} from '@blocksuite/blocks';
import { beforeEach, expect, test } from 'vitest';
import { setupEditor } from '../utils/setup.js';
beforeEach(async () => {
const cleanup = await setupEditor('edgeless');
return cleanup;
});
test('extension api', async () => {
const mockTemplate = {
name: 'Test',
type: 'template',
} as Template;
const customTemplate = {
list: () => {
return [mockTemplate];
},
categories: () => {
return ['custom'];
},
search: (_, __) => {
return [mockTemplate];
},
} satisfies TemplateManager;
EdgelessTemplatePanel.templates.extend(customTemplate);
const categories = await EdgelessTemplatePanel.templates.categories();
expect(categories).toContain('custom');
const templates = await EdgelessTemplatePanel.templates.list('any');
expect(templates).toContain(mockTemplate);
const searchTemplates = await EdgelessTemplatePanel.templates.search('any');
expect(searchTemplates).toContain(mockTemplate);
});
@@ -0,0 +1,95 @@
import type {
EdgelessRootBlockComponent,
SurfaceBlockComponent,
} from '@blocksuite/blocks';
import { beforeEach, describe, expect, test } from 'vitest';
import { click, drag, wait } from '../utils/common.js';
import { addNote, getDocRootBlock, getSurface } from '../utils/edgeless.js';
import { setupEditor } from '../utils/setup.js';
describe('default tool', () => {
let surface!: SurfaceBlockComponent;
let edgeless!: EdgelessRootBlockComponent;
let service!: EdgelessRootBlockComponent['service'];
beforeEach(async () => {
const cleanup = await setupEditor('edgeless');
edgeless = getDocRootBlock(doc, editor, 'edgeless');
surface = getSurface(window.doc, window.editor);
service = edgeless.service;
edgeless.gfx.tool.setTool('default');
return cleanup;
});
test('element click selection', async () => {
const id = service.crud.addElement('shape', {
shapeType: 'rect',
xywh: '[0,0,100,100]',
fillColor: 'red',
});
await wait();
service.viewport.setViewport(1, [
service.viewport.width / 2,
service.viewport.height / 2,
]);
click(edgeless.host, { x: 0, y: 50 });
expect(edgeless.service.selection.surfaceSelections[0].elements).toEqual([
id,
]);
});
test('element drag moving', async () => {
const id = edgeless.service.crud.addElement('shape', {
shapeType: 'rect',
xywh: '[0,0,100,100]',
fillColor: 'red',
});
await wait();
edgeless.service.viewport.setViewport(1, [
edgeless.service.viewport.width / 2,
edgeless.service.viewport.height / 2,
]);
await wait();
click(edgeless.host, { x: 0, y: 50 });
drag(edgeless.host, { x: 0, y: 50 }, { x: 0, y: 150 });
await wait();
const element = service.crud.getElementById(id!)!;
expect(element.xywh).toEqual(`[0,100,100,100]`);
});
test('block drag moving', async () => {
const noteId = addNote(doc);
await wait();
edgeless.service.viewport.setViewport(1, [
surface.renderer.viewport.width / 2,
surface.renderer.viewport.height / 2,
]);
await wait();
click(edgeless.host, { x: 50, y: 50 });
expect(edgeless.service.selection.surfaceSelections[0].elements).toEqual([
noteId,
]);
drag(edgeless.host, { x: 50, y: 50 }, { x: 150, y: 150 });
await wait();
const element = service.crud.getElementById(noteId)!;
const [x, y] = JSON.parse(element.xywh);
expect(x).toEqual(100);
expect(y).toEqual(100);
});
});
@@ -0,0 +1,23 @@
import { ViewportTurboRendererExtension } from '@blocksuite/affine-shared/viewport-renderer';
import { beforeEach, describe, expect, test } from 'vitest';
import { wait } from '../utils/common.js';
import { addSampleNotes } from '../utils/doc-generator.js';
import { setupEditor } from '../utils/setup.js';
describe('viewport turbo renderer', () => {
beforeEach(async () => {
const cleanup = await setupEditor('edgeless', [
ViewportTurboRendererExtension,
]);
return cleanup;
});
test('should render 6 notes in viewport', async () => {
addSampleNotes(doc, 6);
await wait();
const notes = document.querySelectorAll('affine-edgeless-note');
expect(notes.length).toBe(6);
});
});