diff --git a/blocksuite/affine/block-surface/src/view/mindmap.ts b/blocksuite/affine/block-surface/src/view/mindmap.ts index 453571bdd9..3e0036830f 100644 --- a/blocksuite/affine/block-surface/src/view/mindmap.ts +++ b/blocksuite/affine/block-surface/src/view/mindmap.ts @@ -25,7 +25,12 @@ export class MindMapView extends GfxElementModelView { } >(); - private _getCollapseButton(node: MindmapNode | string) { + /** + * + * @param node The mindmap node or its id to get the collapse button + * @returns + */ + getCollapseButton(node: MindmapNode | string) { const id = typeof node === 'string' ? node : node.id; return this._collapseButtons.get(`collapse-btn-${id}`); } @@ -149,7 +154,7 @@ export class MindMapView extends GfxElementModelView { elm?.id && this.model.children.has(elm.id) ) { - const button = this._getCollapseButton(elm.id); + const button = this.getCollapseButton(elm.id); if (!button) { return; @@ -164,7 +169,7 @@ export class MindMapView extends GfxElementModelView { private _updateButtonVisibility(node: string) { const latestNode = this.model.getNode(node); - const buttonModel = this._getCollapseButton(node); + const buttonModel = this.getCollapseButton(node); if (!buttonModel) { return; diff --git a/blocksuite/presets/package.json b/blocksuite/presets/package.json index e833132b11..03ffbd32ba 100644 --- a/blocksuite/presets/package.json +++ b/blocksuite/presets/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "build": "tsc", - "test:unit": "nx vite:test --browser.headless --run", + "test:unit": "vitest --browser.headless --run", "test:debug": "PWDEBUG=1 npx vitest" }, "sideEffects": false, @@ -37,5 +37,8 @@ "themes", "!src/__tests__", "!dist/__tests__" - ] + ], + "devDependencies": { + "vitest": "^2.1.8" + } } diff --git a/blocksuite/presets/src/__tests__/edgeless/mindmap.spec.ts b/blocksuite/presets/src/__tests__/edgeless/mindmap.spec.ts new file mode 100644 index 0000000000..7fe5c66d88 --- /dev/null +++ b/blocksuite/presets/src/__tests__/edgeless/mindmap.spec.ts @@ -0,0 +1,467 @@ +import type { MindmapElementModel } from '@blocksuite/affine-model'; +import type { GfxController } from '@blocksuite/block-std/gfx'; +import { LayoutType, type MindMapView } from '@blocksuite/blocks'; +import { Bound } from '@blocksuite/global/utils'; +import { beforeEach, describe, expect, test } from 'vitest'; + +import { click, pointermove, wait } from '../utils/common.js'; +import { getDocRootBlock } from '../utils/edgeless.js'; +import { setupEditor } from '../utils/setup.js'; + +describe('mindmap', () => { + let gfx: GfxController; + const moveAndClick = ({ x, y, w, h }: Bound) => { + const { left, top } = gfx.viewport; + x += left; + y += top; + + // trigger enter event + pointermove(editor.host!, { x: x + w / 2, y: y + h / 2 }); + // trigger move event + pointermove(editor.host!, { x: x + w / 2 + 1, y: y + h / 2 + 1 }); + click(editor.host!, { x: x + w / 2, y: y + h / 2 }); + }; + + const move = ({ x, y, w, h }: Bound) => { + x += gfx.viewport.left; + y += gfx.viewport.top; + + pointermove(editor.host!, { x: x + w / 2, y: y + h / 2 }); + }; + + beforeEach(async () => { + const cleanup = await setupEditor('edgeless'); + gfx = getDocRootBlock(window.doc, window.editor, 'edgeless').gfx; + + return cleanup; + }); + + test('delete the root node should remove all children', async () => { + const tree = { + text: 'root', + children: [ + { + text: 'leaf1', + }, + { + text: 'leaf2', + }, + { + text: 'leaf3', + children: [ + { + text: 'leaf4', + }, + ], + }, + ], + }; + const mindmapId = gfx.surface!.addElement({ + type: 'mindmap', + children: tree, + }); + const mindmap = () => gfx.getElementById(mindmapId) as MindmapElementModel; + + expect(gfx.surface!.elementModels.length).toBe(6); + doc.captureSync(); + + gfx.deleteElement(mindmap().tree.element); + await wait(); + expect(gfx.surface!.elementModels.length).toBe(0); + doc.captureSync(); + await wait(); + + doc.undo(); + expect(gfx.surface!.elementModels.length).toBe(6); + await wait(); + + gfx.deleteElement(mindmap().tree.children[2].element); + await wait(); + expect(gfx.surface!.elementModels.length).toBe(4); + await wait(); + + doc.undo(); + await wait(); + expect(gfx.surface!.elementModels.length).toBe(6); + }); + + test('mindmap should layout automatically when creating', async () => { + const tree = { + text: 'root', + children: [ + { + text: 'leaf1', + }, + { + text: 'leaf2', + }, + { + text: 'leaf3', + children: [ + { + text: 'leaf4', + }, + ], + }, + ], + }; + const mindmapId = gfx.surface!.addElement({ + type: 'mindmap', + layoutType: LayoutType.RIGHT, + children: tree, + }); + const mindmap = () => gfx.getElementById(mindmapId) as MindmapElementModel; + + doc.captureSync(); + await wait(); + + const root = mindmap().tree.element; + const children = mindmap().tree.children.map(child => child.element); + const leaf4 = mindmap().tree.children[2].children[0].element; + + expect(children[0].x).greaterThan(root.x + root.w); + expect(children[1].x).greaterThan(root.x + root.w); + expect(children[2].x).greaterThan(root.x + root.w); + + expect(children[1].y).greaterThan(children[0].y + children[0].h); + expect(children[2].y).greaterThan(children[1].y + children[1].h); + + expect(leaf4.x).greaterThan(children[2].x + children[2].w); + }); + + test('deliberately creating a circular reference should be resolved correctly', async () => { + const tree = { + text: 'root', + children: [ + { + text: 'leaf1', + }, + { + text: 'leaf2', + }, + { + text: 'leaf3', + children: [ + { + text: 'leaf4', + }, + ], + }, + ], + }; + const mindmapId = gfx.surface!.addElement({ + type: 'mindmap', + layoutType: LayoutType.RIGHT, + children: tree, + }); + const mindmap = () => gfx.getElementById(mindmapId) as MindmapElementModel; + + doc.captureSync(); + await wait(); + + // create a circular reference + doc.transact(() => { + const root = mindmap().tree; + const leaf3 = root.children[2]; + const leaf4 = root.children[2].children[0]; + + mindmap().children.set(leaf3.id, { + index: leaf3.detail.index, + parent: leaf4.id, + }); + }); + doc.captureSync(); + + await wait(); + + // the circular referenced node should be removed + expect(mindmap().nodeMap.size).toBe(3); + }); + + test('mindmap collapse and expand should work correctly', async () => { + const tree = { + text: 'root', + children: [ + { + text: 'leaf1', + }, + { + text: 'leaf2', + }, + { + text: 'leaf3', + children: [ + { + text: 'leaf4', + }, + ], + }, + ], + }; + + // click to active the editor + click(editor.host!, { x: 50, y: 50 }); + + const mindmapId = gfx.surface!.addElement({ + type: 'mindmap', + layoutType: LayoutType.RIGHT, + children: tree, + }); + const mindmap = () => gfx.getElementById(mindmapId) as MindmapElementModel; + const mindmapView = () => gfx.view.get(mindmapId) as MindMapView; + + doc.captureSync(); + await wait(100); + + // collapse the root node + { + const rootButton = mindmapView().getCollapseButton(mindmap().tree)!; + + moveAndClick(gfx.viewport.toViewBound(rootButton.elementBound)); + await wait(); + + 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(); + + 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)); + + doc.captureSync(); + await wait(); + + const rootButton = mindmapView().getCollapseButton(mindmap().tree)!; + // collapse root node + moveAndClick(gfx.viewport.toViewBound(rootButton.elementBound)); + await wait(); + + // 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); + }); +}); diff --git a/blocksuite/presets/vitest.config.ts b/blocksuite/presets/vitest.config.ts index 9c15ac7432..4bbafd8780 100644 --- a/blocksuite/presets/vitest.config.ts +++ b/blocksuite/presets/vitest.config.ts @@ -23,6 +23,10 @@ export default defineConfig(_configEnv => provider: 'playwright', isolate: false, providerOptions: {}, + viewport: { + width: 1024, + height: 768, + }, }, coverage: { provider: 'istanbul', // or 'c8' diff --git a/yarn.lock b/yarn.lock index 8187f0c8f2..dba66cd79c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3773,6 +3773,7 @@ __metadata: "@preact/signals-core": "npm:^1.8.0" "@toeverything/theme": "npm:^1.1.1" lit: "npm:^3.2.0" + vitest: "npm:^2.1.8" zod: "npm:^3.23.8" languageName: unknown linkType: soft