snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 00:31:07 +05:00
commit 50f788e799
7907 changed files with 988785 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
# `@blocksuite/integration-test`
Integration test for BlockSuite.
## Running Tests
You can run all integration tests using:
```bash
cd blocksuite/integration-test
yarn test:unit
```
To run a specific test or test file, use the `-t` flag with a test name pattern:
```bash
# Run a specific test
yarn test:unit -t "should access turbo renderer instance"
# Run all tests in a specific file
yarn test:unit src/__tests__/edgeless/viewport-renderer.spec.ts
```
For debugging tests with the Playwright debugger:
```bash
yarn test:debug
yarn test:debug -t "should access turbo renderer instance"
```

View File

@@ -0,0 +1,45 @@
{
"name": "@blocksuite/integration-test",
"description": "Integration test for BlockSuite",
"type": "module",
"scripts": {
"dev": "vite",
"build": "yarn run -T tsc",
"test:unit": "vitest --browser.headless --run",
"test:debug": "PWDEBUG=1 npx vitest --browser.headless=false"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine": "workspace:*",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"lit": "^3.2.0",
"rxjs": "^7.8.2",
"yjs": "^13.6.27"
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts",
"./store": "./src/store.ts",
"./view": "./src/view.ts"
},
"files": [
"src",
"dist",
"themes",
"!src/__tests__",
"!dist/__tests__"
],
"devDependencies": {
"@vanilla-extract/vite-plugin": "^5.0.0",
"@vitest/browser-playwright": "^4.1.10",
"playwright": "=1.58.2",
"vite": "^7.3.5",
"vite-plugin-wasm": "^3.5.0",
"vitest": "^4.1.8"
},
"version": "0.27.5"
}

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 '@blocksuite/affine/gfx/pointer';
import type { EdgelessRootBlockComponent } from '@blocksuite/affine/blocks/root';
import { DefaultTool } from '@blocksuite/affine/blocks/surface';
import { ColorScheme } from '@blocksuite/affine/model';
import { ThemeProvider } from '@blocksuite/affine/shared/services';
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(DefaultTool);
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,213 @@
import { DomRenderer } from '@blocksuite/affine-block-surface';
import { beforeEach, describe, expect, test } from 'vitest';
import { wait } from '../utils/common.js';
import { getSurface } from '../utils/edgeless.js';
import { setupEditor } from '../utils/setup.js';
function hasConnectorPath(
surfaceView: ReturnType<typeof getSurface>,
connectorId: string
) {
const connector = surfaceView.model.getElementById(connectorId);
if (!connector || !('path' in connector)) return false;
const { path } = connector as { path: unknown };
return Array.isArray(path) && path.length >= 2;
}
async function waitForConnectorElement(
surfaceView: ReturnType<typeof getSurface>,
connectorId: string,
timeout = 5000
) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeout) {
if (!hasConnectorPath(surfaceView, connectorId)) {
await wait(50);
continue;
}
if (surfaceView.renderer instanceof DomRenderer) {
surfaceView.renderer.markElementDirty(connectorId);
surfaceView.renderer.forceFullRender();
}
const connectorElement = surfaceView.renderRoot.querySelector<HTMLElement>(
`[data-element-id="${connectorId}"]`
);
if (connectorElement) return connectorElement;
await wait(50);
}
return null;
}
async function waitForConnectorElementRemoval(
surfaceView: ReturnType<typeof getSurface>,
connectorId: string,
timeout = 5000
) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeout) {
const connectorElement = surfaceView.renderRoot.querySelector(
`[data-element-id="${connectorId}"]`
);
if (!connectorElement) return true;
await wait(50);
}
return false;
}
describe('Connector rendering with DOM renderer', () => {
beforeEach(async () => {
const cleanup = await setupEditor('edgeless', [], {
enableDomRenderer: true,
});
return cleanup;
});
test('should use DomRenderer when enable_dom_renderer flag is true', async () => {
const surface = getSurface(doc, editor);
expect(surface).not.toBeNull();
expect(surface?.renderer).toBeInstanceOf(DomRenderer);
});
test('should render a connector element as a DOM node', async () => {
const surfaceView = getSurface(window.doc, window.editor);
const surfaceModel = surfaceView.model;
// Create two shapes to connect
const shape1Id = surfaceModel.addElement({
type: 'shape',
xywh: '[100, 100, 80, 60]',
});
const shape2Id = surfaceModel.addElement({
type: 'shape',
xywh: '[300, 200, 80, 60]',
});
// Create a connector between the shapes
const connectorProps = {
type: 'connector',
source: { id: shape1Id },
target: { id: shape2Id },
stroke: '#000000',
strokeWidth: 2,
};
const connectorId = surfaceModel.addElement(connectorProps);
const connectorElement = await waitForConnectorElement(
surfaceView,
connectorId
);
expect(connectorElement).not.toBeNull();
expect(connectorElement).toBeInstanceOf(HTMLElement);
// Check if SVG element is present for connector rendering
const svgElement = connectorElement?.querySelector('svg');
expect(svgElement).not.toBeNull();
});
test('should render connector with different stroke styles', async () => {
const surfaceView = getSurface(window.doc, window.editor);
const surfaceModel = surfaceView.model;
// Create a dashed connector
const connectorProps = {
type: 'connector',
source: { position: [100, 100] },
target: { position: [200, 200] },
strokeStyle: 'dash',
stroke: '#ff0000',
strokeWidth: 4,
};
const connectorId = surfaceModel.addElement(connectorProps);
const connectorElement = await waitForConnectorElement(
surfaceView,
connectorId
);
expect(connectorElement).not.toBeNull();
const svgElement = connectorElement?.querySelector('svg');
expect(svgElement).not.toBeNull();
// Find the main path element (not the ones inside markers)
const pathElements = svgElement?.querySelectorAll('path');
// The main connector path should be the last one (after marker paths)
const pathElement = pathElements?.[pathElements.length - 1];
expect(pathElement).not.toBeNull();
// Check stroke-dasharray attribute
const strokeDasharray = pathElement!.getAttribute('stroke-dasharray');
expect(strokeDasharray).toBe('12,12');
});
test('should render connector with arrow endpoints', async () => {
const surfaceView = getSurface(window.doc, window.editor);
const surfaceModel = surfaceView.model;
const connectorProps = {
type: 'connector',
source: { position: [100, 100] },
target: { position: [200, 200] },
frontEndpointStyle: 'Triangle',
rearEndpointStyle: 'Arrow',
};
const connectorId = surfaceModel.addElement(connectorProps);
const connectorElement = await waitForConnectorElement(
surfaceView,
connectorId
);
expect(connectorElement).not.toBeNull();
// Check for markers in defs
const defsElement = connectorElement?.querySelector('defs');
expect(defsElement).not.toBeNull();
const markers = defsElement?.querySelectorAll('marker');
expect(markers?.length).toBeGreaterThan(0);
});
test('should remove connector DOM node when element is deleted', async () => {
const surfaceView = getSurface(window.doc, window.editor);
const surfaceModel = surfaceView.model;
expect(surfaceView.renderer).toBeInstanceOf(DomRenderer);
const connectorProps = {
type: 'connector',
source: { position: [50, 50] },
target: { position: [150, 150] },
};
const connectorId = surfaceModel.addElement(connectorProps);
let connectorElement = await waitForConnectorElement(
surfaceView,
connectorId
);
expect(connectorElement).not.toBeNull();
surfaceModel.deleteElement(connectorId);
const removed = await waitForConnectorElementRemoval(
surfaceView,
connectorId
);
expect(removed).toBe(true);
});
});

View File

@@ -0,0 +1,133 @@
import type { FrameBlockComponent } from '@blocksuite/affine/blocks/frame';
import type { EdgelessRootBlockComponent } from '@blocksuite/affine/blocks/root';
import type { FrameBlockModel } from '@blocksuite/affine/model';
import type { AffineFrameTitleWidget } from '@blocksuite/affine/widgets/frame-title';
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 getFrameTitle = (frameId: string) => {
const frameTitleWidget = service.std.view.getWidget(
'affine-frame-title-widget',
frameId
) as AffineFrameTitleWidget | null;
return frameTitleWidget?.shadowRoot?.querySelector('affine-frame-title');
};
const frameTitle = 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.toModelCoordFromClientCoord([
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 = getFrameTitle(nestedFrame);
expect(nestedTitle).toBeTruthy();
if (!nestedTitle) return;
const nestedTitleRect = nestedTitle.getBoundingClientRect()!;
const [nestedTitleX, nestedTitleY] =
service.viewport.toModelCoordFromClientCoord([
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,395 @@
import type { EdgelessRootBlockComponent } from '@blocksuite/affine/blocks/root';
import {
type GroupElementModel,
LayoutType,
NoteDisplayMode,
} from '@blocksuite/affine/model';
import type { MindmapElementModel } from '@blocksuite/affine-model';
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',
});
if (!shapeId) {
throw new Error('shapeId is not found');
}
map.set(noteId, true);
map.set(shapeId, true);
const groupId = service.crud.addElement('group', { children: map });
if (!groupId) {
throw new Error('groupId is not found');
}
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]',
});
if (!shape1) {
throw new Error('shape1 is not found');
}
const shape2 = service.crud.addElement('shape', {
shapeType: 'rect',
xywh: '[100,100,100,100]',
});
if (!shape2) {
throw new Error('shape2 is not found');
}
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 });
if (!groupId) {
throw new Error('groupId is not found');
}
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 });
if (!groupId) {
throw new Error('groupId is not found');
}
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 });
if (!mindmapId) {
throw new Error('mindmapId is not found');
}
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,
});
if (!mindmapId) {
throw new Error('mindmapId is not found');
}
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,
});
if (!mindmapId) {
throw new Error('mindmapId is not found');
}
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,323 @@
import type { EdgelessRootBlockComponent } from '@blocksuite/affine/blocks/root';
import { getSurfaceBlock } from '@blocksuite/affine/blocks/surface';
import {
type BrushElementModel,
type ConnectorElementModel,
DEFAULT_NOTE_SHADOW,
DefaultTheme,
type EdgelessTextBlockModel,
FontFamily,
type FrameBlockModel,
LayoutType,
type MindmapElementModel,
MindmapStyle,
type NoteBlockModel,
NoteShadow,
type ShapeElementModel,
ShapeType,
type TextElementModel,
} from '@blocksuite/affine/model';
import { EditPropsStore } from '@blocksuite/affine/shared/services';
import type { BlockStdScope } from '@blocksuite/std';
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,
});
if (!rectId) {
throw new Error('rectId is not found');
}
const rectShape = service.crud.getElementById(rectId) as ShapeElementModel;
expect(rectShape.fillColor).toBe(DefaultTheme.shapeFillColor);
service.crud.updateElement(rectId, {
fillColor: DefaultTheme.FillColorShortMap.Orange,
});
expect(
std.get(EditPropsStore).lastProps$.value[`shape:${ShapeType.Rect}`]
.fillColor
).toBe(DefaultTheme.FillColorShortMap.Orange);
// diamond shape
const diamondId = service.crud.addElement('shape', {
shapeType: ShapeType.Diamond,
});
if (!diamondId) {
throw new Error('diamondId is not found');
}
const diamondShape = service.crud.getElementById(
diamondId
) as ShapeElementModel;
expect(diamondShape.fillColor).toBe(DefaultTheme.FillColorShortMap.Yellow);
service.crud.updateElement(diamondId, {
fillColor: DefaultTheme.FillColorShortMap.Blue,
});
expect(
std.get(EditPropsStore).lastProps$.value[`shape:${ShapeType.Diamond}`]
.fillColor
).toBe(DefaultTheme.FillColorShortMap.Blue);
// rounded rect shape
const roundedRectId = service.crud.addElement('shape', {
shapeType: ShapeType.Rect,
radius: 0.1,
});
if (!roundedRectId) {
throw new Error('roundedRectId is not found');
}
const roundedRectShape = service.crud.getElementById(
roundedRectId
) as ShapeElementModel;
expect(roundedRectShape.fillColor).toBe(
DefaultTheme.FillColorShortMap.Yellow
);
service.crud.updateElement(roundedRectId, {
fillColor: DefaultTheme.FillColorShortMap.Green,
});
expect(
std.get(EditPropsStore).lastProps$.value['shape:roundedRect'].fillColor
).toBe(DefaultTheme.FillColorShortMap.Green);
// apply last props
const rectId2 = service.crud.addElement('shape', {
shapeType: ShapeType.Rect,
});
if (!rectId2) {
throw new Error('rectId2 is not found');
}
const rectShape2 = service.crud.getElementById(
rectId2
) as ShapeElementModel;
expect(rectShape2.fillColor).toBe(DefaultTheme.FillColorShortMap.Orange);
const diamondId2 = service.crud.addElement('shape', {
shapeType: ShapeType.Diamond,
});
if (!diamondId2) {
throw new Error('diamondId2 is not found');
}
const diamondShape2 = service.crud.getElementById(
diamondId2
) as ShapeElementModel;
expect(diamondShape2.fillColor).toBe(DefaultTheme.FillColorShortMap.Blue);
const roundedRectId2 = service.crud.addElement('shape', {
shapeType: ShapeType.Rect,
radius: 0.1,
});
if (!roundedRectId2) {
throw new Error('roundedRectId2 is not found');
}
const roundedRectShape2 = service.crud.getElementById(
roundedRectId2
) as ShapeElementModel;
expect(roundedRectShape2.fillColor).toBe(
DefaultTheme.FillColorShortMap.Green
);
});
test('connector', () => {
const id = service.crud.addElement('connector', { mode: 0 });
if (!id) {
throw new Error('id is not found');
}
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 });
if (!id2) {
throw new Error('id2 is not found');
}
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 });
if (!id3) {
throw new Error('id3 is not found');
}
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', {});
if (!id) {
throw new Error('id is not found');
}
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', {});
if (!id) {
throw new Error('id is not found');
}
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', {});
if (!id) {
throw new Error('id is not found');
}
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', {});
if (!id2) {
throw new Error('id2 is not found');
}
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);
if (!id) {
throw new Error('id is not found');
}
const text = service.crud.getElementById(id) as EdgelessTextBlockModel;
expect(text.props.color).toStrictEqual(DefaultTheme.textColor);
expect(text.props.fontFamily).toBe(FontFamily.Inter);
service.crud.updateElement(id, {
color: DefaultTheme.StrokeColorShortMap.Green,
fontFamily: FontFamily.OrelegaOne,
});
const id2 = service.crud.addBlock('affine:edgeless-text', {}, surface!.id);
if (!id2) {
throw new Error('id2 is not found');
}
const text2 = service.crud.getElementById(id2) as EdgelessTextBlockModel;
expect(text2.props.color).toBe(DefaultTheme.StrokeColorShortMap.Green);
expect(text2.props.fontFamily).toBe(FontFamily.OrelegaOne);
});
test('note', () => {
const id = service.crud.addBlock('affine:note', {}, doc.root!.id);
if (!id) {
throw new Error('id is not found');
}
const note = service.crud.getElementById(id) as NoteBlockModel;
expect(note.props.background).toEqual(DefaultTheme.noteBackgrounColor);
expect(note.props.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);
if (!id2) {
throw new Error('id2 is not found');
}
const note2 = service.crud.getElementById(id2) as NoteBlockModel;
expect(note2.props.background).toEqual(
DefaultTheme.NoteBackgroundColorMap.Purple
);
expect(note2.props.edgeless.style.shadowType).toBe(NoteShadow.Film);
});
test('frame', () => {
const surface = getSurfaceBlock(doc);
const id = service.crud.addBlock('affine:frame', {}, surface!.id);
if (!id) {
throw new Error('id is not found');
}
const note = service.crud.getElementById(id) as FrameBlockModel;
expect(note.props.background).toBe('transparent');
service.crud.updateElement(id, {
background: DefaultTheme.StrokeColorShortMap.Purple,
});
const id2 = service.crud.addBlock('affine:frame', {}, surface!.id);
if (!id2) {
throw new Error('id2 is not found');
}
const frame2 = service.crud.getElementById(id2) as FrameBlockModel;
expect(frame2.props.background).toBe(
DefaultTheme.StrokeColorShortMap.Purple
);
service.crud.updateElement(id2, {
background: { normal: '#def4e740' },
});
const id3 = service.crud.addBlock('affine:frame', {}, surface!.id);
if (!id3) {
throw new Error('id3 is not found');
}
const frame3 = service.crud.getElementById(id3) as FrameBlockModel;
expect(frame3.props.background).toEqual({ normal: '#def4e740' });
service.crud.updateElement(id3, {
background: { light: '#a381aa23', dark: '#6e907452' },
});
const id4 = service.crud.addBlock('affine:frame', {}, surface!.id);
if (!id4) {
throw new Error('id4 is not found');
}
const frame4 = service.crud.getElementById(id4) as FrameBlockModel;
expect(frame4.props.background).toEqual({
light: '#a381aa23',
dark: '#6e907452',
});
});
});

View File

@@ -0,0 +1,966 @@
import type { EdgelessRootBlockComponent } from '@blocksuite/affine/blocks/root';
import type {
CanvasRenderer,
SurfaceElementModel,
} from '@blocksuite/affine/blocks/surface';
import { ungroupCommand } from '@blocksuite/affine/gfx/group';
import type {
GroupElementModel,
MindmapElementModel,
NoteBlockModel,
} from '@blocksuite/affine/model';
import { generateKeyBetween } from '@blocksuite/affine/std/gfx';
import type { BlockComponent } from '@blocksuite/std';
import type { BlockModel, Store } from '@blocksuite/store';
import { beforeEach, describe, expect, test } from 'vitest';
import * as Y from 'yjs';
import { wait } from '../utils/common.js';
import {
addNote as _addNote,
getDocRootBlock,
getSurface,
} from '../utils/edgeless.js';
import { setupEditor } from '../utils/setup.js';
let service!: EdgelessRootBlockComponent['service'];
const addNote = (doc: Store, props: Record<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);
await 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(5);
};
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();
});
test('block host z-index should update after reordering', async () => {
const backId = addNote(doc);
const frontId = addNote(doc);
await wait();
const getBlockHost = (id: string) =>
document.querySelector<HTMLElement>(
`affine-edgeless-root gfx-viewport > [data-block-id="${id}"]`
);
const backHost = getBlockHost(backId);
const frontHost = getBlockHost(frontId);
expect(backHost).not.toBeNull();
expect(frontHost).not.toBeNull();
expect(Number(backHost!.style.zIndex)).toBeLessThan(
Number(frontHost!.style.zIndex)
);
service.crud.updateElement(backId, {
index: service.layer.getReorderedIndex(
service.crud.getElementById(backId)!,
'front'
),
});
await wait();
expect(Number(backHost!.style.zIndex)).toBeGreaterThan(
Number(frontHost!.style.zIndex)
);
});
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',
xywh: '[0,0,100,100]',
})!,
addNote(doc),
service.crud.addElement('shape', {
shapeType: 'rect',
xywh: '[120,0,100,100]',
})!,
addNote(doc),
service.crud.addElement('shape', {
shapeType: 'rect',
xywh: '[240,0,100,100]',
})!,
];
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("change mindmap index should update its nodes' layer", async () => {
const noteId = addNote(doc);
const mindmapId = service.crud.addElement('mindmap', {
children: {
text: 'root',
children: [{ text: 'child' }],
},
})!;
await wait();
const note = service.crud.getElementById(noteId)!;
const mindmap = service.crud.getElementById(
mindmapId
)! as MindmapElementModel;
const root = mindmap.tree.element;
expect(service.layer.getZIndex(root)).toBeGreaterThan(
service.layer.getZIndex(note)
);
mindmap.index = service.layer.getReorderedIndex(mindmap, 'back');
await wait();
expect(service.layer.getZIndex(root)).toBeLessThan(
service.layer.getZIndex(note)
);
});
test('should keep relative index order of elements after group, ungroup, undo, redo', () => {
const edgeless = getDocRootBlock(doc, editor, 'edgeless');
const elementIds = [
service.crud.addElement('shape', {
shapeType: 'rect',
})!,
addNote(doc),
service.crud.addElement('shape', {
shapeType: 'rect',
})!,
addNote(doc),
service.crud.addElement('shape', {
shapeType: 'rect',
})!,
];
service.doc.captureSync();
const elements = elementIds.map(id => service.crud.getElementById(id)!);
const isKeptRelativeOrder = () => {
return elements.every((element, idx) => {
if (idx === 0) return true;
return elements[idx - 1].index < element.index;
});
};
expect(isKeptRelativeOrder()).toBeTruthy();
const groupId = createGroup(edgeless.service, elementIds)!;
expect(isKeptRelativeOrder()).toBeTruthy();
service.std.command.exec(ungroupCommand, {
group: service.crud.getElementById(groupId) as GroupElementModel,
});
expect(isKeptRelativeOrder()).toBeTruthy();
service.doc.undo();
expect(isKeptRelativeOrder()).toBeTruthy();
service.doc.redo();
expect(isKeptRelativeOrder()).toBeTruthy();
});
});
describe('compare function', () => {
const SORT_ORDER = {
AFTER: 1,
BEFORE: -1,
SAME: 0,
};
const createGroup = (
service: EdgelessRootBlockComponent['service'],
childIds: string[]
// oxlint-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',
xywh: '[0,0,100,100]',
})!;
addNote(doc);
await wait();
service.crud.addElement('shape', {
shapeType: 'rect',
xywh: '[120,0,100,100]',
})!;
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).toBeLessThanOrEqual(
(surface.renderer as CanvasRenderer).canvas.width
);
expect(indexedCanvas.height).toBeLessThanOrEqual(
(surface.renderer as CanvasRenderer).canvas.height
);
expect(indexedCanvas.width).not.toBe(0);
expect(indexedCanvas.height).not.toBe(0);
});
test('the actual rendering z-index should satisfy the logic order of their indexes', async () => {
editor.mode = 'page';
await wait();
const indexes = [
'ao',
'b0D',
'ar',
'as',
'at',
'au',
'av',
'b0Y',
'b0V',
'b0H',
'b0M',
'b0T',
'b0f',
'b0fV',
'b0g',
'b0i',
'b0fl',
];
indexes.forEach(index => {
addNote(doc, {
index,
});
});
await wait();
editor.mode = 'edgeless';
await wait(500);
const edgeless = getDocRootBlock(doc, editor, 'edgeless');
const blocks = Array.from(
edgeless.querySelectorAll('gfx-viewport > [data-block-id]')
) as BlockComponent[];
expect(blocks.length).toBe(indexes.length + 1);
blocks
.filter(block => block.flavour !== 'affine:surface')
.forEach((block, index) => {
if (index === blocks.length - 1) return;
const model = block.model as BlockModel<{ index: string }>;
const nextModel = blocks[index + 1].model as BlockModel<{
index: string;
}>;
const zIndex = Number(block.style.zIndex);
const nextZIndex = Number(blocks[index + 1].style.zIndex);
expect(model.props.index <= nextModel.props.index).equals(
zIndex <= nextZIndex
);
});
});
describe('index generator', () => {
let preinsertedShape: SurfaceElementModel;
let preinsertedNote: NoteBlockModel;
beforeEach(() => {
const shapeId = service.crud.addElement('shape', {
shapeType: 'rect',
})!;
const noteId = addNote(doc);
preinsertedShape = service.crud.getElementById(
shapeId
)! as SurfaceElementModel;
preinsertedNote = service.crud.getElementById(noteId)! as NoteBlockModel;
});
test('generator should remember the index it generated', () => {
const generator = service.layer.createIndexGenerator();
const shape1 = generator();
const block1 = generator();
const shape2 = generator();
const block2 = generator();
expect(block2 > shape2).toBeTruthy();
expect(shape2 > block1).toBeTruthy();
expect(block1 > shape1).toBeTruthy();
expect(shape1 > preinsertedNote.index).toBeTruthy();
expect(shape1 > preinsertedShape.index).toBeTruthy();
});
});

View File

@@ -0,0 +1,615 @@
import type { MindMapView } from '@blocksuite/affine/gfx/mindmap';
import { mountShapeTextEditor } from '@blocksuite/affine/gfx/shape';
import {
LayoutType,
type MindmapElementModel,
type ShapeElementModel,
} from '@blocksuite/affine-model';
import { Bound, deserializeXYWH } from '@blocksuite/global/gfx';
import type { GfxController } from '@blocksuite/std/gfx';
import { beforeEach, describe, expect, test, vi } 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('should update mindmap node size during IME composition', async () => {
const mindmapId = gfx.surface!.addElement({
type: 'mindmap',
children: { text: 'root', children: [{ text: 'leaf1' }] },
});
const mindmap = () => gfx.getElementById(mindmapId) as MindmapElementModel;
const root = getDocRootBlock(window.doc, window.editor, 'edgeless');
doc.captureSync();
await wait();
const rootNode = mindmap().tree.element as ShapeElementModel;
const updateSpy = vi.spyOn(gfx.surface!, 'updateElement');
mountShapeTextEditor(rootNode, root);
await wait();
const shapeEditor = root.querySelector('edgeless-shape-text-editor') as
| (HTMLElement & {
inlineEditorContainer?: HTMLElement;
richText?: HTMLElement;
})
| null;
expect(shapeEditor).not.toBeNull();
expect(shapeEditor?.richText).toBeTruthy();
expect(shapeEditor?.inlineEditorContainer).toBeTruthy();
expect(rootNode.maxWidth).toBe(512);
const initialWidth = mindmap().tree.element.w;
const initialHeight = mindmap().tree.element.h;
const exaggeratedScrollWidth = initialWidth + 1000;
const exaggeratedScrollHeight = initialHeight + 1000;
updateSpy.mockClear();
const preedit = shapeEditor!.inlineEditorContainer!.querySelector(
'[data-v-text="true"]'
);
expect(preedit).toBeTruthy();
shapeEditor!.inlineEditorContainer?.dispatchEvent(
new CompositionEvent('compositionstart', {
data: '',
bubbles: true,
})
);
preedit!.textContent =
'测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试';
Object.defineProperty(shapeEditor!.inlineEditorContainer!, 'scrollWidth', {
configurable: true,
get: () => exaggeratedScrollWidth,
});
Object.defineProperty(shapeEditor!.inlineEditorContainer!, 'scrollHeight', {
configurable: true,
get: () => exaggeratedScrollHeight,
});
const compositionUpdate = new CompositionEvent('compositionupdate', {
data: '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试',
bubbles: true,
});
shapeEditor!.inlineEditorContainer?.dispatchEvent(compositionUpdate);
await wait();
await wait();
const compositionWidths = updateSpy.mock.calls
.filter(
(call): call is [string, { xywh: string }] =>
call[0] === rootNode.id &&
typeof call[1] === 'object' &&
call[1] !== null &&
'xywh' in call[1] &&
typeof call[1].xywh === 'string'
)
.map(([, payload]) => deserializeXYWH(payload.xywh)[2]);
const compositionHeights = updateSpy.mock.calls
.filter(
(call): call is [string, { xywh: string }] =>
call[0] === rootNode.id &&
typeof call[1] === 'object' &&
call[1] !== null &&
'xywh' in call[1] &&
typeof call[1].xywh === 'string'
)
.map(([, payload]) => deserializeXYWH(payload.xywh)[3]);
expect(compositionWidths.length).toBeGreaterThan(0);
expect(Math.max(...compositionWidths)).toBeGreaterThan(initialWidth);
expect(Math.max(...compositionWidths)).toBeLessThanOrEqual(512);
expect(Math.max(...compositionWidths)).toBeLessThan(exaggeratedScrollWidth);
expect(Math.max(...compositionHeights)).toBeGreaterThan(initialHeight);
expect(Math.max(...compositionHeights)).toBeLessThan(
exaggeratedScrollHeight
);
const compositionEnd = new CompositionEvent('compositionend', {
data: '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试',
bubbles: true,
});
shapeEditor!.inlineEditorContainer?.dispatchEvent(compositionEnd);
await wait();
});
test('should wrap long mindmap editor content by max width instead of viewport', async () => {
const text = 'abcdefghijklmnopqrstuvwxyz'.repeat(20);
const mindmapId = gfx.surface!.addElement({
type: 'mindmap',
children: { text, children: [{ text: 'leaf1' }] },
});
const mindmap = () => gfx.getElementById(mindmapId) as MindmapElementModel;
const root = getDocRootBlock(window.doc, window.editor, 'edgeless');
doc.captureSync();
await wait();
const rootNode = mindmap().tree.element as ShapeElementModel;
gfx.viewport.setViewport(1, [
-gfx.viewport.width / 2 + 48,
gfx.viewport.height / 2,
]);
await wait();
mountShapeTextEditor(rootNode, root);
await wait();
const shapeEditor = root.querySelector('edgeless-shape-text-editor') as
| (HTMLElement & {
richText?: HTMLElement;
})
| null;
expect(shapeEditor?.richText).toBeTruthy();
const richText = shapeEditor!.richText!;
const viewLeft = gfx.viewport.toViewCoord(rootNode.x, rootNode.y)[0];
expect(viewLeft).toBeGreaterThan(gfx.viewport.width - 64);
expect(rootNode.maxWidth).toBe(512);
expect(rootNode.w).toBeLessThanOrEqual(512);
expect(richText.clientWidth).toBeGreaterThan(400);
expect(richText.clientWidth).toBeLessThanOrEqual(512);
expect(richText.scrollWidth).toBeLessThanOrEqual(richText.clientWidth + 1);
});
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,158 @@
import { DomRenderer } from '@blocksuite/affine-block-surface';
import { beforeEach, describe, expect, test } from 'vitest';
import { wait } from '../utils/common.js';
import { getSurface } from '../utils/edgeless.js';
import { setupEditor } from '../utils/setup.js';
function expectPxCloseTo(
value: string,
expected: number,
precision: number = 2
) {
expect(Number.parseFloat(value)).toBeCloseTo(expected, precision);
}
async function waitForShapeElement(
surfaceView: ReturnType<typeof getSurface>,
shapeId: string,
timeout = 5000
) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeout) {
if (surfaceView.renderer instanceof DomRenderer) {
surfaceView.renderer.markElementDirty(shapeId);
surfaceView.renderer.forceFullRender();
}
const shapeElement = surfaceView.renderRoot.querySelector<HTMLElement>(
`[data-element-id="${shapeId}"]`
);
if (shapeElement) return shapeElement;
await wait(50);
}
return null;
}
describe('Shape rendering with DOM renderer', () => {
beforeEach(async () => {
const cleanup = await setupEditor('edgeless', [], {
enableDomRenderer: true,
});
return cleanup;
});
test('should use DomRenderer when enable_dom_renderer flag is true', async () => {
const surface = getSurface(doc, editor);
expect(surface).not.toBeNull();
expect(surface?.renderer).toBeInstanceOf(DomRenderer);
});
test('should render a shape element as a DOM node', async () => {
const surfaceView = getSurface(window.doc, window.editor);
const surfaceModel = surfaceView.model;
const shapeProps = {
type: 'shape',
subType: 'rectangle',
xywh: '[150, 150, 80, 60]',
fill: '#ff0000',
stroke: '#000000',
};
const shapeId = surfaceModel.addElement(shapeProps);
const shapeElement = await waitForShapeElement(surfaceView, shapeId);
expect(shapeElement).not.toBeNull();
expect(shapeElement).toBeInstanceOf(HTMLElement);
});
test('should correctly apply percentage-based border radius', async () => {
const surfaceView = getSurface(window.doc, window.editor);
const surfaceModel = surfaceView.model;
const shapeProps = {
type: 'shape',
subType: 'rectangle',
xywh: '[150, 150, 80, 60]', // width: 80, height: 60
radius: 0.1, // 10% of min(width, height) = 10% of 60 = 6
fill: '#ff0000',
stroke: '#000000',
};
const shapeId = surfaceModel.addElement(shapeProps);
const shapeElement = await waitForShapeElement(surfaceView, shapeId);
expect(shapeElement).not.toBeNull();
const zoom = surfaceView.renderer.viewport.zoom;
expectPxCloseTo(shapeElement!.style.borderRadius, 6 * zoom);
});
test('should remove shape DOM node when element is deleted', async () => {
const surfaceView = getSurface(window.doc, window.editor);
const surfaceModel = surfaceView.model;
expect(surfaceView.renderer).toBeInstanceOf(DomRenderer);
const shapeProps = {
type: 'shape',
subType: 'ellipse',
xywh: '[200, 200, 50, 50]',
};
const shapeId = surfaceModel.addElement(shapeProps);
let shapeElement = await waitForShapeElement(surfaceView, shapeId);
expect(shapeElement).not.toBeNull();
surfaceModel.deleteElement(shapeId);
await wait(100);
shapeElement = surfaceView.renderRoot.querySelector(
`[data-element-id="${shapeId}"]`
);
expect(shapeElement).toBeNull();
});
test('should correctly render diamond shape', async () => {
const surfaceView = getSurface(window.doc, window.editor);
const surfaceModel = surfaceView.model;
const shapeProps = {
type: 'shape',
subType: 'diamond',
xywh: '[150, 150, 80, 60]',
fillColor: '#ff0000',
strokeColor: '#000000',
filled: true,
};
const shapeId = surfaceModel.addElement(shapeProps);
const shapeElement = await waitForShapeElement(surfaceView, shapeId);
expect(shapeElement).not.toBeNull();
const zoom = surfaceView.renderer.viewport.zoom;
expectPxCloseTo(shapeElement!.style.width, 80 * zoom);
expectPxCloseTo(shapeElement!.style.height, 60 * zoom);
});
test('should correctly render triangle shape', async () => {
const surfaceView = getSurface(window.doc, window.editor);
const surfaceModel = surfaceView.model;
const shapeProps = {
type: 'shape',
subType: 'triangle',
xywh: '[150, 150, 80, 60]',
fillColor: '#ff0000',
strokeColor: '#000000',
filled: true,
};
const shapeId = surfaceModel.addElement(shapeProps);
const shapeElement = await waitForShapeElement(surfaceView, shapeId);
expect(shapeElement).not.toBeNull();
const zoom = surfaceView.renderer.viewport.zoom;
expectPxCloseTo(shapeElement!.style.width, 80 * zoom);
expectPxCloseTo(shapeElement!.style.height, 60 * zoom);
});
});

View File

@@ -0,0 +1,378 @@
import type { SurfaceBlockModel } from '@blocksuite/affine/blocks/surface';
import type {
BrushElementModel,
ConnectorElementModel,
GroupElementModel,
} from '@blocksuite/affine/model';
import { serializeXYWH } from '@blocksuite/global/gfx';
import { beforeEach, describe, expect, test } 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.getModelsByFlavour(
'affine:surface'
) as SurfaceBlockModel[];
model = models[0];
return cleanup;
});
describe('group', () => {
test('empty group should have all zero xywh', () => {
const id = model.addElement({
type: 'group',
});
const group = model.getElementById(id)! as GroupElementModel;
expect(group.x).toBe(0);
expect(group.y).toBe(0);
expect(group.w).toBe(0);
expect(group.h).toBe(0);
});
test('should get group', () => {
const id = model.addElement({
type: 'shape',
});
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.store.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]);
});
test('group xywh should update when child xywh changes', () => {
const shapeId = model.addElement({
type: 'shape',
xywh: serializeXYWH(0, 0, 100, 100),
});
const groupId = model.addElement({
type: 'group',
children: {
[shapeId]: true,
},
});
const group = model.getElementById(groupId) as GroupElementModel;
expect(group.xywh).toBe(serializeXYWH(0, 0, 100, 100));
model.updateElement(shapeId, {
xywh: serializeXYWH(50, 60, 100, 100),
});
expect(group.xywh).toBe(serializeXYWH(50, 60, 100, 100));
});
});
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]);
expect((connector as ConnectorElementModel).forceFullRender).toBe(true);
});
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);
expect((connector as ConnectorElementModel).forceFullRender).toBe(true);
expect((connector2 as ConnectorElementModel).forceFullRender).toBe(true);
});
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,
},
});
const connectorBeforeUpdate = model.getElementById(connectorId)!;
expect(
(connectorBeforeUpdate as ConnectorElementModel).forceFullRender
).toBe(true);
model.updateElement(connectorId, {
source: {
position: [0, 0],
},
target: {
position: [0, 0],
},
});
expect(model.getConnectors(id)).toEqual([]);
expect(model.getConnectors(id2)).toEqual([]);
});
test('should update endpoint index when connector retargets', () => {
const id = model.addElement({
type: 'shape',
});
const id2 = model.addElement({
type: 'shape',
});
const id3 = 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(c => c.id)).toEqual([connector.id]);
expect(model.getConnectors(id2).map(c => c.id)).toEqual([connector.id]);
model.updateElement(connectorId, {
source: {
id: id3,
},
target: {
id: id2,
},
});
expect(model.getConnectors(id)).toEqual([]);
expect(model.getConnectors(id3).map(c => c.id)).toEqual([connector.id]);
expect(model.getConnectors(id2).map(c => c.id)).toEqual([connector.id]);
});
test('getConnectors should purge stale connector ids from endpoint cache', () => {
const shapeId = model.addElement({
type: 'shape',
});
const surfaceModel = model as any;
surfaceModel._connectorIdsByEndpoint.set(
shapeId,
new Set(['missing-connector-id'])
);
surfaceModel._connectorEndpoints.set('missing-connector-id', {
sourceId: shapeId,
targetId: null,
});
expect(model.getConnectors(shapeId)).toEqual([]);
expect(
surfaceModel._connectorIdsByEndpoint
.get(shapeId)
?.has('missing-connector-id') ?? false
).toBe(false);
expect(surfaceModel._connectorEndpoints.has('missing-connector-id')).toBe(
false
);
});
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,
},
});
const connectorBeforeDelete = model.getElementById(connectorId)!;
expect(
(connectorBeforeDelete as ConnectorElementModel).forceFullRender
).toBe(true);
model.deleteElement(connectorId);
await wait();
expect(model.getConnectors(id)).toEqual([]);
expect(model.getConnectors(id2)).toEqual([]);
});
});
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,293 @@
import {
type EdgelessRootBlockComponent,
EdgelessRootService,
} from '@blocksuite/affine/blocks/root';
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(),
})!;
await wait(0); // wait next frame
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,
refFlavour: 'affine:frame',
},
noteAId
);
editor.mode = 'page';
await wait();
expect(
document.querySelector(
`affine-surface-ref[data-block-id="${surfaceRefId}"]`
)
).instanceOf(Element);
});
test('content in frame should be rendered in the correct order', async () => {
const surfaceRefId = doc.addBlock(
'affine:surface-ref',
{
reference: frameId,
refFlavour: 'affine:frame',
},
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,
refFlavour: 'group',
},
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,
refFlavour: 'affine:frame',
},
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,
refFlavour: 'group',
},
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,
refFlavour: 'affine:frame',
},
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,
refFlavour: 'group',
},
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 type { FrameBlockComponent } from '@blocksuite/affine/blocks/frame';
import type { SurfaceRefBlockComponent } from '@blocksuite/affine/blocks/surface-ref';
import snapshot from '../snapshots/edgeless/surface-ref.spec.ts/surface-ref.json' with { type: 'json' };
describe('clipboard', () => {
test('import surface-ref snapshot should render content correctly', async () => {
await setupEditor('page');
const newDoc = await importFromSnapshot(
doc.workspace,
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/affine/gfx/template';
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,96 @@
import type { EdgelessRootBlockComponent } from '@blocksuite/affine/blocks/root';
import {
DefaultTool,
type SurfaceBlockComponent,
} from '@blocksuite/affine/blocks/surface';
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(DefaultTool);
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,121 @@
/**
* Please refer to integration-test/README.md for commands to run tests.
*/
import { ParagraphLayoutHandlerExtension } from '@blocksuite/affine/blocks/paragraph';
import { noop } from '@blocksuite/affine/global/utils';
import {
TurboRendererConfigFactory,
ViewportTurboRendererExtension,
} from '@blocksuite/affine-gfx-turbo-renderer';
import { firstValueFrom } from 'rxjs';
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { wait } from '../utils/common.js';
import { addSampleNotes } from '../utils/doc-generator.js';
import {
createPainterWorker,
getRenderer,
setupEditor,
} from '../utils/setup.js';
const FRAME = 16;
describe('viewport turbo renderer', () => {
let cleanup: () => Promise<void>;
beforeEach(async () => {
cleanup = await setupEditor('edgeless', [
ParagraphLayoutHandlerExtension,
TurboRendererConfigFactory({
painterWorkerEntry: createPainterWorker,
}),
ViewportTurboRendererExtension,
]);
return cleanup;
});
afterEach(async () => await cleanup?.());
test('should access turbo renderer instance', async () => {
const renderer = getRenderer();
expect(renderer).toBeDefined();
expect(renderer instanceof ViewportTurboRendererExtension).toBe(true);
expect(renderer.canvas).toBeInstanceOf(HTMLCanvasElement);
});
test('initial state should be pending', async () => {
const renderer = getRenderer();
expect(renderer.state$.value).toBe('pending');
});
test.skip('zooming should change internal state and populate optimized block ids', async () => {
const renderer = getRenderer();
addSampleNotes(doc, 1);
await wait(FRAME);
expect(renderer.optimizedBlockIds.length).toBe(0);
renderer.viewport.zooming$.next(true);
const nextState = await firstValueFrom(renderer.state$);
expect(nextState).toBe('zooming');
const canUseCache = renderer.canUseBitmapCache();
expect(canUseCache).toBe(false);
await renderer.refresh();
await wait(FRAME);
expect(renderer.optimizedBlockIds.length).toBe(1);
renderer.viewport.zooming$.next(false);
await wait(renderer.options.debounceTime + 100);
expect(renderer.state$.value).not.toBe('zooming');
expect(renderer.optimizedBlockIds.length).toBe(0);
});
test.skip('state transitions between pending and ready', async () => {
const renderer = getRenderer();
addSampleNotes(doc, 1);
await wait(FRAME);
expect(renderer.state$.value).toBe('pending');
// Ensure zooming is off and wait for debounce + buffer
renderer.viewport.zooming$.next(false);
await wait(renderer.options.debounceTime + 500);
expect(renderer.state$.value).toBe('ready');
});
test('initial layout cache data should be null', () => {
const renderer = getRenderer();
expect(renderer.layoutCacheData).toBeNull();
});
test('invalidation should reset layout cache data to null', async () => {
const renderer = getRenderer();
addSampleNotes(doc, 1);
await wait(100);
// Access getter to populate cache
const _cache = renderer.layoutCache;
noop(_cache);
expect(renderer.layoutCacheData).not.toBeNull();
// Invalidate
addSampleNotes(doc, 1);
await wait(100);
expect(renderer.layoutCacheData).toBeNull();
});
test('accessing layoutCache getter should populate cache data', async () => {
const renderer = getRenderer();
addSampleNotes(doc, 1);
await wait(FRAME);
expect(renderer.layoutCacheData).toBeNull();
const _cache = renderer.layoutCache;
noop(_cache);
expect(renderer.layoutCacheData).not.toBeNull();
expect(renderer.layoutCache?.roots.length).toBeGreaterThan(0);
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,395 @@
import { LinkExtension } from '@blocksuite/affine-inline-link';
import { textKeymap } from '@blocksuite/affine-inline-preset';
import type { AffineReference } from '@blocksuite/affine-inline-reference';
import type {
EmbedYoutubeModel,
ListBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
import { insertContent } from '@blocksuite/affine-rich-text';
import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts';
import { createDefaultDoc } from '@blocksuite/affine-shared/utils';
import { TextSelection } from '@blocksuite/std';
import type { InlineMarkdownMatch } from '@blocksuite/std/inline';
import { Text } from '@blocksuite/store';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defaultSlashMenuConfig } from '../../../../affine/widgets/slash-menu/src/config.js';
import type {
SlashMenuActionItem,
SlashMenuItem,
} from '../../../../affine/widgets/slash-menu/src/types.js';
import { wait } from '../utils/common.js';
import { addNote } from '../utils/edgeless.js';
import { setupEditor } from '../utils/setup.js';
type RichTextElement = HTMLElement & {
inlineEditor: {
getFormat: (range: {
index: number;
length: number;
}) => Record<string, unknown>;
getInlineRange: () => { index: number; length: number } | null;
setInlineRange: (range: { index: number; length: number }) => void;
yTextString: string;
};
markdownMatches: InlineMarkdownMatch[];
undoManager: {
stopCapturing: () => void;
};
};
function findSlashActionItem(
items: SlashMenuItem[],
name: string
): SlashMenuActionItem {
const item = items.find(entry => entry.name === name);
if (!item || !('action' in item)) {
throw new Error(`Cannot find slash-menu action: ${name}`);
}
return item;
}
function getRichTextByBlockId(blockId: string): RichTextElement {
const block = editor.host?.view.getBlock(blockId) as HTMLElement | null;
if (!block) {
throw new Error(`Cannot find block view: ${blockId}`);
}
const richText = block.querySelector('rich-text') as RichTextElement | null;
if (!richText) {
throw new Error(`Cannot find rich-text for block: ${blockId}`);
}
return richText;
}
async function createParagraph(text = '') {
const noteId = addNote(doc);
const note = doc.getBlock(noteId)?.model;
if (!note) {
throw new Error('Cannot find note model');
}
const paragraph = note.children[0] as ParagraphBlockModel | undefined;
if (!paragraph) {
throw new Error('Cannot find paragraph model');
}
if (text) {
doc.updateBlock(paragraph, {
text: new Text(text),
});
}
await wait();
return {
noteId,
paragraphId: paragraph.id,
};
}
function setTextSelection(blockId: string, index: number, length: number) {
const to = length
? {
blockId,
index: index + length,
length: 0,
}
: null;
const selection = editor.host?.selection.create(TextSelection, {
from: {
blockId,
index,
length: 0,
},
to,
});
if (!selection) {
throw new Error('Cannot create text selection');
}
editor.host?.selection.setGroup('note', [selection]);
const richText = getRichTextByBlockId(blockId);
richText.inlineEditor.setInlineRange({ index, length });
}
async function triggerMarkdown(
blockId: string,
input: string,
matcherName: string
) {
const model = doc.getBlock(blockId)?.model as ParagraphBlockModel | undefined;
if (!model) {
throw new Error(`Cannot find paragraph model: ${blockId}`);
}
doc.updateBlock(model, {
text: new Text(input),
});
await wait();
const richText = getRichTextByBlockId(blockId);
const matcher = richText.markdownMatches.find(
item => item.name === matcherName
);
if (!matcher) {
throw new Error(`Cannot find markdown matcher: ${matcherName}`);
}
const inlineRange = { index: input.length, length: 0 };
setTextSelection(blockId, inlineRange.index, 0);
matcher.action({
inlineEditor: richText.inlineEditor as any,
prefixText: input,
inlineRange,
pattern: matcher.pattern,
undoManager: richText.undoManager as any,
});
await wait();
}
function mockKeyboardContext() {
const preventDefault = vi.fn();
const ctx = {
get(key: string) {
if (key === 'keyboardState') {
return { raw: { preventDefault } };
}
throw new Error(`Unexpected state key: ${key}`);
},
};
return { ctx: ctx as any, preventDefault };
}
beforeEach(async () => {
const cleanup = await setupEditor('page', [LinkExtension]);
return cleanup;
});
test.each([false, true])(
'normalizes YouTube IDs without a mounted view (readonly: %s)',
readonly => {
const detached = createDefaultDoc(collection, { title: 'YouTube import' });
const note = detached.getModelsByFlavour('affine:note')[0];
const id = detached.addBlock(
'affine:embed-youtube',
{ url: 'https://youtu.be/first' },
note.id
);
const model = detached.getModelById(id) as EmbedYoutubeModel;
expect(model.props.videoId).toBe('first');
expect(model.yBlock.get('prop:videoId')).toBe('first');
detached.readonly = readonly;
model.yBlock.set('prop:url', 'https://www.youtube.com/watch?v=second');
expect(model.props.videoId).toBe(readonly ? 'first' : 'second');
detached.readonly = false;
}
);
describe('markdown/list/paragraph/quote/code/link', () => {
test('markdown list shortcut converts to todo list and keeps checked state', async () => {
const { noteId, paragraphId } = await createParagraph();
await triggerMarkdown(paragraphId, '[x] ', 'list');
const note = doc.getBlock(noteId)?.model;
if (!note) {
throw new Error('Cannot find note model');
}
const model = note.children[0] as ListBlockModel;
expect(model.flavour).toBe('affine:list');
expect(model.props.type).toBe('todo');
expect(model.props.checked).toBe(true);
});
test('markdown heading and quote shortcuts convert paragraph type', async () => {
const { noteId: headingNoteId, paragraphId: headingParagraphId } =
await createParagraph();
await triggerMarkdown(headingParagraphId, '# ', 'heading');
const headingNote = doc.getBlock(headingNoteId)?.model;
if (!headingNote) {
throw new Error('Cannot find heading note model');
}
const headingModel = headingNote.children[0] as ParagraphBlockModel;
expect(headingModel.flavour).toBe('affine:paragraph');
expect(headingModel.props.type).toBe('h1');
const { noteId: quoteNoteId, paragraphId: quoteParagraphId } =
await createParagraph();
await triggerMarkdown(quoteParagraphId, '> ', 'heading');
const quoteNote = doc.getBlock(quoteNoteId)?.model;
if (!quoteNote) {
throw new Error('Cannot find quote note model');
}
const quoteModel = quoteNote.children[0] as ParagraphBlockModel;
expect(quoteModel.flavour).toBe('affine:paragraph');
expect(quoteModel.props.type).toBe('quote');
});
test('markdown code shortcut converts paragraph to code block with language', async () => {
const { noteId, paragraphId } = await createParagraph();
await triggerMarkdown(paragraphId, '```ts ', 'code-block');
const note = doc.getBlock(noteId)?.model;
if (!note) {
throw new Error('Cannot find note model');
}
const model = note.children[0];
expect(model.flavour).toBe('affine:code');
expect((model as any).props.language).toBe('typescript');
});
test('inline markdown converts style and link attributes', async () => {
const { paragraphId: boldParagraphId } = await createParagraph();
await triggerMarkdown(boldParagraphId, '**bold** ', 'bold');
const boldRichText = getRichTextByBlockId(boldParagraphId);
expect(boldRichText.inlineEditor.yTextString).toBe('bold');
expect(
boldRichText.inlineEditor.getFormat({ index: 1, length: 0 })
).toMatchObject({
bold: true,
});
const { paragraphId: codeParagraphId } = await createParagraph();
await triggerMarkdown(codeParagraphId, '`code` ', 'code');
const codeRichText = getRichTextByBlockId(codeParagraphId);
expect(codeRichText.inlineEditor.yTextString).toBe('code');
expect(
codeRichText.inlineEditor.getFormat({ index: 1, length: 0 })
).toMatchObject({
code: true,
});
const { paragraphId: linkParagraphId } = await createParagraph();
await triggerMarkdown(
linkParagraphId,
'[AFFiNE](https://affine.pro) ',
'link'
);
const linkRichText = getRichTextByBlockId(linkParagraphId);
expect(linkRichText.inlineEditor.yTextString).toBe('AFFiNE');
expect(
linkRichText.inlineEditor.getFormat({ index: 1, length: 0 })
).toMatchObject({
link: 'https://affine.pro',
});
});
});
describe('hotkey/bracket/linked-page', () => {
test('bracket keymap skips redundant right bracket in code block', async () => {
const { noteId, paragraphId } = await createParagraph();
await triggerMarkdown(paragraphId, '```ts ', 'code-block');
const note = doc.getBlock(noteId)?.model;
const codeId = note?.children[0]?.id;
if (!codeId) {
throw new Error('Cannot find code block id');
}
const codeModel = doc.getBlock(codeId)?.model;
if (!codeModel) {
throw new Error('Cannot find code block model');
}
const keymap = textKeymap(editor.std);
const leftHandler = keymap['('];
const rightHandler = keymap[')'];
expect(leftHandler).toBeDefined();
if (!rightHandler) {
throw new Error('Cannot find bracket key handlers');
}
doc.updateBlock(codeModel, {
text: new Text('()'),
});
await wait();
const codeRichText = getRichTextByBlockId(codeId);
setTextSelection(codeId, 1, 0);
await wait();
const rightContext = mockKeyboardContext();
rightHandler(rightContext.ctx);
expect(codeRichText.inlineEditor.yTextString).toBe('()');
});
test('consecutive linked-page reference nodes render as separate references', async () => {
const { paragraphId } = await createParagraph();
const paragraphModel = doc.getBlock(paragraphId)?.model as
| ParagraphBlockModel
| undefined;
if (!paragraphModel) {
throw new Error('Cannot find paragraph model');
}
const linkedDoc = createDefaultDoc(collection, {
title: 'Linked page',
});
setTextSelection(paragraphId, 0, 0);
insertContent(editor.std, paragraphModel, REFERENCE_NODE, {
reference: {
type: 'LinkedPage',
pageId: linkedDoc.id,
},
});
insertContent(editor.std, paragraphModel, REFERENCE_NODE, {
reference: {
type: 'LinkedPage',
pageId: linkedDoc.id,
},
});
await wait();
expect(collection.docs.has(linkedDoc.id)).toBe(true);
const richText = getRichTextByBlockId(paragraphId);
expect(richText.querySelectorAll('affine-reference').length).toBe(2);
expect(richText.inlineEditor.yTextString.length).toBe(2);
collection.removeDoc(linkedDoc.id);
await wait();
expect(collection.docs.has(linkedDoc.id)).toBe(false);
const danglingReferences =
richText.querySelectorAll<AffineReference>('affine-reference');
expect(danglingReferences.length).toBe(2);
expect([...danglingReferences].every(reference => !reference.refMeta)).toBe(
true
);
});
});
describe('slash-menu action semantics', () => {
test('date and move actions mutate block content/order as expected', async () => {
const noteId = addNote(doc);
const note = doc.getBlock(noteId)?.model;
if (!note) {
throw new Error('Cannot find note model');
}
const first = note.children[0] as ParagraphBlockModel;
const secondId = doc.addBlock(
'affine:paragraph',
{ text: new Text('second') },
noteId
);
const second = doc.getBlock(secondId)?.model as
| ParagraphBlockModel
| undefined;
if (!second) {
throw new Error('Cannot find second paragraph model');
}
doc.updateBlock(first, { text: new Text('first') });
await wait();
const slashItems = defaultSlashMenuConfig.items;
const items =
typeof slashItems === 'function'
? slashItems({ std: editor.std, model: first })
: slashItems;
const today = findSlashActionItem(items, 'Today');
const moveDown = findSlashActionItem(items, 'Move Down');
const moveUp = findSlashActionItem(items, 'Move Up');
moveDown.action({ std: editor.std, model: first });
await wait();
expect(note.children.map(child => child.id)).toEqual([second.id, first.id]);
moveUp.action({ std: editor.std, model: first });
await wait();
expect(note.children.map(child => child.id)).toEqual([first.id, second.id]);
setTextSelection(first.id, 0, 0);
today.action({ std: editor.std, model: first });
await wait();
const richText = getRichTextByBlockId(first.id);
expect(richText.inlineEditor.yTextString).toMatch(/\d{4}-\d{2}-\d{2}/);
});
});

View File

@@ -0,0 +1,111 @@
import type { SurfaceBlockModel } from '@blocksuite/affine/blocks/surface';
import { AffineSchemas } from '@blocksuite/affine/schemas';
import { ZipTransformer } from '@blocksuite/affine/widgets/linked-doc';
import type { PointLocation } from '@blocksuite/global/gfx';
import { Schema } from '@blocksuite/store';
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 transformer = ZipTransformer;
const schema = new Schema();
schema.register(AffineSchemas);
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,
schema,
snapshotFile
);
if (!newDoc) {
throw new Error('Failed to import snapshot');
}
editor.doc = newDoc;
await wait();
const surface = newDoc.getModelsByFlavour(
'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/gfx';
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,
} from '@blocksuite/affine/blocks/root';
import type { SurfaceBlockComponent } from '@blocksuite/affine/blocks/surface';
import type { Store } from '@blocksuite/store';
import type { TestAffineEditorContainer } from '../../index.js';
export function getSurface(doc: Store, editor: TestAffineEditorContainer) {
const surfaceModel = doc.getModelsByFlavour('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,26 @@
import { AffineSchemas } from '@blocksuite/affine/schemas';
import { replaceIdMiddleware } from '@blocksuite/affine/shared/adapters';
import {
type DocSnapshot,
Schema,
Transformer,
type Workspace,
} from '@blocksuite/store';
export async function importFromSnapshot(
collection: Workspace,
snapshot: DocSnapshot
) {
const job = new Transformer({
schema: new Schema().register(AffineSchemas),
blobCRUD: collection.blobSync,
docCRUD: {
create: (id: string) => collection.createDoc(id).getStore({ id }),
get: (id: string) => collection.getDoc(id)?.getStore({ id }) ?? null,
delete: (id: string) => collection.removeDoc(id),
},
middlewares: [replaceIdMiddleware(collection.idGenerator)],
});
return job.snapshotToDoc(snapshot);
}

View File

@@ -0,0 +1,32 @@
import { ImageLayoutHandlerExtension } from '@blocksuite/affine/blocks/image';
import { ListLayoutHandlerExtension } from '@blocksuite/affine/blocks/list';
import { ParagraphLayoutHandlerExtension } from '@blocksuite/affine/blocks/paragraph';
import {
TurboRendererConfigFactory,
ViewportTurboRendererExtension,
ViewportTurboRendererIdentifier,
} from '@blocksuite/affine/gfx/turbo-renderer';
import { addSampleNotes } from './doc-generator.js';
import { createPainterWorker, setupEditor } from './setup.js';
async function init() {
await setupEditor('edgeless', [
ParagraphLayoutHandlerExtension,
ListLayoutHandlerExtension,
ImageLayoutHandlerExtension,
TurboRendererConfigFactory({
painterWorkerEntry: createPainterWorker,
}),
ViewportTurboRendererExtension,
]);
addSampleNotes(doc, 100);
doc.load();
const renderer = editor.std.get(
ViewportTurboRendererIdentifier
) as ViewportTurboRendererExtension;
window.renderer = renderer;
}
await init();

View File

@@ -0,0 +1,187 @@
import '@toeverything/theme/style.css';
import '@toeverything/theme/fonts.css';
import type { DocMode } from '@blocksuite/affine/model';
import { AffineSchemas } from '@blocksuite/affine/schemas';
import {
CommunityCanvasTextFonts,
FeatureFlagService,
FontConfigExtension,
} from '@blocksuite/affine/shared/services';
import {
type ViewportTurboRendererExtension,
ViewportTurboRendererIdentifier,
} from '@blocksuite/affine-gfx-turbo-renderer';
import type { ExtensionType, Store, Transformer } from '@blocksuite/store';
import { Schema, Text } from '@blocksuite/store';
import {
createAutoIncrementIdGenerator,
TestWorkspace,
} from '@blocksuite/store/test';
import { effects } from '../../effects.js';
import { TestAffineEditorContainer } from '../../index.js';
import { getTestStoreManager } from '../../store.js';
import { getTestViewManager } from '../../view.js';
const storeManager = getTestStoreManager();
const viewManager = getTestViewManager();
effects();
const storeExtensions = storeManager.get('store');
const painterWorkers = new Set<Worker>();
export function getRenderer() {
return editor.std.get(
ViewportTurboRendererIdentifier
) as ViewportTurboRendererExtension;
}
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,
};
}
function initCollection(collection: TestWorkspace) {
const doc = collection.createDoc('doc:home').getStore();
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;
if (!blockCollection) {
throw new Error('Need to create a doc first');
}
const doc = blockCollection.getStore();
const editor = new TestAffineEditorContainer();
editor.doc = doc;
editor.mode = mode;
editor.pageSpecs = [
...viewManager.get('page'),
FontConfigExtension(CommunityCanvasTextFonts),
...extensions,
];
editor.edgelessSpecs = [
...viewManager.get('edgeless'),
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 function createPainterWorker() {
const worker = new Worker(
new URL('./turbo-painter.worker.ts', import.meta.url),
{
type: 'module',
}
);
painterWorkers.add(worker);
return worker;
}
type SetupEditorOptions = {
extensions?: ExtensionType[];
enableDomRenderer?: boolean;
};
export async function setupEditor(
mode: DocMode = 'page',
extensionsInput?: ExtensionType[],
optionsInput?: SetupEditorOptions
) {
const extensions: ExtensionType[] = extensionsInput ?? [];
const options: SetupEditorOptions = optionsInput ?? {};
const enableDomRenderer = options?.enableDomRenderer ?? false;
await cleanup();
const collection = new TestWorkspace(createCollectionOptions());
collection.storeExtensions = storeExtensions;
collection.meta.initialize();
window.collection = collection;
initCollection(collection);
if (enableDomRenderer) {
const docStore = window.collection.docs.get('doc:home')?.getStore();
const featureFlagService = docStore?.get(FeatureFlagService);
featureFlagService?.setFlag('enable_dom_renderer', true);
}
const appElement = await createEditor(collection, mode, extensions);
return async () => {
await cleanup();
appElement?.remove();
};
}
export async function cleanup() {
window.editor?.remove();
await window.editor?.updateComplete;
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
for (const worker of painterWorkers) {
worker.terminate();
}
painterWorkers.clear();
delete (window as any).collection;
delete (window as any).editor;
delete (window as any).doc;
delete (window as any).renderer;
delete (window as any).store;
}
declare global {
const editor: TestAffineEditorContainer;
const doc: Store;
const collection: TestWorkspace;
const job: Transformer;
interface Window {
editor: TestAffineEditorContainer;
doc: Store;
job: Transformer;
collection: TestWorkspace;
renderer: ViewportTurboRendererExtension;
}
}

View File

@@ -0,0 +1,12 @@
import { ImageLayoutPainterExtension } from '@blocksuite/affine-block-image/turbo-painter';
import { ListLayoutPainterExtension } from '@blocksuite/affine-block-list/turbo-painter';
import { NoteLayoutPainterExtension } from '@blocksuite/affine-block-note/turbo-painter';
import { ParagraphLayoutPainterExtension } from '@blocksuite/affine-block-paragraph/turbo-painter';
import { ViewportLayoutPainter } from '@blocksuite/affine-gfx-turbo-renderer/painter';
new ViewportLayoutPainter([
ParagraphLayoutPainterExtension,
ListLayoutPainterExtension,
NoteLayoutPainterExtension,
ImageLayoutPainterExtension,
]);

View File

@@ -0,0 +1,225 @@
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import type { DocMode } from '@blocksuite/affine/model';
import { ThemeProvider } from '@blocksuite/affine/shared/services';
import { BlockStdScope, ShadowlessElement } from '@blocksuite/affine/std';
import {
type BlockModel,
type ExtensionType,
type Store,
} from '@blocksuite/affine/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.subscribe(() => this.requestUpdate())
);
}
override firstUpdated() {
if (this.mode === 'page') {
setTimeout(() => {
if (this.autofocus && this.mode === 'page') {
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/affine/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,8 @@
import { StoreExtensionManager } from '@blocksuite/affine/ext-loader';
import { getInternalStoreExtensions } from '@blocksuite/affine/extensions/store';
const manager = new StoreExtensionManager(getInternalStoreExtensions());
export function getTestStoreManager() {
return manager;
}

View File

@@ -0,0 +1,8 @@
import { ViewExtensionManager } from '@blocksuite/affine/ext-loader';
import { getInternalViewExtensions } from '@blocksuite/affine/extensions/view';
const manager = new ViewExtensionManager(getInternalViewExtensions());
export function getTestViewManager() {
return manager;
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src", "./src/**/*.json"],
"references": [{ "path": "../affine/all" }]
}

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,46 @@
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
import { playwright } from '@vitest/browser-playwright';
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'],
fileParallelism: false,
retry: process.env.CI === 'true' ? 3 : 0,
browser: {
enabled: true,
headless: true,
instances: [
{ browser: 'chromium' },
{ browser: 'firefox' },
{ browser: 'webkit' },
],
provider: playwright(),
isolate: false,
viewport: {
width: 1024,
height: 768,
},
},
coverage: {
provider: 'istanbul',
reporter: ['lcov'],
reportsDirectory: '../../.coverage/integration-test',
},
deps: {
interopDefault: true,
},
},
})
);