chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,18 @@
import { beforeEach, expect, test } from 'vitest';
import { 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();
});
@@ -0,0 +1,114 @@
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');
expect(themeService.generateColorProperty({}, 'red')).toBe('red');
});
test('gets a color value', () => {
const themeService = edgeless.gfx.std.get(ThemeProvider);
expect(themeService.theme).toBe(ColorScheme.Light);
expect(themeService.getColorValue('--affine-transparent')).toBe(
'--affine-transparent'
);
expect(
themeService.getColorValue('--affine-transparent', 'transparent', true)
).toBe('transparent');
expect(
themeService.getColorValue('--affine-hover-color', 'transparent', true)
).toBe('rgba(0, 0, 0, 0.04)');
expect(
themeService.getColorValue('--affine-tooltip', undefined, true)
).toBe('rgba(0, 0, 0, 1)');
expect(
themeService.getColorValue(
{ dark: 'white', light: 'black' },
undefined,
true
)
).toBe('black');
expect(
themeService.getColorValue({ dark: 'white', light: '' }, undefined, true)
).toBe('transparent');
expect(
themeService.getColorValue({ normal: 'grey' }, undefined, true)
).toBe('grey');
themeService.theme$.value = ColorScheme.Dark;
expect(themeService.theme).toBe(ColorScheme.Dark);
expect(
themeService.getColorValue('--affine-hover-color', 'transparent', true)
).toEqual('rgba(255, 255, 255, 0.1)');
expect(
themeService.getColorValue('--affine-tooltip', undefined, true)
).toEqual('rgba(234, 234, 234, 1)'); // #eaeaea
});
});
@@ -0,0 +1,128 @@
import type {
AffineFrameTitleWidget,
EdgelessRootBlockComponent,
FrameBlockComponent,
FrameBlockModel,
} from '@blocksuite/blocks';
import { assertType } from '@blocksuite/global/utils';
import { Text } from '@blocksuite/store';
import { beforeEach, describe, expect, test } from 'vitest';
import { wait } from '../utils/common.js';
import { getDocRootBlock } from '../utils/edgeless.js';
import { setupEditor } from '../utils/setup.js';
describe('frame', () => {
let service!: EdgelessRootBlockComponent['service'];
beforeEach(async () => {
const cleanup = await setupEditor('edgeless');
service = getDocRootBlock(window.doc, window.editor, 'edgeless').service;
return cleanup;
});
test('frame should have title', async () => {
const frame = service.doc.addBlock(
'affine:frame',
{
xywh: '[0,0,300,300]',
title: new Text('Frame 1'),
},
service.surface.id
);
await wait();
const frameTitleWidget = service.std.view.getWidget(
'affine-frame-title-widget',
doc.root!.id
) as AffineFrameTitleWidget | null;
const frameTitle = frameTitleWidget?.getFrameTitle(frame);
const rect = frameTitle?.getBoundingClientRect();
expect(frameTitle).toBeTruthy();
expect(rect).toBeTruthy();
expect(rect!.width).toBeGreaterThan(0);
expect(rect!.height).toBeGreaterThan(0);
const [titleX, titleY] = service.viewport.toModelCoord(rect!.x, rect!.y);
expect(titleX).toBeCloseTo(0);
expect(titleY).toBeLessThan(0);
const nestedFrame = service.doc.addBlock(
'affine:frame',
{
xywh: '[20,20,200,200]',
title: new Text('Frame 2'),
},
service.surface.id
);
await wait();
const nestedTitle = frameTitleWidget?.getFrameTitle(nestedFrame);
expect(nestedTitle).toBeTruthy();
if (!nestedTitle) return;
const nestedTitleRect = nestedTitle.getBoundingClientRect()!;
const [nestedTitleX, nestedTitleY] = service.viewport.toModelCoord(
nestedTitleRect.x,
nestedTitleRect.y
);
expect(nestedTitleX).toBeGreaterThan(20);
expect(nestedTitleY).toBeGreaterThan(20);
});
test('frame should have externalXYWH after moving viewport to contains frame', async () => {
const frameId = service.doc.addBlock(
'affine:frame',
{
xywh: '[1800,1800,200,200]',
title: new Text('Frame 1'),
},
service.surface.id
);
await wait();
const frame = service.doc.getBlock(frameId);
expect(frame).toBeTruthy();
assertType<FrameBlockComponent>(frame);
service.viewport.setCenter(900, 900);
expect(frame?.model.externalXYWH).toBeDefined();
});
test('descendant of frame should not contain itself', async () => {
const frameIds = [1, 2, 3].map(i => {
return service.doc.addBlock(
'affine:frame',
{
xywh: '[0,0,300,300]',
title: new Text(`Frame ${i}`),
},
service.surface.id
);
});
await wait();
const frames = frameIds.map(
id => service.doc.getBlock(id)?.model as FrameBlockModel
);
frames.forEach(frame => {
expect(frame.descendantElements).toHaveLength(0);
});
frames[0].addChild(frames[1]);
frames[1].addChild(frames[2]);
frames[2].addChild(frames[0]);
await wait();
expect(frames[0].descendantElements).toHaveLength(2);
expect(frames[1].descendantElements).toHaveLength(1);
expect(frames[2].descendantElements).toHaveLength(0);
});
});
@@ -0,0 +1,367 @@
import type { MindmapElementModel } from '@blocksuite/affine-model';
import {
type EdgelessRootBlockComponent,
type GroupElementModel,
LayoutType,
NoteDisplayMode,
} from '@blocksuite/blocks';
import { DocCollection } from '@blocksuite/store';
import { beforeEach, describe, expect, test } from 'vitest';
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 DocCollection.Y.Map<boolean>();
const ids = Array.from({ length: 2 })
.map(() => {
const id = service.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.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 DocCollection.Y.Map<boolean>();
const doc = service.doc;
const noteId = addNote(doc);
const shapeId = service.addElement('shape', {
shapeType: 'rect',
});
map.set(noteId, true);
map.set(shapeId, true);
const groupId = service.addElement('group', { children: map });
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.addElement('shape', {
shapeType: 'rect',
xywh: '[0,0,100,100]',
});
const shape2 = service.addElement('shape', {
shapeType: 'rect',
xywh: '[100,100,100,100]',
});
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 DocCollection.Y.Map<boolean>();
children.set(shape1, true);
children.set(shape2, true);
children.set(note1, true);
const groupId = service.addElement('group', { children });
const group = service.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.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.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 DocCollection.Y.Map<boolean>();
const groupId = service.addElement('group', { children: map });
const group = service.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.addElement('group', {
children: new DocCollection.Y.Map<boolean>(),
});
});
const groups = groupIds.map(
id => service.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.addElement('mindmap', { children: tree });
const mindmap = () =>
service.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.addElement('mindmap', {
type: LayoutType.RIGHT,
children: tree,
});
const mindmap = () =>
service.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.addElement('mindmap', {
type: LayoutType.RIGHT,
children: tree,
});
const mindmap = () =>
service.getElementById(mindmapId) as MindmapElementModel;
doc.captureSync();
await wait();
// create a circular reference
doc.transact(() => {
const root = mindmap().tree;
const leaf3 = root.children[2];
const leaf4 = root.children[2].children[0];
mindmap().children.set(leaf3.id, {
index: leaf3.detail.index,
parent: leaf4.id,
});
});
doc.captureSync();
await wait();
// the circular referenced node should be removed
expect(mindmap().nodeMap.size).toBe(3);
});
});
@@ -0,0 +1,248 @@
import type { BlockStdScope } from '@blocksuite/block-std';
import {
type BrushElementModel,
type ConnectorElementModel,
DEFAULT_NOTE_BACKGROUND_COLOR,
DEFAULT_NOTE_SHADOW,
DEFAULT_TEXT_COLOR,
type EdgelessRootBlockComponent,
type EdgelessTextBlockModel,
EditPropsStore,
FontFamily,
FrameBackgroundColor,
type FrameBlockModel,
getSurfaceBlock,
LayoutType,
LineColor,
type MindmapElementModel,
MindmapStyle,
NoteBackgroundColor,
type NoteBlockModel,
NoteShadow,
type ShapeElementModel,
ShapeFillColor,
ShapeType,
type TextElementModel,
} from '@blocksuite/blocks';
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.addElement('shape', { shapeType: ShapeType.Rect });
const rectShape = service.getElementById(rectId) as ShapeElementModel;
expect(rectShape.fillColor).toBe(ShapeFillColor.Yellow);
service.updateElement(rectId, {
fillColor: ShapeFillColor.Orange,
});
expect(
std.get(EditPropsStore).lastProps$.value[`shape:${ShapeType.Rect}`]
.fillColor
).toBe(ShapeFillColor.Orange);
// diamond shape
const diamondId = service.addElement('shape', {
shapeType: ShapeType.Diamond,
});
const diamondShape = service.getElementById(diamondId) as ShapeElementModel;
expect(diamondShape.fillColor).toBe(ShapeFillColor.Yellow);
service.updateElement(diamondId, {
fillColor: ShapeFillColor.Blue,
});
expect(
std.get(EditPropsStore).lastProps$.value[`shape:${ShapeType.Diamond}`]
.fillColor
).toBe(ShapeFillColor.Blue);
// rounded rect shape
const roundedRectId = service.addElement('shape', {
shapeType: ShapeType.Rect,
radius: 0.1,
});
const roundedRectShape = service.getElementById(
roundedRectId
) as ShapeElementModel;
expect(roundedRectShape.fillColor).toBe(ShapeFillColor.Yellow);
service.updateElement(roundedRectId, {
fillColor: ShapeFillColor.Green,
});
expect(
std.get(EditPropsStore).lastProps$.value['shape:roundedRect'].fillColor
).toBe(ShapeFillColor.Green);
// apply last props
const rectId2 = service.addElement('shape', { shapeType: ShapeType.Rect });
const rectShape2 = service.getElementById(rectId2) as ShapeElementModel;
expect(rectShape2.fillColor).toBe(ShapeFillColor.Orange);
const diamondId2 = service.addElement('shape', {
shapeType: ShapeType.Diamond,
});
const diamondShape2 = service.getElementById(
diamondId2
) as ShapeElementModel;
expect(diamondShape2.fillColor).toBe(ShapeFillColor.Blue);
const roundedRectId2 = service.addElement('shape', {
shapeType: ShapeType.Rect,
radius: 0.1,
});
const roundedRectShape2 = service.getElementById(
roundedRectId2
) as ShapeElementModel;
expect(roundedRectShape2.fillColor).toBe(ShapeFillColor.Green);
});
test('connector', () => {
const id = service.addElement('connector', { mode: 0 });
const connector = service.getElementById(id) as ConnectorElementModel;
expect(connector.stroke).toBe(LineColor.Grey);
expect(connector.strokeWidth).toBe(2);
expect(connector.strokeStyle).toBe('solid');
expect(connector.frontEndpointStyle).toBe('None');
expect(connector.rearEndpointStyle).toBe('Arrow');
service.updateElement(id, { strokeWidth: 10 });
const id2 = service.addElement('connector', { mode: 1 });
const connector2 = service.getElementById(id2) as ConnectorElementModel;
expect(connector2.strokeWidth).toBe(10);
service.updateElement(id2, {
labelStyle: {
color: LineColor.Magenta,
fontFamily: FontFamily.Kalam,
},
});
const id3 = service.addElement('connector', { mode: 1 });
const connector3 = service.getElementById(id3) as ConnectorElementModel;
expect(connector3.strokeWidth).toBe(10);
expect(connector3.labelStyle.color).toBe(LineColor.Magenta);
expect(connector3.labelStyle.fontFamily).toBe(FontFamily.Kalam);
});
test('brush', () => {
const id = service.addElement('brush', {});
const brush = service.getElementById(id) as BrushElementModel;
expect(brush.color).toEqual({
dark: LineColor.White,
light: LineColor.Black,
});
expect(brush.lineWidth).toBe(4);
service.updateElement(id, { lineWidth: 10 });
const secondBrush = service.getElementById(
service.addElement('brush', {})
) as BrushElementModel;
expect(secondBrush.lineWidth).toBe(10);
});
test('text', () => {
const id = service.addElement('text', {});
const text = service.getElementById(id) as TextElementModel;
expect(text.fontSize).toBe(24);
service.updateElement(id, { fontSize: 36 });
const secondText = service.getElementById(
service.addElement('text', {})
) as TextElementModel;
expect(secondText.fontSize).toBe(36);
});
test('mindmap', () => {
const id = service.addElement('mindmap', {});
const mindmap = service.getElementById(id) as MindmapElementModel;
expect(mindmap.layoutType).toBe(LayoutType.RIGHT);
expect(mindmap.style).toBe(MindmapStyle.ONE);
service.updateElement(id, {
layoutType: LayoutType.BALANCE,
style: MindmapStyle.THREE,
});
const id2 = service.addElement('mindmap', {});
const mindmap2 = service.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.addBlock('affine:edgeless-text', {}, surface!.id);
const text = service.getElementById(id) as EdgelessTextBlockModel;
expect(text.color).toBe(DEFAULT_TEXT_COLOR);
expect(text.fontFamily).toBe(FontFamily.Inter);
service.updateElement(id, {
color: LineColor.Green,
fontFamily: FontFamily.OrelegaOne,
});
const id2 = service.addBlock('affine:edgeless-text', {}, surface!.id);
const text2 = service.getElementById(id2) as EdgelessTextBlockModel;
expect(text2.color).toBe(LineColor.Green);
expect(text2.fontFamily).toBe(FontFamily.OrelegaOne);
});
test('note', () => {
const id = service.addBlock('affine:note', {}, doc.root!.id);
const note = service.getElementById(id) as NoteBlockModel;
expect(note.background).toBe(DEFAULT_NOTE_BACKGROUND_COLOR);
expect(note.edgeless.style.shadowType).toBe(DEFAULT_NOTE_SHADOW);
service.updateElement(id, {
background: NoteBackgroundColor.Purple,
edgeless: {
style: {
shadowType: NoteShadow.Film,
},
},
});
const id2 = service.addBlock('affine:note', {}, doc.root!.id);
const note2 = service.getElementById(id2) as NoteBlockModel;
expect(note2.background).toBe(NoteBackgroundColor.Purple);
expect(note2.edgeless.style.shadowType).toBe(NoteShadow.Film);
});
test('frame', () => {
const surface = getSurfaceBlock(doc);
const id = service.addBlock('affine:frame', {}, surface!.id);
const note = service.getElementById(id) as FrameBlockModel;
expect(note.background).toBe('--affine-palette-transparent');
service.updateElement(id, {
background: FrameBackgroundColor.Purple,
});
const id2 = service.addBlock('affine:frame', {}, surface!.id);
const frame2 = service.getElementById(id2) as FrameBlockModel;
expect(frame2.background).toBe(FrameBackgroundColor.Purple);
service.updateElement(id, {
background: { normal: '#def4e740' },
});
const id3 = service.addBlock('affine:frame', {}, surface!.id);
const frame3 = service.getElementById(id3) as FrameBlockModel;
expect(frame3.background).toEqual({ normal: '#def4e740' });
service.updateElement(id, {
background: { light: '#a381aa23', dark: '#6e907452' },
});
const id4 = service.addBlock('affine:frame', {}, surface!.id);
const frame4 = service.getElementById(id4) as FrameBlockModel;
expect(frame4.background).toEqual({
light: '#a381aa23',
dark: '#6e907452',
});
});
});
@@ -0,0 +1,883 @@
import { CommonUtils } from '@blocksuite/affine-block-surface';
import type { BlockComponent } from '@blocksuite/block-std';
import type {
EdgelessRootBlockComponent,
GroupElementModel,
NoteBlockModel,
} from '@blocksuite/blocks';
import { type BlockModel, type Doc, DocCollection } from '@blocksuite/store';
import { beforeEach, describe, expect, test } from 'vitest';
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: Doc, 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.addElement('shape', {
shapeType: 'rect',
});
await wait();
expect(service.layer.layers.length).toBe(2);
});
test('add note, shape, note sequentially', async () => {
addNote(doc);
service.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.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.addElement('shape', {
shapeType: 'rect',
});
await wait();
service.updateElement(id, {
index: service.layer.getReorderedIndex(
service.getElementById(id)!,
'forward'
),
});
expect(service.layer.layers[service.layer.layers.length - 1].type).toBe(
'block'
);
service.updateElement(canvasElId, {
index: service.layer.getReorderedIndex(
service.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.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.addElement('shape', {
shapeType: 'rect',
});
addNote(doc);
service.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.addElement('shape', {
shapeType: 'rect',
});
addNote(doc);
service.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.getElementById(noteId);
addNote(doc);
service.addElement('shape', {
shapeType: 'rect',
});
const topShapeId = service.addElement('shape', {
shapeType: 'rect',
});
const topShape = service.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.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.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.addElement('shape', {
shapeType: 'rect',
index: CommonUtils.generateKeyBetween(
service.getElementById(blocks[1])!.index,
service.getElementById(blocks[2])!.index
),
});
await wait();
assertBlocksContent();
});
describe('layer reorder functionality', () => {
let ids: string[] = [];
beforeEach(() => {
ids = [
service.addElement('shape', {
shapeType: 'rect',
}),
addNote(doc),
service.addElement('shape', {
shapeType: 'rect',
}),
addNote(doc),
];
});
test('forward', async () => {
service.updateElement(ids[0], {
index: service.layer.getReorderedIndex(
service.getElementById(ids[0])!,
'forward'
),
});
expect(
service.layer.layers.findIndex(layer =>
layer.set.has(service.getElementById(ids[0]) as any)
)
).toBe(1);
expect(
service.layer.layers.findIndex(layer =>
layer.set.has(service.getElementById(ids[1]) as any)
)
).toBe(0);
await wait();
service.updateElement(ids[1], {
index: service.layer.getReorderedIndex(
service.getElementById(ids[1])!,
'forward'
),
});
expect(
service.layer.layers.findIndex(layer =>
layer.set.has(service.getElementById(ids[0]) as any)
)
).toBe(0);
expect(
service.layer.layers.findIndex(layer =>
layer.set.has(service.getElementById(ids[1]) as any)
)
).toBe(1);
});
test('front', async () => {
service.updateElement(ids[0], {
index: service.layer.getReorderedIndex(
service.getElementById(ids[0])!,
'front'
),
});
await wait();
expect(
service.layer.layers.findIndex(layer =>
layer.set.has(service.getElementById(ids[0]) as any)
)
).toBe(3);
service.updateElement(ids[1], {
index: service.layer.getReorderedIndex(
service.getElementById(ids[1])!,
'front'
),
});
expect(
service.layer.layers.findIndex(layer =>
layer.set.has(service.getElementById(ids[1]) as any)
)
).toBe(3);
});
test('backward', async () => {
service.updateElement(ids[3], {
index: service.layer.getReorderedIndex(
service.getElementById(ids[3])!,
'backward'
),
});
expect(
service.layer.layers.findIndex(layer =>
layer.set.has(service.getElementById(ids[3]) as any)
)
).toBe(1);
expect(
service.layer.layers.findIndex(layer =>
layer.set.has(service.getElementById(ids[2]) as any)
)
).toBe(2);
await wait();
service.updateElement(ids[2], {
index: service.layer.getReorderedIndex(
service.getElementById(ids[2])!,
'backward'
),
});
expect(
service.layer.layers.findIndex(layer =>
layer.set.has(service.getElementById(ids[3]) as any)
)
).toBe(3);
expect(
service.layer.layers.findIndex(layer =>
layer.set.has(service.getElementById(ids[2]) as any)
)
).toBe(2);
});
test('back', async () => {
service.updateElement(ids[3], {
index: service.layer.getReorderedIndex(
service.getElementById(ids[3])!,
'back'
),
});
expect(
service.layer.layers.findIndex(layer =>
layer.set.has(service.getElementById(ids[3]) as any)
)
).toBe(0);
await wait();
service.updateElement(ids[2], {
index: service.layer.getReorderedIndex(
service.getElementById(ids[2])!,
'back'
),
});
expect(
service.layer.layers.findIndex(layer =>
layer.set.has(service.getElementById(ids[2]) as any)
)
).toBe(0);
});
});
describe('group related functionality', () => {
const createGroup = (
service: EdgelessRootBlockComponent['service'],
childIds: string[]
) => {
const children = new DocCollection.Y.Map<boolean>();
childIds.forEach(id => children.set(id, true));
return service.addElement('group', {
children,
});
};
test("new added group should effect it children's layer", async () => {
const edgeless = getDocRootBlock(doc, editor, 'edgeless');
const elements = [
service.addElement('shape', {
shapeType: 'rect',
}),
addNote(doc),
service.addElement('shape', {
shapeType: 'rect',
}),
addNote(doc),
service.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.addElement('shape', {
shapeType: 'rect',
}),
addNote(doc),
service.addElement('shape', {
shapeType: 'rect',
}),
addNote(doc),
service.addElement('shape', {
shapeType: 'rect',
}),
];
const groupId = createGroup(
service,
elements.filter((_, idx) => idx !== 1 && idx !== 3)
);
const group = service.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.addElement('shape', {
shapeType: 'rect',
}),
addNote(doc),
service.addElement('shape', {
shapeType: 'rect',
}),
addNote(doc),
service.addElement('shape', {
shapeType: 'rect',
}),
];
service.doc.captureSync();
const elements = elementIds.map(id => service.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.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 DocCollection.Y.Map<boolean>();
childIds.forEach(id => children.set(id, true));
return service.addElement('group', {
children,
});
};
test('compare same element', () => {
const shapeId = service.addElement('shape', {
shapeType: 'rect',
});
const shapeEl = service.getElementById(shapeId)!;
expect(service.layer.compare(shapeEl, shapeEl)).toBe(SORT_ORDER.SAME);
const groupId = createGroup(service, [shapeId]);
const groupEl = service.getElementById(groupId)!;
expect(service.layer.compare(groupEl, groupEl)).toBe(SORT_ORDER.SAME);
const noteId = addNote(doc);
const note = service.getElementById(noteId)! as NoteBlockModel;
expect(service.layer.compare(note, note)).toBe(SORT_ORDER.SAME);
});
test('compare a group and its child', () => {
const shapeId = service.addElement('shape', {
shapeType: 'rect',
});
const shapeEl = service.getElementById(shapeId)!;
const noteId = addNote(doc);
const note = service.getElementById(noteId)! as NoteBlockModel;
const groupId = createGroup(service, [shapeId, noteId]);
const groupEl = service.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.addElement('shape', {
shapeType: 'rect',
});
const shape1 = service.getElementById(shape1Id)!;
const shape2Id = service.addElement('shape', {
shapeType: 'rect',
});
const shape2 = service.getElementById(shape2Id)!;
const note1Id = addNote(doc);
const note1 = service.getElementById(note1Id)! as NoteBlockModel;
const note2Id = addNote(doc);
const note2 = service.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.addElement('shape', {
shapeType: 'rect',
});
const shape2Id = service.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.getElementById(shape1Id)!;
const shape2 = service.getElementById(shape2Id)!;
const note1 = service.getElementById(note1Id)! as NoteBlockModel;
const note2 = service.getElementById(note2Id)! as NoteBlockModel;
const group1 = service.getElementById(group1Id)!;
const group2 = service.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.addElement('shape', {
shapeType: 'rect',
});
const groupANoteId = addNote(doc);
const groupAId = createGroup(service, [
createGroup(service, [groupAShapeId, groupANoteId]),
]);
const groupAShape = service.getElementById(groupAShapeId)!;
const groupANote = service.getElementById(groupANoteId)!;
const groupA = service.getElementById(groupAId)!;
const groupBShapeId = service.addElement('shape', {
shapeType: 'rect',
});
const groupBNoteId = addNote(doc);
const groupBId = createGroup(service, [
createGroup(service, [groupBShapeId, groupBNoteId]),
]);
const groupBShape = service.getElementById(groupBShapeId)!;
const groupBNote = service.getElementById(groupBNoteId)!;
const groupB = service.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.addElement('shape', {
shapeType: 'rect',
});
addNote(doc);
await wait();
service.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: BlockSuite.SurfaceElementModel;
let preinsertedNote: NoteBlockModel;
beforeEach(() => {
const shapeId = service.addElement('shape', {
shapeType: 'rect',
});
const noteId = addNote(doc);
preinsertedShape = service.getElementById(
shapeId
)! as BlockSuite.SurfaceElementModel;
preinsertedNote = service.getElementById(noteId)! as NoteBlockModel;
});
test('generator should remember the index it generated', () => {
const generator = service.layer.createIndexGenerator();
const shape1 = generator();
const block1 = generator();
const shape2 = generator();
const block2 = generator();
expect(block2 > shape2).toBeTruthy();
expect(shape2 > block1).toBeTruthy();
expect(block1 > shape1).toBeTruthy();
expect(shape1 > preinsertedNote.index).toBeTruthy();
expect(shape1 > preinsertedShape.index).toBeTruthy();
});
});
@@ -0,0 +1,109 @@
import type { EditorHost } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import {
type EdgelessRootBlockComponent,
type MindmapElementModel,
type MindmapSurfaceBlock,
MiniMindmapPreview,
} from '@blocksuite/blocks';
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('mind map', () => {
let edgeless!: EdgelessRootBlockComponent;
beforeEach(async () => {
const cleanup = await setupEditor('edgeless');
edgeless = getDocRootBlock(doc, editor, 'edgeless');
edgeless.gfx.tool.setTool('default');
return cleanup;
});
test('mind map preview', async () => {
const mindmapAnswer = `
- Mindmap
- Node 1
- Node 1.1
- Node 1.2
- Node 2
- Node 2.1
- Node 2.2
`;
const createPreview = (
host: EditorHost,
answer: string = mindmapAnswer
) => {
const mindmapPreview = new MiniMindmapPreview();
mindmapPreview.answer = answer;
mindmapPreview.host = host;
mindmapPreview.ctx = {
get() {
return {};
},
set() {},
};
document.body.append(mindmapPreview);
return mindmapPreview;
};
const miniMindMapPreview = createPreview(window.editor.host!);
await wait(50);
const miniMindMapSurface = miniMindMapPreview.renderRoot.querySelector(
'mini-mindmap-surface-block'
) as MindmapSurfaceBlock;
expect(miniMindMapSurface).not.toBeNull();
expect(miniMindMapSurface.renderer).toBeDefined();
expect(miniMindMapSurface.renderer?.canvas.isConnected).toBe(true);
expect(miniMindMapSurface.renderer?.canvas.width).toBeGreaterThan(0);
expect(miniMindMapSurface.renderer?.canvas.height).toBeGreaterThan(0);
expect(miniMindMapSurface.model.elementModels.length).toBe(8);
return () => {
miniMindMapPreview.remove();
};
});
test('new mind map should layout automatically', async () => {
const gfx = editor.std.get(GfxControllerIdentifier);
const mindmapId = gfx.surface!.addElement({
type: 'mindmap',
children: {
text: 'Main node',
children: [
{
text: 'Child node',
},
{
text: 'Second child node',
},
{
text: 'Third child node',
},
],
},
});
const mindmap = gfx.getElementById(mindmapId) as MindmapElementModel;
await wait(0);
const [child1, child2, child3] = mindmap.tree.children;
expect(mindmapId).not.toBeUndefined();
expect(mindmap.tree.children.length).toBe(3);
expect(mindmap.tree.element.x).toBeLessThan(child1.element.x);
expect(child1.element.x).toBe(child2.element.x);
expect(child2.element.x).toBe(child3.element.x);
expect(child1.element.y).toBeLessThan(child2.element.y);
expect(child2.element.y).toBeLessThan(child3.element.y);
});
});
@@ -0,0 +1,520 @@
import type {
BrushElementModel,
GroupElementModel,
ShapeElementModel,
SurfaceBlockModel,
} 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('--affine-palette-line-yellow');
expect(element.strokeWidth).toBe(4);
});
test('defined prop should not be overwritten by default value', () => {
const id = model.addElement({
type: 'shape',
strokeColor: '--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(
'--affine-palette-line-yellow'
);
element.strokeColor = '--affine-palette-line-black';
expect(element.yMap.get('strokeColor')).toBe('--affine-palette-line-black');
expect(element.strokeColor).toBe('--affine-palette-line-black');
});
});
describe('group', () => {
test('should get group', () => {
const id = model.addElement({
type: 'shape',
});
const id2 = model.addElement({
type: 'shape',
});
const groupId = model.addElement({
type: 'group',
children: {
[id]: true,
[id2]: true,
},
});
const group = model.getElementById(groupId);
const shape = model.getElementById(id)!;
const shape2 = model.getElementById(id2)!;
expect(group).not.toBe(null);
expect(model.getGroup(id)).toBe(group);
expect(model.getGroup(id2)).toBe(group);
expect(shape.group).toBe(group);
expect(shape2.group).toBe(group);
});
test('should return null if children property is updated', () => {
const id = model.addElement({
type: 'shape',
});
const id2 = model.addElement({
type: 'shape',
});
const id3 = model.addElement({
type: 'shape',
});
const groupId = model.addElement({
type: 'group',
children: {
[id]: true,
[id2]: true,
[id3]: true,
},
});
const group = model.getElementById(groupId) as GroupElementModel;
model.doc.transact(() => {
group.children.delete(id);
group.children.delete(id2);
});
expect(model.getElementById(groupId)).toBe(group);
expect(model.getGroup(id)).toBeNull();
expect(model.getGroup(id2)).toBeNull();
});
test('should return null if group are deleted', () => {
const id = model.addElement({
type: 'shape',
});
const id2 = model.addElement({
type: 'shape',
});
const groupId = model.addElement({
type: 'group',
children: {
[id]: true,
[id2]: true,
},
});
model.deleteElement(groupId);
expect(model.getGroup(id)).toBeNull();
expect(model.getGroup(id2)).toBeNull();
});
test('children can be updated with a plain object', () => {
const id = model.addElement({
type: 'shape',
});
const id2 = model.addElement({
type: 'shape',
});
const groupId = model.addElement({
type: 'group',
children: {
[id]: true,
[id2]: true,
},
});
const group = model.getElementById(groupId) as GroupElementModel;
model.updateElement(groupId, {
children: {
[id]: false,
},
});
expect(group.childIds).toEqual([id]);
});
});
describe('connector', () => {
test('should get connector', () => {
const id = model.addElement({
type: 'shape',
});
const id2 = model.addElement({
type: 'shape',
});
const connectorId = model.addElement({
type: 'connector',
source: {
id,
},
target: {
id: id2,
},
});
const connector = model.getElementById(connectorId)!;
expect(model.getConnectors(id).map(el => el.id)).toEqual([connector.id]);
expect(model.getConnectors(id2).map(el => el.id)).toEqual([connector.id]);
});
test('multiple connectors are supported', () => {
const id = model.addElement({
type: 'shape',
});
const id2 = model.addElement({
type: 'shape',
});
const connectorId = model.addElement({
type: 'connector',
source: {
id,
},
target: {
id: id2,
},
});
const connectorId2 = model.addElement({
type: 'connector',
source: {
id,
},
target: {
id: id2,
},
});
const connector = model.getElementById(connectorId)!;
const connector2 = model.getElementById(connectorId2)!;
const connectors = [connector.id, connector2.id];
expect(model.getConnectors(id).map(c => c.id)).toEqual(connectors);
expect(model.getConnectors(id2).map(c => c.id)).toEqual(connectors);
});
test('should return null if connector are updated', () => {
const id = model.addElement({
type: 'shape',
});
const id2 = model.addElement({
type: 'shape',
});
const connectorId = model.addElement({
type: 'connector',
source: {
id,
},
target: {
id: id2,
},
});
model.updateElement(connectorId, {
source: {
position: [0, 0],
},
target: {
position: [0, 0],
},
});
expect(model.getConnectors(id)).toEqual([]);
expect(model.getConnectors(id2)).toEqual([]);
});
test('should return null if connector are deleted', async () => {
const id = model.addElement({
type: 'shape',
});
const id2 = model.addElement({
type: 'shape',
});
const connectorId = model.addElement({
type: 'connector',
source: {
id,
},
target: {
id: id2,
},
});
model.deleteElement(connectorId);
await wait();
expect(model.getConnectors(id)).toEqual([]);
expect(model.getConnectors(id2)).toEqual([]);
});
});
describe('stash/pop', () => {
test('stash and pop should work correctly', () => {
const id = model.addElement({
type: 'shape',
strokeWidth: 4,
});
const elementModel = model.getElementById(id)! as ShapeElementModel;
expect(elementModel.strokeWidth).toBe(4);
elementModel.stash('strokeWidth');
elementModel.strokeWidth = 10;
expect(elementModel.strokeWidth).toBe(10);
expect(elementModel.yMap.get('strokeWidth')).toBe(4);
elementModel.pop('strokeWidth');
expect(elementModel.strokeWidth).toBe(10);
expect(elementModel.yMap.get('strokeWidth')).toBe(10);
elementModel.strokeWidth = 6;
expect(elementModel.strokeWidth).toBe(6);
expect(elementModel.yMap.get('strokeWidth')).toBe(6);
});
test('assign stashed property should emit event', () => {
const id = model.addElement({
type: 'shape',
strokeWidth: 4,
});
const elementModel = model.getElementById(id)! as ShapeElementModel;
elementModel.stash('strokeWidth');
const onchange = vi.fn();
model.elementUpdated.once(({ id }) => onchange(id));
elementModel.strokeWidth = 10;
expect(onchange).toHaveBeenCalledWith(id);
});
test('stashed property should also trigger derive decorator', () => {
const id = model.addElement({
type: 'brush',
points: [
[0, 0],
[100, 100],
[120, 150],
],
});
const elementModel = model.getElementById(id)! as BrushElementModel;
elementModel.stash('points');
elementModel.points = [
[0, 0],
[50, 50],
[135, 145],
[150, 170],
[200, 180],
];
const points = elementModel.points;
expect(elementModel.w).toBe(200 + elementModel.lineWidth);
expect(elementModel.h).toBe(180 + elementModel.lineWidth);
expect(elementModel.yMap.get('points')).not.toEqual(points);
expect(elementModel.w).toBe(200 + elementModel.lineWidth);
expect(elementModel.h).toBe(180 + elementModel.lineWidth);
});
test('non-field property should not allow stash/pop, and should failed silently ', () => {
const id = model.addElement({
type: 'group',
});
const elementModel = model.getElementById(id)! as GroupElementModel;
elementModel.stash('xywh');
elementModel.xywh = '[10,10,200,200]';
expect(elementModel['_stashed'].has('xywh')).toBe(false);
elementModel.pop('xywh');
expect(elementModel['_stashed'].has('xywh')).toBe(false);
expect(elementModel.yMap.has('xywh')).toBe(false);
});
});
describe('derive decorator', () => {
test('derived decorator should work correctly', () => {
const id = model.addElement({
type: 'brush',
points: [
[0, 0],
[100, 100],
[120, 150],
],
});
const elementModel = model.getElementById(id)! as BrushElementModel;
expect(elementModel.w).toBe(120 + elementModel.lineWidth);
expect(elementModel.h).toBe(150 + elementModel.lineWidth);
});
});
describe('local decorator', () => {
test('local decorator should work correctly', () => {
const id = model.addElement({
type: 'shape',
});
const elementModel = model.getElementById(id)! as BrushElementModel;
expect(elementModel.display).toBe(true);
elementModel.display = false;
expect(elementModel.display).toBe(false);
elementModel.opacity = 0.5;
expect(elementModel.opacity).toBe(0.5);
});
test('assign local property should emit event', () => {
const id = model.addElement({
type: 'shape',
});
const elementModel = model.getElementById(id)! as BrushElementModel;
const onchange = vi.fn();
model.elementUpdated.once(({ id }) => onchange(id));
elementModel.display = false;
expect(elementModel.display).toBe(false);
expect(onchange).toHaveBeenCalledWith(id);
});
});
describe('convert decorator', () => {
test('convert decorator', () => {
const id = model.addElement({
type: 'brush',
points: [
[50, 25],
[200, 200],
[300, 300],
],
});
const elementModel = model.getElementById(id)! as BrushElementModel;
const halfLineWidth = elementModel.lineWidth / 2;
const xOffset = 50 - halfLineWidth;
const yOffset = 25 - halfLineWidth;
expect(elementModel.points).toEqual([
[50 - xOffset, 25 - yOffset],
[200 - xOffset, 200 - yOffset],
[300 - xOffset, 300 - yOffset],
]);
});
});
describe('basic property', () => {
test('empty group should have all zero xywh', () => {
const id = model.addElement({
type: 'group',
});
const group = model.getElementById(id)! as GroupElementModel;
expect(group.x).toBe(0);
expect(group.y).toBe(0);
expect(group.w).toBe(0);
expect(group.h).toBe(0);
});
});
describe('brush', () => {
test('same lineWidth should have same xywh', () => {
const id = model.addElement({
type: 'brush',
lineWidth: 2,
points: [
[0, 0],
[100, 100],
[120, 150],
],
});
const brush = model.getElementById(id) as BrushElementModel;
const oldBrushXYWH = brush.xywh;
brush.lineWidth = 4;
expect(brush.xywh).not.toBe(oldBrushXYWH);
brush.lineWidth = 2;
expect(brush.xywh).toBe(oldBrushXYWH);
});
});
@@ -0,0 +1,307 @@
import {
type EdgelessRootBlockComponent,
EdgelessRootService,
type FrameBlockComponent,
type SurfaceRefBlockComponent,
} from '@blocksuite/blocks';
import type { DocSnapshot } from '@blocksuite/store';
import { beforeEach, describe, expect, test } from 'vitest';
import { wait } from '../utils/common.js';
import { addNote, getDocRootBlock } from '../utils/edgeless.js';
import { importFromSnapshot } from '../utils/misc.js';
import { setupEditor } from '../utils/setup.js';
describe('basic', () => {
let service: EdgelessRootBlockComponent['service'];
let edgelessRoot: EdgelessRootBlockComponent;
let noteAId = '';
let noteBId = '';
let shapeAId = '';
let shapeBId = '';
let frameId = '';
beforeEach(async () => {
const cleanup = await setupEditor('edgeless');
edgelessRoot = getDocRootBlock(doc, editor, 'edgeless');
service = edgelessRoot.service;
noteAId = addNote(doc, {
index: service.generateIndex(),
});
shapeAId = service.addElement('shape', {
type: 'rect',
xywh: '[0, 0, 100, 100]',
index: service.generateIndex(),
});
noteBId = addNote(doc, {
index: service.generateIndex(),
});
shapeBId = service.addElement('shape', {
type: 'rect',
xywh: '[100, 0, 100, 100]',
index: service.generateIndex(),
});
frameId = service.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.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.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.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.addElement('group', {
children: {
[shapeAId]: true,
[shapeBId]: true,
[noteAId]: true,
[noteBId]: true,
},
});
const surfaceRefId = doc.addBlock(
'affine:surface-ref',
{
reference: groupId,
},
noteAId
);
editor.mode = 'page';
await wait();
const surfaceRef = document.querySelector(
`affine-surface-ref[data-block-id="${surfaceRefId}"]`
) as HTMLElement;
expect(surfaceRef).instanceOf(Element);
(surfaceRef as SurfaceRefBlockComponent).viewInEdgeless();
await wait();
});
});
import snapshot from '../snapshots/edgeless/surface-ref.spec.ts/surface-ref.json' assert { type: 'json' };
describe('clipboard', () => {
test('import surface-ref snapshot should render content correctly', async () => {
await setupEditor('page');
const pageRoot = getDocRootBlock(doc, editor, 'page');
const pageRootService = pageRoot.service;
const newDoc = await importFromSnapshot(
pageRootService.collection,
snapshot as DocSnapshot
);
expect(newDoc).toBeTruthy();
editor.doc = newDoc!;
await wait();
const surfaceRefs = newDoc!.getBlocksByFlavour('affine:surface-ref');
expect(surfaceRefs).toHaveLength(2);
const surfaceRefBlocks = surfaceRefs.map(({ id }) =>
editor.std.view.getBlock(id)
) as SurfaceRefBlockComponent[];
expect(surfaceRefBlocks[0].querySelector('.ref-placeholder')).toBeFalsy();
expect(surfaceRefBlocks[1].querySelector('.ref-placeholder')).toBeFalsy();
});
});
@@ -0,0 +1,43 @@
import {
EdgelessTemplatePanel,
type Template,
type TemplateManager,
} from '@blocksuite/blocks';
import { beforeEach, expect, test } from 'vitest';
import { setupEditor } from '../utils/setup.js';
beforeEach(async () => {
const cleanup = await setupEditor('edgeless');
return cleanup;
});
test('extension api', async () => {
const mockTemplate = {
name: 'Test',
type: 'template',
} as Template;
const customTemplate = {
list: () => {
return [mockTemplate];
},
categories: () => {
return ['custom'];
},
search: (_, __) => {
return [mockTemplate];
},
} satisfies TemplateManager;
EdgelessTemplatePanel.templates.extend(customTemplate);
const categories = await EdgelessTemplatePanel.templates.categories();
expect(categories).toContain('custom');
const templates = await EdgelessTemplatePanel.templates.list('any');
expect(templates).toContain(mockTemplate);
const searchTemplates = await EdgelessTemplatePanel.templates.search('any');
expect(searchTemplates).toContain(mockTemplate);
});
@@ -0,0 +1,95 @@
import type {
EdgelessRootBlockComponent,
SurfaceBlockComponent,
} from '@blocksuite/blocks';
import { beforeEach, describe, expect, test } from 'vitest';
import { click, drag, wait } from '../utils/common.js';
import { addNote, getDocRootBlock, getSurface } from '../utils/edgeless.js';
import { setupEditor } from '../utils/setup.js';
describe('default tool', () => {
let surface!: SurfaceBlockComponent;
let edgeless!: EdgelessRootBlockComponent;
let service!: EdgelessRootBlockComponent['service'];
beforeEach(async () => {
const cleanup = await setupEditor('edgeless');
edgeless = getDocRootBlock(doc, editor, 'edgeless');
surface = getSurface(window.doc, window.editor);
service = edgeless.service;
edgeless.gfx.tool.setTool('default');
return cleanup;
});
test('element click selection', async () => {
const id = service.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.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.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.getElementById(noteId)!;
const [x, y] = JSON.parse(element.xywh);
expect(x).toEqual(100);
expect(y).toEqual(100);
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { Doc, DocCollection, Job } from '@blocksuite/store';
import type { AffineEditorContainer } from '../index.js';
declare global {
const editor: AffineEditorContainer;
const doc: Doc;
const collection: DocCollection;
const job: Job;
interface Window {
editor: AffineEditorContainer;
doc: Doc;
job: Job;
collection: DocCollection;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

@@ -0,0 +1,101 @@
import type { 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 snapshotTest = async (snapshotUrl: string, elementsCount: number) => {
const pageService = window.editor.host!.std.getService('affine:page');
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.collection,
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 (fieldChecker[typeField] || fieldChecker[field]) {
const checker = fieldChecker[typeField] || fieldChecker[field];
expect(checker(value)).toBe(true);
} else {
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
);
});
@@ -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": []
}
]
}
]
}
}
@@ -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',
});
});
}
@@ -0,0 +1,52 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type {
EdgelessRootBlockComponent,
PageRootBlockComponent,
SurfaceBlockComponent,
} from '@blocksuite/blocks';
import type { Doc } from '@blocksuite/store';
import type { AffineEditorContainer } from '../../index.js';
export function getSurface(doc: Doc, editor: AffineEditorContainer) {
const surfaceModel = doc.getBlockByFlavour('affine:surface');
return editor.host!.view.getBlock(
surfaceModel[0]!.id
) as SurfaceBlockComponent;
}
export function getDocRootBlock(
doc: Doc,
editor: AffineEditorContainer,
mode: 'page'
): PageRootBlockComponent;
export function getDocRootBlock(
doc: Doc,
editor: AffineEditorContainer,
mode: 'edgeless'
): EdgelessRootBlockComponent;
export function getDocRootBlock(
doc: Doc,
editor: AffineEditorContainer,
_?: 'edgeless' | 'page'
) {
return editor.host!.view.getBlock(doc.root!.id) as
| EdgelessRootBlockComponent
| PageRootBlockComponent;
}
export function addNote(doc: Doc, 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;
}
@@ -0,0 +1,14 @@
import { replaceIdMiddleware } from '@blocksuite/blocks';
import { type DocCollection, type DocSnapshot, Job } from '@blocksuite/store';
export async function importFromSnapshot(
collection: DocCollection,
snapshot: DocSnapshot
) {
const job = new Job({
collection,
middlewares: [replaceIdMiddleware],
});
return job.snapshotToDoc(snapshot);
}
@@ -0,0 +1,111 @@
import { effects as blocksEffects } from '@blocksuite/blocks/effects';
import type { BlockCollection } from '@blocksuite/store';
import { effects } from '../../effects.js';
blocksEffects();
effects();
import {
CommunityCanvasTextFonts,
type DocMode,
FontConfigExtension,
} from '@blocksuite/blocks';
import { AffineSchemas } from '@blocksuite/blocks/schemas';
import { assertExists } from '@blocksuite/global/utils';
import {
DocCollection,
IdGeneratorType,
Schema,
Text,
} from '@blocksuite/store';
import { AffineEditorContainer } from '../../index.js';
function createCollectionOptions() {
const schema = new Schema();
const room = Math.random().toString(16).slice(2, 8);
schema.register(AffineSchemas);
const idGenerator: IdGeneratorType = IdGeneratorType.AutoIncrement; // works only in single user mode
return {
id: room,
schema,
idGenerator,
defaultFlags: {
enable_synced_doc_block: true,
enable_pie_menu: true,
readonly: {
'doc:home': false,
},
},
};
}
function initCollection(collection: DocCollection) {
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: DocCollection, mode: DocMode = 'page') {
const app = document.createElement('div');
const blockCollection = collection.docs.values().next().value as
| BlockCollection
| undefined;
assertExists(blockCollection, 'Need to create a doc first');
const doc = blockCollection.getDoc();
const editor = new AffineEditorContainer();
editor.doc = doc;
editor.mode = mode;
editor.pageSpecs = editor.pageSpecs.concat([
FontConfigExtension(CommunityCanvasTextFonts),
]);
editor.edgelessSpecs = editor.edgelessSpecs.concat([
FontConfigExtension(CommunityCanvasTextFonts),
]);
app.append(editor);
window.editor = editor;
window.doc = doc;
app.style.width = '100%';
app.style.height = '1280px';
document.body.append(app);
await editor.updateComplete;
return app;
}
export async function setupEditor(mode: DocMode = 'page') {
const collection = new DocCollection(createCollectionOptions());
collection.meta.initialize();
window.collection = collection;
initCollection(collection);
const appElement = await createEditor(collection, mode);
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;
}