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

View File

@@ -0,0 +1,3 @@
# `@blocksuite/integration-test`
Integration test for BlockSuite.

View File

@@ -0,0 +1,56 @@
{
"name": "@blocksuite/integration-test",
"description": "Integration test for BlockSuite",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc",
"test:unit": "vitest --browser.headless --run",
"test:debug": "PWDEBUG=1 npx vitest"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-note": "workspace:*",
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/block-std": "workspace:*",
"@blocksuite/blocks": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.2",
"@blocksuite/inline": "workspace:*",
"@blocksuite/store": "workspace:*",
"@floating-ui/dom": "^1.6.10",
"@lit/context": "^1.1.3",
"@lottiefiles/dotlottie-wc": "^0.4.0",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.11",
"@vanilla-extract/css": "^1.17.0",
"lit": "^3.2.0",
"yjs": "^13.6.21",
"zod": "^3.23.8"
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts"
},
"files": [
"src",
"dist",
"themes",
"!src/__tests__",
"!dist/__tests__"
],
"devDependencies": {
"@vanilla-extract/vite-plugin": "^5.0.0",
"vite": "^6.1.0",
"vite-plugin-istanbul": "^7.0.0",
"vite-plugin-wasm": "^3.4.1",
"vitest": "^3.0.0"
},
"version": "0.19.0"
}

View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Kalam&display=swap"
rel="stylesheet"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Debug Entry</title>
</head>
<body>
<script
type="module"
src="./src/__tests__/utils/renderer-entry.ts"
></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
});
});

View File

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

View File

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

View File

@@ -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();
});
});

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@@ -0,0 +1,111 @@
import { BlockServiceIdentifier } from '@blocksuite/block-std';
import type { PageRootService, SurfaceBlockModel } from '@blocksuite/blocks';
import type { PointLocation } from '@blocksuite/global/utils';
import { beforeEach, expect, test } from 'vitest';
import { wait } from '../utils/common.js';
import { setupEditor } from '../utils/setup.js';
const excludes = new Set([
'shape-textBound',
'externalXYWH',
'connector-text',
'connector-labelXYWH',
]);
const fieldChecker: Record<string, (value: any) => boolean> = {
'connector-path': (value: PointLocation[]) => {
return value.length > 0;
},
xywh: (value: string) => {
return value.match(xywhPattern) !== null;
},
};
const skipFields = new Set(['_lastXYWH']);
const snapshotTest = async (snapshotUrl: string, elementsCount: number) => {
const pageService = window.editor.host!.std.getOptional(
BlockServiceIdentifier('affine:page')
) as PageRootService;
if (!pageService) {
throw new Error('page service not found');
}
const transformer = pageService.transformers.zip;
const snapshotFile = await fetch(snapshotUrl)
.then(res => res.blob())
.catch(e => {
console.error(e);
throw e;
});
const [newDoc] = await transformer.importDocs(
window.editor.doc.workspace,
snapshotFile
);
if (!newDoc) {
throw new Error('Failed to import snapshot');
}
editor.doc = newDoc;
await wait();
const surface = newDoc.getBlockByFlavour(
'affine:surface'
)[0] as SurfaceBlockModel;
const surfaceElements = [...surface['_elementModels']].map(
([_, { model }]) => model
);
expect(surfaceElements.length).toBe(elementsCount);
surfaceElements.forEach(element => {
const type = element.type;
for (const field in element) {
const value = element[field as keyof typeof element];
const typeField = `${type}-${field}`;
if (excludes.has(`${type}-${field}`) || excludes.has(field)) {
return;
}
if (skipFields.has(field)) {
return;
}
if (fieldChecker[typeField] || fieldChecker[field]) {
const checker = fieldChecker[typeField] || fieldChecker[field];
expect(checker(value)).toBe(true);
return;
}
expect(
value,
`type: ${element.type} field: "${field}"`
).not.toBeUndefined();
expect(value, `type: ${element.type} field: "${field}"`).not.toBeNull();
expect(value, `type: ${element.type} field: "${field}"`).not.toBeNaN();
}
});
};
beforeEach(async () => {
const cleanup = await setupEditor('page');
return cleanup;
});
const xywhPattern = /\[(\s*-?\d+(\.\d+)?\s*,){3}(\s*-?\d+(\.\d+)?\s*)\]/;
test('snapshot 1 importing', async () => {
await snapshotTest('https://test.affineassets.com/test-snapshot-1.zip', 25);
});
test('snapshot 2 importing', async () => {
await snapshotTest(
'https://test.affineassets.com/test-snapshot-2%20(onboarding).zip',
174
);
});

View File

@@ -0,0 +1,222 @@
{
"type": "page",
"meta": {
"id": "doc:home",
"title": "",
"createDate": 1725961648308,
"tags": []
},
"blocks": {
"type": "block",
"id": "xguBwzpkGD",
"flavour": "affine:page",
"version": 2,
"props": {
"title": {
"$blocksuite:internal:text$": true,
"delta": []
}
},
"children": [
{
"type": "block",
"id": "m9-hJeL979",
"flavour": "affine:surface",
"version": 5,
"props": {
"elements": {
"UrT0kJjtKN": {
"index": "a2",
"seed": 1855830969,
"color": "--affine-palette-line-black",
"fillColor": "--affine-palette-shape-yellow",
"filled": true,
"fontFamily": "blocksuite:surface:Inter",
"fontSize": 20,
"fontStyle": "normal",
"fontWeight": "400",
"maxWidth": false,
"padding": [10, 20],
"radius": 0,
"rotate": 0,
"roughness": 1.4,
"shadow": null,
"shapeStyle": "General",
"shapeType": "triangle",
"strokeColor": "--affine-palette-line-yellow",
"strokeStyle": "solid",
"strokeWidth": 2,
"textAlign": "center",
"textResizing": 1,
"xywh": "[220.12515258789062,240.90625,99.82000732421875,80.5]",
"type": "shape",
"id": "UrT0kJjtKN"
},
"0AlG3AohCi": {
"index": "a3",
"seed": 1305411005,
"color": "--affine-palette-line-black",
"fillColor": "--affine-palette-shape-yellow",
"filled": true,
"fontFamily": "blocksuite:surface:Inter",
"fontSize": 20,
"fontStyle": "normal",
"fontWeight": "400",
"maxWidth": false,
"padding": [10, 20],
"radius": 0,
"rotate": 0,
"roughness": 1.4,
"shadow": null,
"shapeStyle": "General",
"shapeType": "ellipse",
"strokeColor": "--affine-palette-line-yellow",
"strokeStyle": "solid",
"strokeWidth": 2,
"textAlign": "center",
"textResizing": 1,
"xywh": "[485.93280029296875,231.14300537109375,99.84002685546875,99.84002685546875]",
"type": "shape",
"id": "0AlG3AohCi"
},
"8fUiHCCqfG": {
"index": "a4",
"seed": 513823325,
"color": "--affine-palette-line-black",
"fillColor": "--affine-palette-shape-yellow",
"filled": true,
"fontFamily": "blocksuite:surface:Inter",
"fontSize": 20,
"fontStyle": "normal",
"fontWeight": "400",
"maxWidth": false,
"padding": [10, 20],
"radius": 0.1,
"rotate": 0,
"roughness": 1.4,
"shadow": null,
"shapeStyle": "General",
"shapeType": "rect",
"strokeColor": "--affine-palette-line-yellow",
"strokeStyle": "solid",
"strokeWidth": 2,
"textAlign": "center",
"textResizing": 1,
"xywh": "[620.0800170898438,234.9613037109375,99.8399658203125,99.8399658203125]",
"type": "shape",
"id": "8fUiHCCqfG"
},
"KfzvfxYw5C": {
"index": "a5",
"seed": 1817567358,
"children": {
"affine:surface:ymap": true,
"json": {
"8fUiHCCqfG": true,
"0AlG3AohCi": true
}
},
"title": {
"affine:surface:text": true,
"delta": [
{
"insert": "Group 1"
}
]
},
"type": "group",
"id": "KfzvfxYw5C"
}
}
},
"children": [
{
"type": "block",
"id": "yBzeQ6raOv",
"flavour": "affine:frame",
"version": 1,
"props": {
"title": {
"$blocksuite:internal:text$": true,
"delta": [
{
"insert": "Frame 1"
}
]
},
"background": "--affine-palette-transparent",
"xywh": "[140.703125,199.1015625,258.6640625,164.109375]",
"index": "a1",
"childElementIds": {
"UrT0kJjtKN": true
}
},
"children": []
}
]
},
{
"type": "block",
"id": "eWPFqL6VST",
"flavour": "affine:note",
"version": 1,
"props": {
"xywh": "[0,0,498,92]",
"background": "--affine-note-background-white",
"index": "a0",
"hidden": false,
"displayMode": "both",
"edgeless": {
"style": {
"borderRadius": 8,
"borderSize": 4,
"borderStyle": "none",
"shadowType": "--affine-note-shadow-box"
}
}
},
"children": [
{
"type": "block",
"id": "09MoQfa5ex",
"flavour": "affine:paragraph",
"version": 1,
"props": {
"type": "text",
"text": {
"$blocksuite:internal:text$": true,
"delta": []
},
"collapsed": false
},
"children": []
},
{
"type": "block",
"id": "NhoAqBBMZg",
"flavour": "affine:surface-ref",
"version": 1,
"props": {
"reference": "yBzeQ6raOv",
"caption": "",
"refFlavour": "affine:frame"
},
"children": []
},
{
"type": "block",
"id": "jJDE1rYIYJ",
"flavour": "affine:surface-ref",
"version": 1,
"props": {
"reference": "KfzvfxYw5C",
"caption": "",
"refFlavour": "group"
},
"children": []
}
]
}
]
}
}

View File

@@ -0,0 +1,202 @@
import type { Point } from '@blocksuite/global/utils';
export function wait(time: number = 0) {
return new Promise(resolve => {
requestAnimationFrame(() => {
setTimeout(resolve, time);
});
});
}
/**
* simulate click event
* @param target
* @param position position relative to the target
*/
export function click(target: HTMLElement, position: { x: number; y: number }) {
const element = target.getBoundingClientRect();
const clientX = element.x + position.x;
const clientY = element.y + position.y;
target.dispatchEvent(
new PointerEvent('pointerdown', {
clientX,
clientY,
bubbles: true,
pointerId: 1,
isPrimary: true,
})
);
target.dispatchEvent(
new PointerEvent('pointerup', {
clientX,
clientY,
bubbles: true,
pointerId: 1,
isPrimary: true,
})
);
target.dispatchEvent(
new MouseEvent('click', {
clientX,
clientY,
bubbles: true,
})
);
}
type PointerOptions = {
isPrimary?: boolean;
pointerId?: number;
pointerType?: string;
};
const defaultPointerOptions: PointerOptions = {
isPrimary: true,
pointerId: 1,
pointerType: 'mouse',
};
/**
* simulate pointerdown event
* @param target
* @param position position relative to the target
*/
export function pointerdown(
target: HTMLElement,
position: { x: number; y: number },
options: PointerOptions = defaultPointerOptions
) {
const element = target.getBoundingClientRect();
const clientX = element.x + position.x;
const clientY = element.y + position.y;
target.dispatchEvent(
new PointerEvent('pointerdown', {
clientX,
clientY,
bubbles: true,
...options,
})
);
}
/**
* simulate pointerup event
* @param target
* @param position position relative to the target
*/
export function pointerup(
target: HTMLElement,
position: { x: number; y: number },
options: PointerOptions = defaultPointerOptions
) {
const element = target.getBoundingClientRect();
const clientX = element.x + position.x;
const clientY = element.y + position.y;
target.dispatchEvent(
new PointerEvent('pointerup', {
clientX,
clientY,
bubbles: true,
...options,
})
);
}
/**
* simulate pointermove event
* @param target
* @param position position relative to the target
*/
export function pointermove(
target: HTMLElement,
position: { x: number; y: number },
options: PointerOptions = defaultPointerOptions
) {
const element = target.getBoundingClientRect();
const clientX = element.x + position.x;
const clientY = element.y + position.y;
target.dispatchEvent(
new PointerEvent('pointermove', {
clientX,
clientY,
bubbles: true,
...options,
})
);
}
export function drag(
target: HTMLElement,
start: { x: number; y: number },
end: { x: number; y: number },
step: number = 5
) {
pointerdown(target, start);
pointermove(target, start);
if (step !== 0) {
const xStep = (end.x - start.x) / step;
const yStep = (end.y - start.y) / step;
for (const [i] of Array.from({ length: step }).entries()) {
pointermove(target, {
x: start.x + xStep * (i + 1),
y: start.y + yStep * (i + 1),
});
}
}
pointermove(target, end);
pointerup(target, end);
}
export function multiTouchDown(target: Element, points: Point[]) {
points.forEach((point, index) => {
pointerdown(target as HTMLElement, point, {
isPrimary: index === 0,
pointerId: index,
pointerType: 'touch',
});
});
}
export function multiTouchMove(
target: Element,
from: Point[],
to: Point[],
step = 5
) {
if (from.length !== to.length) {
throw new Error('from and to should have the same length');
}
if (step !== 0) {
for (const [i] of Array.from({ length: step }).entries()) {
const stepPoints = from.map((point, index) => ({
x: point.x + ((to[index].x - point.x) / step) * (i + 1),
y: point.y + ((to[index].y - point.y) / step) * (i + 1),
}));
from.forEach((_, index) => {
pointermove(target as HTMLElement, stepPoints[index], {
isPrimary: index === 0,
pointerId: index,
pointerType: 'touch',
});
});
}
}
}
export function multiTouchUp(target: Element, points: Point[]) {
points.forEach((point, index) => {
pointerup(target as HTMLElement, point, {
isPrimary: index === 0,
pointerId: index,
pointerType: 'touch',
});
});
}

View File

@@ -0,0 +1,41 @@
import { type Store, Text } from '@blocksuite/store';
function addParagraph(doc: Store, noteId: string, content: string) {
const props = { text: new Text(content) };
doc.addBlock('affine:paragraph', props, noteId);
}
function addSampleNote(doc: Store, noteId: string, i: number) {
addParagraph(doc, noteId, `Note ${i + 1}`);
addParagraph(doc, noteId, 'Hello World!');
addParagraph(
doc,
noteId,
'Hello World! Lorem ipsum dolor sit amet. Consectetur adipiscing elit. Sed do eiusmod tempor incididunt.'
);
addParagraph(
doc,
noteId,
'你好这是测试,这是一个为了换行而写的中文段落。这个段落会自动换行。'
);
}
export function addSampleNotes(doc: Store, n: number) {
const cols = Math.ceil(Math.sqrt(n));
const NOTE_WIDTH = 500;
const NOTE_HEIGHT = 250;
const SPACING = 50;
const rootId = doc.getBlocksByFlavour('affine:page')[0]?.id;
for (let i = 0; i < n; i++) {
const row = Math.floor(i / cols);
const col = i % cols;
const x = col * (NOTE_WIDTH + SPACING);
const y = row * (NOTE_HEIGHT + SPACING);
const xywh = `[${x},${y},${NOTE_WIDTH},${NOTE_HEIGHT}]`;
const noteId = doc.addBlock('affine:note', { xywh }, rootId);
addSampleNote(doc, noteId, i);
}
}

View File

@@ -0,0 +1,51 @@
import type {
EdgelessRootBlockComponent,
PageRootBlockComponent,
SurfaceBlockComponent,
} from '@blocksuite/blocks';
import type { Store } from '@blocksuite/store';
import type { TestAffineEditorContainer } from '../../index.js';
export function getSurface(doc: Store, editor: TestAffineEditorContainer) {
const surfaceModel = doc.getBlockByFlavour('affine:surface');
return editor.host!.view.getBlock(
surfaceModel[0]!.id
) as SurfaceBlockComponent;
}
export function getDocRootBlock(
doc: Store,
editor: TestAffineEditorContainer,
mode: 'page'
): PageRootBlockComponent;
export function getDocRootBlock(
doc: Store,
editor: TestAffineEditorContainer,
mode: 'edgeless'
): EdgelessRootBlockComponent;
export function getDocRootBlock(
doc: Store,
editor: TestAffineEditorContainer,
_?: 'edgeless' | 'page'
) {
return editor.host!.view.getBlock(doc.root!.id) as
| EdgelessRootBlockComponent
| PageRootBlockComponent;
}
export function addNote(doc: Store, props: Record<string, any> = {}) {
const noteId = doc.addBlock(
'affine:note',
{
xywh: '[0, 0, 800, 100]',
...props,
},
doc.root
);
doc.addBlock('affine:paragraph', {}, noteId);
return noteId;
}

View File

@@ -0,0 +1,24 @@
import { replaceIdMiddleware } from '@blocksuite/blocks';
import {
type DocSnapshot,
Transformer,
type Workspace,
} from '@blocksuite/store';
export async function importFromSnapshot(
collection: Workspace,
snapshot: DocSnapshot
) {
const job = new Transformer({
schema: collection.schema,
blobCRUD: collection.blobSync,
docCRUD: {
create: (id: string) => collection.createDoc({ id }),
get: (id: string) => collection.getDoc(id),
delete: (id: string) => collection.removeDoc(id),
},
middlewares: [replaceIdMiddleware(collection.idGenerator)],
});
return job.snapshotToDoc(snapshot);
}

View File

@@ -0,0 +1,12 @@
import { ViewportTurboRendererExtension } from '@blocksuite/affine-shared/viewport-renderer';
import { addSampleNotes } from './doc-generator.js';
import { setupEditor } from './setup.js';
async function init() {
setupEditor('edgeless', [ViewportTurboRendererExtension]);
addSampleNotes(doc, 100);
doc.load();
}
init();

View File

@@ -0,0 +1,140 @@
import '@toeverything/theme/style.css';
import '@toeverything/theme/fonts.css';
import { effects as blocksEffects } from '@blocksuite/blocks/effects';
import type { ExtensionType, Store, Transformer } from '@blocksuite/store';
import { effects } from '../../effects.js';
blocksEffects();
effects();
import {
CommunityCanvasTextFonts,
type DocMode,
EdgelessEditorBlockSpecs,
FontConfigExtension,
PageEditorBlockSpecs,
StoreExtensions,
} from '@blocksuite/blocks';
import { AffineSchemas } from '@blocksuite/blocks/schemas';
import { assertExists } from '@blocksuite/global/utils';
import { Schema, Text } from '@blocksuite/store';
import {
createAutoIncrementIdGenerator,
TestWorkspace,
} from '@blocksuite/store/test';
import { TestAffineEditorContainer } from '../../index.js';
function createCollectionOptions() {
const schema = new Schema();
const room = Math.random().toString(16).slice(2, 8);
schema.register(AffineSchemas);
const idGenerator = createAutoIncrementIdGenerator();
return {
id: room,
schema,
idGenerator,
defaultFlags: {
enable_synced_doc_block: true,
enable_pie_menu: true,
readonly: {
'doc:home': false,
},
},
};
}
function initCollection(collection: TestWorkspace) {
const doc = collection.createDoc({ id: 'doc:home' });
doc.load(() => {
const rootId = doc.addBlock('affine:page', {
title: new Text(),
});
doc.addBlock('affine:surface', {}, rootId);
});
doc.resetHistory();
}
async function createEditor(
collection: TestWorkspace,
mode: DocMode = 'page',
extensions: ExtensionType[] = []
) {
const app = document.createElement('div');
const blockCollection = collection.docs.values().next().value;
assertExists(blockCollection, 'Need to create a doc first');
const doc = blockCollection.getStore();
const editor = new TestAffineEditorContainer();
editor.doc = doc;
editor.mode = mode;
editor.pageSpecs = [
...PageEditorBlockSpecs,
FontConfigExtension(CommunityCanvasTextFonts),
...extensions,
];
editor.edgelessSpecs = [
...EdgelessEditorBlockSpecs,
FontConfigExtension(CommunityCanvasTextFonts),
...extensions,
];
app.append(editor);
window.editor = editor;
window.doc = doc;
app.style.width = '100%';
app.style.height = '1280px';
app.style.overflowY = 'auto';
document.body.append(app);
await editor.updateComplete;
return app;
}
export async function setupEditor(
mode: DocMode = 'page',
extensions: ExtensionType[] = []
) {
const collection = new TestWorkspace(createCollectionOptions());
collection.storeExtensions = StoreExtensions;
collection.meta.initialize();
window.collection = collection;
initCollection(collection);
const appElement = await createEditor(collection, mode, extensions);
return () => {
appElement.remove();
cleanup();
};
}
export function cleanup() {
window.editor.remove();
delete (window as any).collection;
delete (window as any).editor;
delete (window as any).doc;
}
declare global {
const editor: TestAffineEditorContainer;
const doc: Store;
const collection: TestWorkspace;
const job: Transformer;
interface Window {
editor: TestAffineEditorContainer;
doc: Store;
job: Transformer;
collection: TestWorkspace;
}
}

View File

@@ -0,0 +1,220 @@
import { BlockStdScope, ShadowlessElement } from '@blocksuite/block-std';
import { type DocMode, ThemeProvider } from '@blocksuite/blocks';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import {
type BlockModel,
type ExtensionType,
type Store,
} from '@blocksuite/store';
import { computed, signal } from '@preact/signals-core';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import { keyed } from 'lit/directives/keyed.js';
import { when } from 'lit/directives/when.js';
export class TestAffineEditorContainer extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
.affine-page-viewport {
position: relative;
display: flex;
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
container-name: viewport;
container-type: inline-size;
font-family: var(--affine-font-family);
}
.affine-page-viewport * {
box-sizing: border-box;
}
@media print {
.affine-page-viewport {
height: auto;
}
}
.playground-page-editor-container {
flex-grow: 1;
font-family: var(--affine-font-family);
display: block;
}
.playground-page-editor-container * {
box-sizing: border-box;
}
@media print {
.playground-page-editor-container {
height: auto;
}
}
.edgeless-editor-container {
font-family: var(--affine-font-family);
background: var(--affine-background-primary-color);
display: block;
height: 100%;
position: relative;
overflow: clip;
}
.edgeless-editor-container * {
box-sizing: border-box;
}
@media print {
.edgeless-editor-container {
height: auto;
}
}
.affine-edgeless-viewport {
display: block;
height: 100%;
position: relative;
overflow: clip;
container-name: viewport;
container-type: inline-size;
}
`;
private readonly _doc = signal<Store>();
private readonly _edgelessSpecs = signal<ExtensionType[]>([]);
private readonly _mode = signal<DocMode>('page');
private readonly _pageSpecs = signal<ExtensionType[]>([]);
private readonly _specs = computed(() =>
this._mode.value === 'page'
? this._pageSpecs.value
: this._edgelessSpecs.value
);
private readonly _std = computed(() => {
return new BlockStdScope({
store: this.doc,
extensions: this._specs.value,
});
});
private readonly _editorTemplate = computed(() => {
return this._std.value.render();
});
get doc() {
return this._doc.value as Store;
}
set doc(doc: Store) {
this._doc.value = doc;
}
set edgelessSpecs(specs: ExtensionType[]) {
this._edgelessSpecs.value = specs;
}
get edgelessSpecs() {
return this._edgelessSpecs.value;
}
get host() {
try {
return this.std.host;
} catch {
return null;
}
}
get mode() {
return this._mode.value;
}
set mode(mode: DocMode) {
this._mode.value = mode;
}
set pageSpecs(specs: ExtensionType[]) {
this._pageSpecs.value = specs;
}
get pageSpecs() {
return this._pageSpecs.value;
}
get rootModel() {
return this.doc.root as BlockModel;
}
get std() {
return this._std.value;
}
override connectedCallback() {
super.connectedCallback();
this._disposables.add(
this.doc.slots.rootAdded.on(() => this.requestUpdate())
);
}
override firstUpdated() {
if (this.mode === 'page') {
setTimeout(() => {
if (this.autofocus) {
const richText = this.querySelector('rich-text');
const inlineEditor = richText?.inlineEditor;
inlineEditor?.focusEnd();
}
});
}
}
override render() {
const mode = this._mode.value;
const themeService = this.std.get(ThemeProvider);
const appTheme = themeService.app$.value;
const edgelessTheme = themeService.edgeless$.value;
return html`${keyed(
this.rootModel.id + mode,
html`
<div
data-theme=${mode === 'page' ? appTheme : edgelessTheme}
class=${mode === 'page'
? 'affine-page-viewport'
: 'affine-edgeless-viewport'}
>
${when(
mode === 'page',
() => html` <doc-title .doc=${this.doc}></doc-title> `
)}
<div
class=${mode === 'page'
? 'page-editor playground-page-editor-container'
: 'edgeless-editor-container'}
>
${this._editorTemplate.value}
</div>
</div>
`
)}`;
}
switchEditor(mode: DocMode) {
this._mode.value = mode;
}
@property({ attribute: false })
override accessor autofocus = false;
}
declare global {
interface HTMLElementTagNameMap {
'affine-editor-container': TestAffineEditorContainer;
}
}

View File

@@ -0,0 +1 @@
export * from './editor-container.js';

View File

@@ -0,0 +1,7 @@
import '@blocksuite/blocks/effects';
import { TestAffineEditorContainer } from './editors/index.js';
export function effects() {
customElements.define('affine-editor-container', TestAffineEditorContainer);
}

View File

@@ -0,0 +1 @@
export * from './editors';

View File

@@ -0,0 +1,21 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src", "./src/**/*.json"],
"references": [
{ "path": "../affine/block-note" },
{ "path": "../affine/block-surface" },
{ "path": "../affine/components" },
{ "path": "../affine/model" },
{ "path": "../affine/shared" },
{ "path": "../framework/block-std" },
{ "path": "../blocks" },
{ "path": "../framework/global" },
{ "path": "../framework/inline" },
{ "path": "../framework/store" }
]
}

View File

@@ -0,0 +1,40 @@
import { cpus } from 'node:os';
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
import { defineConfig } from 'vite';
import wasm from 'vite-plugin-wasm';
// https://vitejs.dev/config/
export default defineConfig(() => {
return {
plugins: [wasm(), vanillaExtractPlugin()],
esbuild: {
target: 'es2022',
},
resolve: {
extensions: ['.ts', '.js'],
},
server: {
host: true,
allowedHosts: true,
},
build: {
target: 'es2022',
sourcemap: true,
rollupOptions: {
cache: false,
maxParallelFileOps: Math.max(1, cpus().length - 1),
onwarn(warning, defaultHandler) {
if (
warning.code &&
['EVAL', 'SOURCEMAP_ERROR'].includes(warning.code)
) {
return;
}
defaultHandler(warning);
},
treeshake: true,
},
},
};
});

View File

@@ -0,0 +1,66 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
import { defineConfig } from 'vitest/config';
export default defineConfig(_configEnv =>
defineConfig({
esbuild: { target: 'es2018' },
optimizeDeps: {
force: true,
esbuildOptions: {
// Vitest hardcodes the esbuild target to es2020,
// override it to es2022 for top level await.
target: 'es2022',
},
},
plugins: [vanillaExtractPlugin()],
test: {
include: ['src/__tests__/**/*.spec.ts'],
browser: {
enabled: true,
headless: process.env.CI === 'true',
name: 'chromium',
provider: 'playwright',
isolate: false,
providerOptions: {},
viewport: {
width: 1024,
height: 768,
},
},
coverage: {
provider: 'istanbul', // or 'c8'
reporter: ['lcov'],
reportsDirectory: '../../.coverage/presets',
},
deps: {
interopDefault: true,
},
testTransformMode: {
web: ['src/__tests__/**/*.spec.ts'],
},
alias: {
'@blocksuite/blocks': path.resolve(
fileURLToPath(new URL('../blocks/src', import.meta.url))
),
'@blocksuite/blocks/*': path.resolve(
fileURLToPath(new URL('../blocks/src/*', import.meta.url))
),
'@blocksuite/global/*': path.resolve(
fileURLToPath(new URL('../framework/global/src/*', import.meta.url))
),
'@blocksuite/store': path.resolve(
fileURLToPath(new URL('../framework/store/src', import.meta.url))
),
'@blocksuite/inline': path.resolve(
fileURLToPath(new URL('../framework/inline/src', import.meta.url))
),
'@blocksuite/inline/*': path.resolve(
fileURLToPath(new URL('../framework/inline/src/*', import.meta.url))
),
},
},
})
);