From 6be9e2d52c24439f2bde95dd8a997746cd118fa8 Mon Sep 17 00:00:00 2001
From: Hongtao Lye <hongtao.lye@toeverything.info>
Date: Mon, 9 Dec 2024 16:44:05 +0800
Subject: [PATCH] feat: add basic button render

---
 .../block-surface/src/commands/auto-align.ts  |   5 +-
 .../block-surface/src/element-model/index.ts  |   8 +-
 packages/affine/block-surface/src/index.ts    |   1 -
 .../src/renderer/canvas-renderer.ts           |  37 ++-
 .../src/renderer/elements/mindmap.ts          |  35 +-
 .../src/renderer/elements/shape/diamond.ts    |   7 +-
 .../src/renderer/elements/shape/ellipse.ts    |   7 +-
 .../src/renderer/elements/shape/index.ts      |  19 +-
 .../src/renderer/elements/shape/rect.ts       |   7 +-
 .../src/renderer/elements/shape/triangle.ts   |   7 +-
 .../src/renderer/elements/shape/utils.ts      |   3 +-
 .../affine/block-surface/src/surface-block.ts |  18 +-
 .../affine/block-surface/src/surface-spec.ts  |   2 +
 .../block-surface/src/utils/mindmap/layout.ts |  30 +-
 .../block-surface/src/utils/mindmap/utils.ts  |  37 +--
 .../block-surface/src/utils/update-xywh.ts    |   5 +-
 .../affine/block-surface/src/view/mindmap.ts  | 301 ++++++++++++++++++
 .../model/src/elements/mindmap/mindmap.ts     | 274 ++++++++++------
 .../model/src/elements/mindmap/style.ts       | 162 +++++++++-
 .../affine/model/src/elements/shape/shape.ts  |  79 +++++
 .../telemetry-service/telemetry-service.ts    |   2 +
 .../src/services/telemetry-service/types.ts   |   6 +
 .../src/_common/edgeless/mindmap/index.ts     |   2 +-
 .../auto-complete/edgeless-auto-complete.ts   |  67 ++--
 .../rects/edgeless-selected-rect.ts           |  10 +-
 .../root-block/edgeless/components/utils.ts   |  17 +-
 .../root-block/edgeless/edgeless-keyboard.ts  |  14 +-
 .../edgeless/edgeless-root-block.ts           |  21 +-
 .../edgeless/edgeless-root-service.ts         |   5 +-
 .../mind-map-ext/drag-utils.ts                |   6 +-
 .../mind-map-ext/indicator-overlay.ts         |   2 +-
 .../mind-map-ext/mind-map-ext.ts              |   4 +-
 .../edgeless/gfx-tool/default-tool.ts         |   4 +-
 .../mini-mindmap/surface-block.ts             |   3 +-
 .../framework/block-std/src/gfx/controller.ts |   4 +
 .../framework/block-std/src/gfx/cursor.ts     |  54 ++++
 packages/framework/block-std/src/gfx/grid.ts  |  20 +-
 packages/framework/block-std/src/gfx/index.ts |   1 +
 packages/framework/block-std/src/gfx/layer.ts |   6 +-
 .../framework/block-std/src/gfx/model/base.ts |  31 ++
 .../src/gfx/model/gfx-block-model.ts          |  25 +-
 .../src/gfx/model/surface/element-model.ts    |  27 +-
 .../gfx/model/surface/local-element-model.ts  |  12 +-
 .../src/gfx/model/surface/surface-model.ts    |   2 +-
 .../framework/block-std/src/gfx/view/view.ts  |  39 ++-
 .../framework/block-std/src/utils/layer.ts    |   2 +-
 .../framework/global/src/utils/model/bound.ts |  19 +-
 47 files changed, 1141 insertions(+), 308 deletions(-)
 create mode 100644 packages/affine/block-surface/src/view/mindmap.ts
 create mode 100644 packages/framework/block-std/src/gfx/cursor.ts

diff --git a/packages/affine/block-surface/src/commands/auto-align.ts b/packages/affine/block-surface/src/commands/auto-align.ts
index dc353e7145a0..e2c249ce72f3 100644
--- a/packages/affine/block-surface/src/commands/auto-align.ts
+++ b/packages/affine/block-surface/src/commands/auto-align.ts
@@ -4,13 +4,12 @@ import {
   ConnectorElementModel,
   EdgelessTextBlockModel,
   EmbedSyncedDocModel,
+  MindmapElementModel,
   NoteBlockModel,
 } from '@blocksuite/affine-model';
 import { Bound } from '@blocksuite/global/utils';
 import chunk from 'lodash.chunk';
 
-import { LayoutableMindmapElementModel } from '../utils/mindmap/utils.js';
-
 const ALIGN_HEIGHT = 200;
 const ALIGN_PADDING = 20;
 
@@ -121,7 +120,7 @@ function autoResizeElements(
   elements.forEach(ele => {
     if (
       ele instanceof ConnectorElementModel ||
-      ele instanceof LayoutableMindmapElementModel
+      ele instanceof MindmapElementModel
     ) {
       return;
     }
diff --git a/packages/affine/block-surface/src/element-model/index.ts b/packages/affine/block-surface/src/element-model/index.ts
index 4966b89d84e8..b5e98fbdee44 100644
--- a/packages/affine/block-surface/src/element-model/index.ts
+++ b/packages/affine/block-surface/src/element-model/index.ts
@@ -2,11 +2,11 @@ import {
   BrushElementModel,
   ConnectorElementModel,
   GroupElementModel,
+  MindmapElementModel,
   ShapeElementModel,
   TextElementModel,
 } from '@blocksuite/affine-model';
 
-import { LayoutableMindmapElementModel } from '../utils/mindmap/utils.js';
 import { SurfaceElementModel } from './base.js';
 
 export const elementsCtorMap = {
@@ -15,14 +15,14 @@ export const elementsCtorMap = {
   shape: ShapeElementModel,
   brush: BrushElementModel,
   text: TextElementModel,
-  mindmap: LayoutableMindmapElementModel,
+  mindmap: MindmapElementModel,
 };
 
 export {
   BrushElementModel,
   ConnectorElementModel,
   GroupElementModel,
-  LayoutableMindmapElementModel,
+  MindmapElementModel,
   ShapeElementModel,
   SurfaceElementModel,
   TextElementModel,
@@ -43,7 +43,7 @@ export type ElementModelMap = {
   ['connector']: ConnectorElementModel;
   ['text']: TextElementModel;
   ['group']: GroupElementModel;
-  ['mindmap']: LayoutableMindmapElementModel;
+  ['mindmap']: MindmapElementModel;
 };
 
 export function isCanvasElementType(type: string): type is CanvasElementType {
diff --git a/packages/affine/block-surface/src/index.ts b/packages/affine/block-surface/src/index.ts
index 977e5e0cf367..9bfc4b2f9acf 100644
--- a/packages/affine/block-surface/src/index.ts
+++ b/packages/affine/block-surface/src/index.ts
@@ -59,7 +59,6 @@ export {
   NODE_HORIZONTAL_SPACING,
   NODE_VERTICAL_SPACING,
 } from './utils/mindmap/layout.js';
-export { LayoutableMindmapElementModel } from './utils/mindmap/utils.js';
 export { RoughCanvas } from './utils/rough/canvas.js';
 
 import {
diff --git a/packages/affine/block-surface/src/renderer/canvas-renderer.ts b/packages/affine/block-surface/src/renderer/canvas-renderer.ts
index 4f8880ba6d2c..b4552edd5d14 100644
--- a/packages/affine/block-surface/src/renderer/canvas-renderer.ts
+++ b/packages/affine/block-surface/src/renderer/canvas-renderer.ts
@@ -1,6 +1,7 @@
 import type {
   GridManager,
   LayerManager,
+  SurfaceBlockModel,
   Viewport,
 } from '@blocksuite/block-std/gfx';
 import type { IBound } from '@blocksuite/global/utils';
@@ -37,6 +38,7 @@ type RendererOptions = {
   onStackingCanvasCreated?: (canvas: HTMLCanvasElement) => void;
   elementRenderers: Record<string, ElementRenderer>;
   gridManager: GridManager;
+  surfaceModel: SurfaceBlockModel;
 };
 
 export class CanvasRenderer {
@@ -90,6 +92,8 @@ export class CanvasRenderer {
     if (options.enableStackingCanvas) {
       this._initStackingCanvas(options.onStackingCanvasCreated);
     }
+
+    this._watchSurface(options.surfaceModel);
   }
 
   /**
@@ -274,10 +278,9 @@ export class CanvasRenderer {
       (this.grid.search(bound, {
         filter: ['canvas', 'local'],
       }) as SurfaceElementModel[]);
-    for (const element of elements) {
-      ctx.save();
 
-      const display = element.display ?? true;
+    for (const element of elements) {
+      const display = (element.display ?? true) && !element.hidden;
       if (display && intersects(getBoundWithRotation(element), bound)) {
         const renderFn =
           this.elementRenderers[
@@ -286,18 +289,18 @@ export class CanvasRenderer {
 
         if (!renderFn) {
           console.warn(`Cannot find renderer for ${element.type}`);
-          ctx.restore();
           continue;
         }
 
+        ctx.save();
+
         ctx.globalAlpha = element.opacity ?? 1;
         const dx = element.x - bound.x;
         const dy = element.y - bound.y;
 
         renderFn(element, ctx, matrix.translate(dx, dy), this, rc, bound);
+        ctx.restore();
       }
-
-      ctx.restore();
     }
 
     if (overLay) {
@@ -321,6 +324,28 @@ export class CanvasRenderer {
     this.refresh();
   }
 
+  private _watchSurface(surfaceModel: SurfaceBlockModel) {
+    const slots = [
+      'elementAdded',
+      'elementRemoved',
+      'localElementAdded',
+      'localElementDeleted',
+      'localElementUpdated',
+    ] as const;
+
+    slots.forEach(slotName => {
+      this._disposables.add(surfaceModel[slotName].on(() => this.refresh()));
+    });
+
+    this._disposables.add(
+      surfaceModel.elementUpdated.on(payload => {
+        // ignore externalXYWH update cause it's updated by the renderer
+        if (payload.props['externalXYWH']) return;
+        this.refresh();
+      })
+    );
+  }
+
   addOverlay(overlay: Overlay) {
     overlay.setRenderer(this);
     this._overlays.add(overlay);
diff --git a/packages/affine/block-surface/src/renderer/elements/mindmap.ts b/packages/affine/block-surface/src/renderer/elements/mindmap.ts
index a552db53755e..6f29b09b0b26 100644
--- a/packages/affine/block-surface/src/renderer/elements/mindmap.ts
+++ b/packages/affine/block-surface/src/renderer/elements/mindmap.ts
@@ -1,4 +1,7 @@
-import type { MindmapElementModel } from '@blocksuite/affine-model';
+import type {
+  MindmapElementModel,
+  MindmapNode,
+} from '@blocksuite/affine-model';
 import type { GfxModel } from '@blocksuite/block-std/gfx';
 import type { IBound } from '@blocksuite/global/utils';
 
@@ -21,15 +24,18 @@ export function mindmap(
 
   matrix = matrix.translate(-dx, -dy);
 
-  model.traverse((to, from) => {
-    if (from) {
-      const connector = model.getConnector(from, to);
-      if (!connector) return;
-
+  const traverse = (node: MindmapNode) => {
+    const connectors = model.getConnectors(node);
+    if (!connectors) return;
+    connectors.reverse().forEach(result => {
+      const { connector, outdated } = result;
       const elementGetter = (id: string) =>
         model.surface.getElementById(id) ??
         (model.surface.doc.getBlockById(id) as GfxModel);
-      ConnectorPathGenerator.updatePath(connector, null, elementGetter);
+
+      if (outdated) {
+        ConnectorPathGenerator.updatePath(connector, null, elementGetter);
+      }
 
       const dx = connector.x - bound.x;
       const dy = connector.y - bound.y;
@@ -45,13 +51,14 @@ export function mindmap(
       if (shouldSetGlobalAlpha) {
         ctx.globalAlpha = origin;
       }
-    }
-  });
+    });
 
-  model.extraConnectors.forEach(connector => {
-    const dx = connector.x - bound.x;
-    const dy = connector.y - bound.y;
+    if (node.detail.collapsed) {
+      return;
+    } else {
+      node.children.forEach(traverse);
+    }
+  };
 
-    renderConnector(connector, ctx, matrix.translate(dx, dy), renderer, rc);
-  });
+  model.tree && traverse(model.tree);
 }
diff --git a/packages/affine/block-surface/src/renderer/elements/shape/diamond.ts b/packages/affine/block-surface/src/renderer/elements/shape/diamond.ts
index 9c0ca3b15f73..8f6fd13b4cee 100644
--- a/packages/affine/block-surface/src/renderer/elements/shape/diamond.ts
+++ b/packages/affine/block-surface/src/renderer/elements/shape/diamond.ts
@@ -1,4 +1,7 @@
-import type { ShapeElementModel } from '@blocksuite/affine-model';
+import type {
+  LocalShapeElementModel,
+  ShapeElementModel,
+} from '@blocksuite/affine-model';
 
 import type { RoughCanvas } from '../../../utils/rough/canvas.js';
 import type { CanvasRenderer } from '../../canvas-renderer.js';
@@ -6,7 +9,7 @@ import type { CanvasRenderer } from '../../canvas-renderer.js';
 import { type Colors, drawGeneralShape } from './utils.js';
 
 export function diamond(
-  model: ShapeElementModel,
+  model: ShapeElementModel | LocalShapeElementModel,
   ctx: CanvasRenderingContext2D,
   matrix: DOMMatrix,
   renderer: CanvasRenderer,
diff --git a/packages/affine/block-surface/src/renderer/elements/shape/ellipse.ts b/packages/affine/block-surface/src/renderer/elements/shape/ellipse.ts
index 127f31f179dd..5dbbcd7e58c3 100644
--- a/packages/affine/block-surface/src/renderer/elements/shape/ellipse.ts
+++ b/packages/affine/block-surface/src/renderer/elements/shape/ellipse.ts
@@ -1,4 +1,7 @@
-import type { ShapeElementModel } from '@blocksuite/affine-model';
+import type {
+  LocalShapeElementModel,
+  ShapeElementModel,
+} from '@blocksuite/affine-model';
 
 import type { RoughCanvas } from '../../../utils/rough/canvas.js';
 import type { CanvasRenderer } from '../../canvas-renderer.js';
@@ -6,7 +9,7 @@ import type { CanvasRenderer } from '../../canvas-renderer.js';
 import { type Colors, drawGeneralShape } from './utils.js';
 
 export function ellipse(
-  model: ShapeElementModel,
+  model: ShapeElementModel | LocalShapeElementModel,
   ctx: CanvasRenderingContext2D,
   matrix: DOMMatrix,
   renderer: CanvasRenderer,
diff --git a/packages/affine/block-surface/src/renderer/elements/shape/index.ts b/packages/affine/block-surface/src/renderer/elements/shape/index.ts
index 0783b2e49f1b..b7361d9c75e6 100644
--- a/packages/affine/block-surface/src/renderer/elements/shape/index.ts
+++ b/packages/affine/block-surface/src/renderer/elements/shape/index.ts
@@ -1,4 +1,8 @@
-import type { ShapeElementModel, ShapeType } from '@blocksuite/affine-model';
+import type {
+  LocalShapeElementModel,
+  ShapeElementModel,
+  ShapeType,
+} from '@blocksuite/affine-model';
 import type { IBound } from '@blocksuite/global/utils';
 
 import {
@@ -30,7 +34,7 @@ import { type Colors, horizontalOffset, verticalOffset } from './utils.js';
 const shapeRenderers: Record<
   ShapeType,
   (
-    model: ShapeElementModel,
+    model: ShapeElementModel | LocalShapeElementModel,
     ctx: CanvasRenderingContext2D,
     matrix: DOMMatrix,
     renderer: CanvasRenderer,
@@ -45,7 +49,7 @@ const shapeRenderers: Record<
 };
 
 export function shape(
-  model: ShapeElementModel,
+  model: ShapeElementModel | LocalShapeElementModel,
   ctx: CanvasRenderingContext2D,
   matrix: DOMMatrix,
   renderer: CanvasRenderer,
@@ -76,7 +80,7 @@ export function shape(
 }
 
 function renderText(
-  model: ShapeElementModel,
+  model: ShapeElementModel | LocalShapeElementModel,
   ctx: CanvasRenderingContext2D,
   { color }: Colors
 ) {
@@ -103,9 +107,10 @@ function renderText(
     fontWeight
   );
   const metrics = getFontMetrics(fontFamily, fontSize, fontWeight);
-  const lines = deltaInsertsToChunks(
-    wrapTextDeltas(text, font, w - horPadding * 2)
-  );
+  const lines =
+    typeof text === 'string'
+      ? [text.split('\n').map(line => ({ insert: line }))]
+      : deltaInsertsToChunks(wrapTextDeltas(text, font, w - horPadding * 2));
   const horOffset = horizontalOffset(model.w, model.textAlign, horPadding);
   const vertOffset =
     verticalOffset(
diff --git a/packages/affine/block-surface/src/renderer/elements/shape/rect.ts b/packages/affine/block-surface/src/renderer/elements/shape/rect.ts
index 5d69d2472b36..784568d564b8 100644
--- a/packages/affine/block-surface/src/renderer/elements/shape/rect.ts
+++ b/packages/affine/block-surface/src/renderer/elements/shape/rect.ts
@@ -1,4 +1,7 @@
-import type { ShapeElementModel } from '@blocksuite/affine-model';
+import type {
+  LocalShapeElementModel,
+  ShapeElementModel,
+} from '@blocksuite/affine-model';
 
 import type { RoughCanvas } from '../../../utils/rough/canvas.js';
 import type { CanvasRenderer } from '../../canvas-renderer.js';
@@ -11,7 +14,7 @@ import { type Colors, drawGeneralShape } from './utils.js';
 const K_RECT = 1 - 0.5522847498;
 
 export function rect(
-  model: ShapeElementModel,
+  model: ShapeElementModel | LocalShapeElementModel,
   ctx: CanvasRenderingContext2D,
   matrix: DOMMatrix,
   renderer: CanvasRenderer,
diff --git a/packages/affine/block-surface/src/renderer/elements/shape/triangle.ts b/packages/affine/block-surface/src/renderer/elements/shape/triangle.ts
index 63a695049ee1..27c77c75bb4f 100644
--- a/packages/affine/block-surface/src/renderer/elements/shape/triangle.ts
+++ b/packages/affine/block-surface/src/renderer/elements/shape/triangle.ts
@@ -1,4 +1,7 @@
-import type { ShapeElementModel } from '@blocksuite/affine-model';
+import type {
+  LocalShapeElementModel,
+  ShapeElementModel,
+} from '@blocksuite/affine-model';
 
 import type { RoughCanvas } from '../../../utils/rough/canvas.js';
 import type { CanvasRenderer } from '../../canvas-renderer.js';
@@ -6,7 +9,7 @@ import type { CanvasRenderer } from '../../canvas-renderer.js';
 import { type Colors, drawGeneralShape } from './utils.js';
 
 export function triangle(
-  model: ShapeElementModel,
+  model: ShapeElementModel | LocalShapeElementModel,
   ctx: CanvasRenderingContext2D,
   matrix: DOMMatrix,
   renderer: CanvasRenderer,
diff --git a/packages/affine/block-surface/src/renderer/elements/shape/utils.ts b/packages/affine/block-surface/src/renderer/elements/shape/utils.ts
index 51aae9257e6c..520f4075db7a 100644
--- a/packages/affine/block-surface/src/renderer/elements/shape/utils.ts
+++ b/packages/affine/block-surface/src/renderer/elements/shape/utils.ts
@@ -1,4 +1,5 @@
 import type {
+  LocalShapeElementModel,
   ShapeElementModel,
   TextAlign,
   TextVerticalAlign,
@@ -28,7 +29,7 @@ export type Colors = {
 
 export function drawGeneralShape(
   ctx: CanvasRenderingContext2D,
-  shapeModel: ShapeElementModel,
+  shapeModel: ShapeElementModel | LocalShapeElementModel,
   renderer: CanvasRenderer,
   filled: boolean,
   fillColor: string,
diff --git a/packages/affine/block-surface/src/surface-block.ts b/packages/affine/block-surface/src/surface-block.ts
index a752602fbabe..4a6136bd6d86 100644
--- a/packages/affine/block-surface/src/surface-block.ts
+++ b/packages/affine/block-surface/src/surface-block.ts
@@ -187,25 +187,9 @@ export class SurfaceBlockComponent extends BlockComponent<
         canvas.className = 'indexable-canvas';
       },
       elementRenderers: this._edgelessService.elementRenderers,
+      surfaceModel: this.model,
     });
 
-    this._disposables.add(
-      this.model.elementUpdated.on(payload => {
-        // ignore externalXYWH update cause it's updated by the renderer
-        if (payload.props['externalXYWH']) return;
-        this._renderer.refresh();
-      })
-    );
-    this._disposables.add(
-      this.model.elementAdded.on(() => {
-        this._renderer.refresh();
-      })
-    );
-    this._disposables.add(
-      this.model.elementRemoved.on(() => {
-        this._renderer.refresh();
-      })
-    );
     this._disposables.add(() => {
       this._renderer.dispose();
     });
diff --git a/packages/affine/block-surface/src/surface-spec.ts b/packages/affine/block-surface/src/surface-spec.ts
index 227ddf443b08..fc4f141ded3c 100644
--- a/packages/affine/block-surface/src/surface-spec.ts
+++ b/packages/affine/block-surface/src/surface-spec.ts
@@ -13,12 +13,14 @@ import {
 } from './adapters/extension.js';
 import { commands } from './commands/index.js';
 import { SurfaceBlockService } from './surface-service.js';
+import { MindMapView } from './view/mindmap.js';
 
 const CommonSurfaceBlockSpec: ExtensionType[] = [
   FlavourExtension('affine:surface'),
   SurfaceBlockService,
   CommandExtension(commands),
   HighlightSelectionExtension,
+  MindMapView,
 ];
 
 export const PageSurfaceBlockSpec: ExtensionType[] = [
diff --git a/packages/affine/block-surface/src/utils/mindmap/layout.ts b/packages/affine/block-surface/src/utils/mindmap/layout.ts
index a14e91f5a716..c54470345166 100644
--- a/packages/affine/block-surface/src/utils/mindmap/layout.ts
+++ b/packages/affine/block-surface/src/utils/mindmap/layout.ts
@@ -52,7 +52,7 @@ const calculateNodeSize = (
     children,
   };
 
-  if (rootChildren?.length) {
+  if (rootChildren?.length && !root.detail.collapsed) {
     const childrenBound = rootChildren.reduce(
       (pre, node) => {
         const childSize = calculateNodeSize(node, treeSize);
@@ -108,23 +108,25 @@ const layoutTree = (
     currentY += (tree.root.element.h - onlyChild.root.element.h) / 2;
   }
 
-  tree.children.forEach((subtree, idx) => {
-    const subtreeRootEl = subtree.root.element;
-    const subtreeHeight = subtree.bound.h;
-    const xywh = `[${
-      layoutType === LayoutType.RIGHT ? currentX : currentX - subtreeRootEl.w
-    },${currentY + (subtreeHeight - subtreeRootEl.h) / 2},${subtreeRootEl.w},${subtreeRootEl.h}]` as SerializedXYWH;
+  if (!tree.root.detail.collapsed) {
+    tree.children.forEach((subtree, idx) => {
+      const subtreeRootEl = subtree.root.element;
+      const subtreeHeight = subtree.bound.h;
+      const xywh = `[${
+        layoutType === LayoutType.RIGHT ? currentX : currentX - subtreeRootEl.w
+      },${currentY + (subtreeHeight - subtreeRootEl.h) / 2},${subtreeRootEl.w},${subtreeRootEl.h}]` as SerializedXYWH;
 
-    const currentNodePath = [...path, idx];
+      const currentNodePath = [...path, idx];
 
-    if (subtreeRootEl.xywh !== xywh) {
-      subtreeRootEl.xywh = xywh;
-    }
+      if (subtreeRootEl.xywh !== xywh) {
+        subtreeRootEl.xywh = xywh;
+      }
 
-    layoutTree(subtree, layoutType, mindmap, currentNodePath);
+      layoutTree(subtree, layoutType, mindmap, currentNodePath);
 
-    currentY += subtreeHeight + NODE_VERTICAL_SPACING;
-  });
+      currentY += subtreeHeight + NODE_VERTICAL_SPACING;
+    });
+  }
 };
 
 const layoutRight = (
diff --git a/packages/affine/block-surface/src/utils/mindmap/utils.ts b/packages/affine/block-surface/src/utils/mindmap/utils.ts
index 23f43161a995..474797d7e7b6 100644
--- a/packages/affine/block-surface/src/utils/mindmap/utils.ts
+++ b/packages/affine/block-surface/src/utils/mindmap/utils.ts
@@ -1,7 +1,7 @@
 import {
   applyNodeStyle,
   LayoutType,
-  MindmapElementModel,
+  type MindmapElementModel,
   type MindmapNode,
   type MindmapRoot,
   type MindmapStyle,
@@ -19,33 +19,6 @@ import { DocCollection } from '@blocksuite/store';
 import { fitContent } from '../../renderer/elements/shape/utils.js';
 import { layout } from './layout.js';
 
-export class LayoutableMindmapElementModel extends MindmapElementModel {
-  override layout(
-    tree: MindmapNode | MindmapRoot = this.tree,
-    options: {
-      applyStyle?: boolean;
-      layoutType?: LayoutType;
-      stashed?: boolean;
-    } = {
-      applyStyle: true,
-      stashed: true,
-    }
-  ) {
-    const { stashed, applyStyle, layoutType } = Object.assign(
-      {
-        applyStyle: true,
-        calculateTreeBound: true,
-        stashed: true,
-      },
-      options
-    );
-
-    const pop = stashed ? this.stashTree(tree) : null;
-    handleLayout(this, tree, applyStyle, layoutType);
-    pop?.();
-  }
-}
-
 export function getHoveredArea(
   target: ShapeElementModel,
   position: [number, number],
@@ -157,6 +130,10 @@ function moveNodePosition(
     mindmap.children.set(node.id, val);
   });
 
+  if (parent.detail.collapsed) {
+    mindmap.toggleCollapse(parent);
+  }
+
   mindmap.layout();
 
   return mindmap.nodeMap.get(node.id);
@@ -256,6 +233,10 @@ export function addNode(
     recursiveAddChild(node);
   });
 
+  if (parentNode.detail.collapsed) {
+    mindmap.toggleCollapse(parentNode);
+  }
+
   mindmap.layout();
 }
 
diff --git a/packages/affine/block-surface/src/utils/update-xywh.ts b/packages/affine/block-surface/src/utils/update-xywh.ts
index 3630dc0e527d..8458249d2cef 100644
--- a/packages/affine/block-surface/src/utils/update-xywh.ts
+++ b/packages/affine/block-surface/src/utils/update-xywh.ts
@@ -2,6 +2,7 @@ import type { BlockModel, BlockProps } from '@blocksuite/store';
 
 import {
   ConnectorElementModel,
+  MindmapElementModel,
   NOTE_MIN_HEIGHT,
   NOTE_MIN_WIDTH,
   NoteBlockModel,
@@ -13,8 +14,6 @@ import {
 } from '@blocksuite/block-std/gfx';
 import { Bound, clamp } from '@blocksuite/global/utils';
 
-import { LayoutableMindmapElementModel } from './mindmap/utils.js';
-
 function updatChildElementsXYWH(
   container: GfxGroupCompatibleInterface,
   targetBound: Bound,
@@ -61,7 +60,7 @@ export function updateXYWH(
     updateElement(ele.id, {
       xywh: bound.serialize(),
     });
-  } else if (ele instanceof LayoutableMindmapElementModel) {
+  } else if (ele instanceof MindmapElementModel) {
     const rootId = ele.tree.id;
     const rootEle = ele.childElements.find(child => child.id === rootId);
     if (rootEle) {
diff --git a/packages/affine/block-surface/src/view/mindmap.ts b/packages/affine/block-surface/src/view/mindmap.ts
new file mode 100644
index 000000000000..476e39593744
--- /dev/null
+++ b/packages/affine/block-surface/src/view/mindmap.ts
@@ -0,0 +1,301 @@
+import type { PointerEventState } from '@blocksuite/block-std';
+
+import {
+  LayoutType,
+  LocalShapeElementModel,
+  type MindmapElementModel,
+  type MindmapNode,
+  type MindmapRoot,
+} from '@blocksuite/affine-model';
+import { TelemetryProvider } from '@blocksuite/affine-shared/services';
+import { requestThrottledConnectedFrame } from '@blocksuite/affine-shared/utils';
+import { GfxElementModelView } from '@blocksuite/block-std/gfx';
+
+import { handleLayout } from '../utils/mindmap/utils.js';
+
+export class MindMapView extends GfxElementModelView<MindmapElementModel> {
+  static override type = 'mindmap';
+
+  private _collapseButtons = new Map<string, LocalShapeElementModel>();
+
+  private _selectedButton: LocalShapeElementModel | null = null;
+
+  private _getCollapseButton(node: MindmapNode | string) {
+    const id = typeof node === 'string' ? node : node.id;
+    return this._collapseButtons.get(`collapse-btn-${id}`);
+  }
+
+  private _initCollapseButtons() {
+    const updateButtons = requestThrottledConnectedFrame(() => {
+      if (!this.isConnected) {
+        return;
+      }
+
+      const visited = new Set<LocalShapeElementModel>();
+
+      this.model.traverse(node => {
+        const btn = this._updateCollapseButton(node);
+
+        btn && visited.add(btn);
+      });
+
+      this._collapseButtons.forEach(btn => {
+        if (!visited.has(btn)) {
+          this.surface.deleteLocalElement(btn);
+          this._collapseButtons.delete(btn.id);
+        }
+      });
+    });
+
+    this.disposable.add(
+      this.model.propsUpdated.on(({ key }) => {
+        if (key === 'layoutType' || key === 'style') {
+          updateButtons();
+        }
+      })
+    );
+
+    this.disposable.add(
+      this.surface.elementUpdated.on(payload => {
+        if (this.model.children.has(payload.id) && payload.props['xywh']) {
+          updateButtons();
+        }
+      })
+    );
+
+    this.model.children.observe(updateButtons);
+
+    this.disposable.add(() => {
+      this.model.children.unobserve(updateButtons);
+    });
+
+    updateButtons();
+  }
+
+  private _needToUpdateButtonStyle(options: {
+    button: LocalShapeElementModel;
+    node: MindmapNode;
+    updateKey?: boolean;
+  }) {
+    const { button, node } = options;
+    const layout = this.model.getLayoutDir(node);
+    const cacheKey = `${node.detail.collapsed ?? false}-${layout}-${node.element.xywh}-${this.model.style}`;
+
+    if (button.cache.get('MINDMAP_COLLAPSE_BUTTON') === cacheKey) {
+      return false;
+    } else if (options.updateKey) {
+      button.cache.set('MINDMAP_COLLAPSE_BUTTON', cacheKey);
+    }
+
+    return true;
+  }
+
+  private _setLayoutMethod() {
+    this.model.setLayoutMethod(function (
+      this: MindmapElementModel,
+      tree: MindmapNode | MindmapRoot = this.tree,
+      options: {
+        applyStyle?: boolean;
+        layoutType?: LayoutType;
+        stashed?: boolean;
+      } = {
+        applyStyle: true,
+        stashed: true,
+      }
+    ) {
+      const { stashed, applyStyle, layoutType } = Object.assign(
+        {
+          applyStyle: true,
+          calculateTreeBound: true,
+          stashed: true,
+        },
+        options
+      );
+
+      const pop = stashed ? this.stashTree(tree) : null;
+      handleLayout(this, tree, applyStyle, layoutType);
+      pop?.();
+    });
+  }
+
+  private _setVisibleOnSelection() {
+    this.disposable.add(
+      this.gfx.selection.slots.updated.on(() => {
+        const elm = this.gfx.selection.firstElement;
+
+        if (
+          this.gfx.selection.selectedElements.length === 1 &&
+          elm?.id &&
+          this.model.children.has(elm.id)
+        ) {
+          const button = this._getCollapseButton(elm.id);
+
+          if (!button || button === this._selectedButton) {
+            return;
+          }
+
+          button.opacity = 1;
+          if (this._selectedButton) {
+            this._selectedButton.opacity = 0;
+          }
+          this._selectedButton = button;
+        } else {
+          if (this._selectedButton) {
+            this._selectedButton.opacity = 0;
+            this._selectedButton = null;
+          }
+        }
+      })
+    );
+  }
+
+  private _updateCollapseButton(node: MindmapNode) {
+    if (!node?.element || node.children.length === 0) return null;
+
+    const id = `collapse-btn-${node.id}`;
+    const alreadyCreated = this._collapseButtons.has(id);
+    const collapseButton =
+      this._collapseButtons.get(id) ||
+      new LocalShapeElementModel(this.model.surface);
+    const collapsed = node.detail.collapsed ?? false;
+
+    if (
+      this._needToUpdateButtonStyle({
+        button: collapseButton,
+        node,
+        updateKey: true,
+      })
+    ) {
+      const style = this.model.styleGetter.getNodeStyle(
+        node,
+        this.model.getPath(node)
+      );
+      const layout = this.model.getLayoutDir(node);
+      const buttonStyle = collapsed ? style.expandButton : style.collapseButton;
+      const hidden = node.parent?.detail.collapsed ?? false;
+
+      Object.entries(buttonStyle).forEach(([key, value]) => {
+        // @ts-ignore
+        collapseButton[key as unknown] = value;
+      });
+
+      const nodeElementBound = node.element.elementBound;
+      const buttonBound = nodeElementBound.moveDelta(
+        layout === LayoutType.LEFT
+          ? -6 - buttonStyle.width
+          : 6 + nodeElementBound.w,
+        (nodeElementBound.h - buttonStyle.height) / 2
+      );
+
+      buttonBound.w = buttonStyle.width;
+      buttonBound.h = buttonStyle.height;
+
+      collapseButton.responseExtension = [16, 16];
+      collapseButton.hidden = hidden;
+      collapseButton.xywh = buttonBound.serialize();
+      collapseButton.groupId = this.model.id;
+      collapseButton.text = collapsed ? node.children.length.toString() : '';
+    }
+
+    if (!alreadyCreated) {
+      collapseButton.opacity = collapsed ? 1 : 0;
+      collapseButton.id = id;
+
+      this._collapseButtons.set(id, collapseButton);
+      this.surface.addLocalElement(collapseButton);
+
+      const buttonView = this.gfx.view.get(id) as GfxElementModelView;
+      const isOnElementBound = (evt: PointerEventState) => {
+        const [x, y] = this.gfx.viewport.toModelCoord(evt.x, evt.y);
+
+        return buttonView.model.includesPoint(
+          x,
+          y,
+          { useElementBound: true },
+          this.gfx.std.host
+        );
+      };
+      let hoveredButton = false;
+      let hoveredNode = false;
+      const updateButtonOpacity = () => {
+        buttonView.model.opacity = hoveredButton || hoveredNode ? 1 : 0;
+      };
+
+      buttonView.on('pointerenter', () => {
+        const latestNode = this.model.getNode(node.id);
+        if (latestNode && latestNode.children.length > 0) {
+          hoveredButton = true;
+          updateButtonOpacity();
+        }
+      });
+      buttonView.on('pointermove', evt => {
+        const latestNode = this.model.getNode(node.id);
+        if (latestNode && latestNode.children.length > 0) {
+          if (isOnElementBound(evt)) {
+            this.gfx.cursor$.value = 'pointer';
+          } else {
+            this.gfx.cursor$.value = 'default';
+          }
+        }
+      });
+      buttonView.on('pointerleave', () => {
+        const latestNode = this.model.getNode(node.id);
+
+        this.gfx.cursor$.value = 'default';
+
+        if (
+          !latestNode ||
+          latestNode.detail.collapsed ||
+          this._selectedButton === buttonView.model
+        ) {
+          return;
+        }
+
+        hoveredButton = false;
+        updateButtonOpacity();
+      });
+      buttonView.on('click', evt => {
+        const latestNode = this.model.getNode(node.id);
+        const telemetry = this.gfx.std.getOptional(TelemetryProvider);
+
+        if (latestNode && isOnElementBound(evt)) {
+          if (telemetry) {
+            telemetry.track('ExpandedAndCollapsed', {
+              page: 'whiteboard editor',
+              segment: 'mind map',
+              type: latestNode.detail.collapsed ? 'expand' : 'collapse',
+            });
+          }
+
+          this.model.toggleCollapse(latestNode!, { layout: true });
+        }
+      });
+
+      const nodeView = this.gfx.view.get(node.id) as GfxElementModelView;
+
+      nodeView.on('pointerenter', () => {
+        hoveredNode = true;
+        updateButtonOpacity();
+      });
+      nodeView.on('pointerleave', () => {
+        hoveredNode = false;
+        updateButtonOpacity();
+      });
+    }
+
+    return collapseButton;
+  }
+
+  override onCreated(): void {
+    this._setLayoutMethod();
+    this._initCollapseButtons();
+    this._setVisibleOnSelection();
+  }
+
+  override onDestroyed() {
+    super.onDestroyed();
+    this._collapseButtons.forEach(btn => {
+      this.surface.deleteLocalElement(btn);
+    });
+  }
+}
diff --git a/packages/affine/model/src/elements/mindmap/mindmap.ts b/packages/affine/model/src/elements/mindmap/mindmap.ts
index b8ebd5914474..0c13b7bad541 100644
--- a/packages/affine/model/src/elements/mindmap/mindmap.ts
+++ b/packages/affine/model/src/elements/mindmap/mindmap.ts
@@ -25,8 +25,9 @@ import { DocCollection, type Y } from '@blocksuite/store';
 import { generateKeyBetween } from 'fractional-indexing';
 import { z } from 'zod';
 
-import type { ConnectorStyle, MindmapStyleGetter } from './style.js';
+import type { MindmapStyleGetter } from './style.js';
 
+import { ConnectorMode } from '../../consts/connector.js';
 import { LayoutType, MindmapStyle } from '../../consts/mindmap.js';
 import { LocalConnectorElementModel } from '../connector/local-connector.js';
 import { mindmapStyleGetters } from './style.js';
@@ -38,6 +39,7 @@ export type NodeDetail = {
    */
   index: string;
   parent?: string;
+  collapsed?: boolean;
 };
 
 export type MindmapNode = {
@@ -143,6 +145,8 @@ function watchStyle(_: unknown, instance: MindmapElementModel, local: boolean) {
 }
 
 export class MindmapElementModel extends GfxGroupLikeElementModel<MindmapElementProps> {
+  private _layout: MindmapElementModel['layout'] | null = null;
+
   private _nodeMap = new Map<string, MindmapNode>();
 
   private _queueBuildTree = false;
@@ -155,8 +159,6 @@ export class MindmapElementModel extends GfxGroupLikeElementModel<MindmapElement
 
   connectors = new Map<string, LocalConnectorElementModel>();
 
-  extraConnectors = new Map<string, LocalConnectorElementModel>();
-
   get nodeMap() {
     return this._nodeMap;
   }
@@ -215,31 +217,39 @@ export class MindmapElementModel extends GfxGroupLikeElementModel<MindmapElement
   }
 
   private _isConnectorOutdated(
-    options: {
-      connector: LocalConnectorElementModel;
-      from: MindmapNode;
-      to: MindmapNode;
-      layout: LayoutType;
-    },
+    options:
+      | {
+          connector: LocalConnectorElementModel;
+          from: MindmapNode;
+          to: MindmapNode;
+          layout: LayoutType;
+        }
+      | {
+          connector: LocalConnectorElementModel;
+          from: MindmapNode;
+          layout: LayoutType;
+          collapsed: boolean;
+        },
     updateKey: boolean = true
   ) {
-    const { connector, from, to, layout } = options;
+    const collapsed = 'collapsed' in options;
+    const { connector, from, layout } = options;
 
-    if (!from.element || !to.element) {
+    if (!from.element || (!collapsed && !options.to.element)) {
       return { outdated: true, cacheKey: '' };
     }
 
-    const cacheKey = `${from.element.xywh}-${to.element.xywh}-${layout}-${this.style}`;
+    const cacheKey = collapsed
+      ? `${from.element.xywh}-collapsed-${layout}-${this.style}`
+      : `${from.element.xywh}-${options.to.element.xywh}-${layout}-${this.style}`;
 
-    // @ts-ignore
-    if (connector['MINDMAP_CONNECTOR'] === cacheKey) {
-      return { outdated: false, cacheKey };
+    if (connector.cache.get('MINDMAP_CONNECTOR') === cacheKey) {
+      return false;
     } else if (updateKey) {
-      // @ts-ignore
-      connector['MINDMAP_CONNECTOR'] = cacheKey;
+      connector.cache.set('MINDMAP_CONNECTOR', cacheKey);
     }
 
-    return { outdated: true, cacheKey };
+    return true;
   }
 
   protected override _getXYWH(): Bound {
@@ -254,66 +264,6 @@ export class MindmapElementModel extends GfxGroupLikeElementModel<MindmapElement
     noop();
   }
 
-  protected addConnector(
-    from: MindmapNode,
-    to: MindmapNode,
-    layout: LayoutType,
-    connectorStyle: ConnectorStyle,
-    extra: boolean = false
-  ) {
-    const id = `#${from.id}-${to.id}`;
-
-    if (extra) {
-      this.extraConnectors.set(
-        id,
-        new LocalConnectorElementModel(this.surface)
-      );
-    } else if (this.connectors.has(id)) {
-      const connector = this.connectors.get(id)!;
-      const { outdated } = this._isConnectorOutdated({
-        connector,
-        from,
-        to,
-        layout,
-      });
-
-      if (!outdated) {
-        return connector;
-      }
-    } else {
-      const connector = new LocalConnectorElementModel(this.surface);
-      // update cache key
-      this._isConnectorOutdated({
-        connector,
-        from,
-        to,
-        layout,
-      });
-      this.connectors.set(id, connector);
-    }
-
-    const connector = extra
-      ? this.extraConnectors.get(id)!
-      : this.connectors.get(id)!;
-
-    connector.id = id;
-    connector.source = {
-      id: from.id,
-      position: layout === LayoutType.RIGHT ? [1, 0.5] : [0, 0.5],
-    };
-    connector.target = {
-      id: to.id,
-      position: layout === LayoutType.RIGHT ? [0, 0.5] : [1, 0.5],
-    };
-
-    Object.entries(connectorStyle).forEach(([key, value]) => {
-      // @ts-ignore
-      connector[key as unknown] = value;
-    });
-
-    return connector;
-  }
-
   addNode(
     /**
      * The parent node id of the new node. If it's null, the node will be the root node
@@ -537,17 +487,105 @@ export class MindmapElementModel extends GfxGroupLikeElementModel<MindmapElement
     return node.children;
   }
 
-  getConnector(from: MindmapNode, to: MindmapNode) {
-    if (!this._nodeMap.has(from.id) || !this._nodeMap.has(to.id)) {
+  /**
+   * Get all the connectors start from the given node
+   * @param node
+   * @returns
+   */
+  getConnectors(node: MindmapNode) {
+    if (!this._nodeMap.has(node.id)) {
       return null;
     }
 
-    return this.addConnector(
-      from,
-      to,
-      this.getLayoutDir(to)!,
-      this.styleGetter.getNodeStyle(to, this.getPath(to)).connector
-    );
+    if (node.detail.collapsed) {
+      const id = `#${node.id}-collapsed`;
+      const layout = this.getLayoutDir(node)!;
+      const connector =
+        this.connectors.get(id) ?? new LocalConnectorElementModel(this.surface);
+      const connectorExist = this.connectors.has(id);
+      const connectorStyle = this.styleGetter.getNodeStyle(
+        node,
+        this.getPath(node).concat([0])
+      ).connector;
+      const outdated = this._isConnectorOutdated({
+        connector,
+        from: node,
+        collapsed: true,
+        layout,
+      });
+
+      if (!connectorExist) {
+        connector.id = id;
+        this.connectors.set(id, connector);
+      }
+
+      if (outdated) {
+        const nodeBound = node.element.elementBound;
+        connector.id = id;
+        connector.source = {
+          id: node.id,
+          position: layout === LayoutType.LEFT ? [0, 0.5] : [1, 0.5],
+        };
+        connector.target = {
+          position:
+            layout === LayoutType.LEFT
+              ? [nodeBound.x - 6, nodeBound.y + nodeBound.h / 2]
+              : [nodeBound.x + nodeBound.w + 6, nodeBound.y + nodeBound.h / 2],
+        };
+
+        Object.entries(connectorStyle).forEach(([key, value]) => {
+          // @ts-ignore
+          connector[key as unknown] = value;
+        });
+
+        connector.mode = ConnectorMode.Straight;
+      }
+
+      return [{ outdated, connector }];
+    } else {
+      const from = node;
+      return from.children.map(to => {
+        const layout = this.getLayoutDir(to)!;
+        const id = `#${from.id}-${to.id}`;
+        const connectorExist = this.connectors.has(id);
+        const connectorStyle = this.styleGetter.getNodeStyle(
+          to,
+          this.getPath(to)
+        ).connector;
+        const connector =
+          this.connectors.get(id) ??
+          new LocalConnectorElementModel(this.surface);
+        const outdated = this._isConnectorOutdated({
+          connector,
+          from,
+          to,
+          layout,
+        });
+
+        if (!connectorExist) {
+          connector.id = id;
+          this.connectors.set(id, connector);
+        }
+
+        if (outdated) {
+          connector.source = {
+            id: from.id,
+            position: layout === LayoutType.RIGHT ? [1, 0.5] : [0, 0.5],
+          };
+          connector.target = {
+            id: to.id,
+            position: layout === LayoutType.RIGHT ? [0, 0.5] : [1, 0.5],
+          };
+
+          Object.entries(connectorStyle).forEach(([key, value]) => {
+            // @ts-ignore
+            connector[key as unknown] = value;
+          });
+        }
+
+        return { outdated, connector };
+      });
+    }
   }
 
   getLayoutDir(node: string | MindmapNode): LayoutType {
@@ -688,7 +726,11 @@ export class MindmapElementModel extends GfxGroupLikeElementModel<MindmapElement
       stashed: true,
     }
   ) {
-    // should be override by subclass
+    // should be implemented by the view
+    // otherwise, it would be just an empty function
+    if (this._layout) {
+      this._layout(_tree, _options);
+    }
   }
 
   moveTo(targetXYWH: SerializedXYWH | XYWH) {
@@ -709,7 +751,7 @@ export class MindmapElementModel extends GfxGroupLikeElementModel<MindmapElement
   }
 
   override onCreated(): void {
-    this.requestBuildTree();
+    this.buildTree();
   }
 
   removeChild(element: GfxModel) {
@@ -770,6 +812,10 @@ export class MindmapElementModel extends GfxGroupLikeElementModel<MindmapElement
     return result as SerializedMindmapElement;
   }
 
+  setLayoutMethod(layoutMethod: MindmapElementModel['layout']) {
+    this._layout = layoutMethod;
+  }
+
   /**
    * Stash mind map node and its children's xywh property
    * @param node
@@ -802,17 +848,63 @@ export class MindmapElementModel extends GfxGroupLikeElementModel<MindmapElement
     };
   }
 
-  traverse(callback: (node: MindmapNode, parent: MindmapNode | null) => void) {
+  toggleCollapse(node: MindmapNode, options: { layout?: boolean } = {}) {
+    if (!this._nodeMap.has(node.id)) {
+      return;
+    }
+
+    const { layout = false } = options;
+
+    if (node && node.children.length > 0) {
+      const collapsed = node.detail.collapsed ? false : true;
+      const isExpand = !collapsed;
+
+      const changeNodesVisibility = (node: MindmapNode) => {
+        node.element.hidden = collapsed;
+
+        if (isExpand && node.detail.collapsed) {
+          return;
+        }
+
+        node.children.forEach(child => {
+          changeNodesVisibility(child);
+        });
+      };
+
+      node.children.forEach(changeNodesVisibility);
+      this.surface.doc.transact(() => {
+        this.children.set(node.id, {
+          ...node.detail,
+          collapsed,
+        });
+      });
+    }
+
+    if (layout) {
+      this.requestLayout();
+    }
+  }
+
+  traverse(
+    callback: (node: MindmapNode, parent: MindmapNode | null) => void,
+    root: MindmapNode = this._tree,
+    options: { stopOnCollapse?: boolean } = {}
+  ) {
+    const { stopOnCollapse = false } = options;
     const traverse = (node: MindmapNode, parent: MindmapNode | null) => {
       callback(node, parent);
 
+      if (stopOnCollapse && node.detail.collapsed) {
+        return;
+      }
+
       node?.children.forEach(child => {
         traverse(child, node);
       });
     };
 
-    if (this._tree) {
-      traverse(this._tree, null);
+    if (root) {
+      traverse(root, null);
     }
   }
 
diff --git a/packages/affine/model/src/elements/mindmap/style.ts b/packages/affine/model/src/elements/mindmap/style.ts
index e14a6cfb5ae5..e7ad9b61dfc1 100644
--- a/packages/affine/model/src/elements/mindmap/style.ts
+++ b/packages/affine/model/src/elements/mindmap/style.ts
@@ -9,6 +9,26 @@ import { StrokeStyle } from '../../consts/note.js';
 import { ShapeFillColor } from '../../consts/shape.js';
 import { FontFamily, FontWeight, TextResizing } from '../../consts/text.js';
 
+export type CollapseButton = {
+  width: number;
+  height: number;
+  radius: number;
+
+  filled: boolean;
+  fillColor: string;
+
+  strokeColor: string;
+  strokeWidth: number;
+};
+
+export type ExpandButton = CollapseButton & {
+  fontFamily: FontFamily;
+  fontSize: number;
+  fontWeight: FontWeight;
+
+  color: string;
+};
+
 export type NodeStyle = {
   radius: number;
 
@@ -51,6 +71,8 @@ export abstract class MindmapStyleGetter {
     path: number[]
   ): {
     connector: ConnectorStyle;
+    collapseButton: CollapseButton;
+    expandButton: ExpandButton;
     node: NodeStyle;
   };
 }
@@ -95,10 +117,7 @@ export class StyleOne extends MindmapStyleGetter {
     return this._colorOrders[number % this._colorOrders.length];
   }
 
-  getNodeStyle(
-    _: MindmapNode,
-    path: number[]
-  ): { connector: ConnectorStyle; node: NodeStyle } {
+  getNodeStyle(_: MindmapNode, path: number[]) {
     const color = this._getColor(path[1] ?? 0);
 
     return {
@@ -109,6 +128,36 @@ export class StyleOne extends MindmapStyleGetter {
 
         mode: ConnectorMode.Curve,
       },
+      collapseButton: {
+        width: 16,
+        height: 16,
+        radius: 0.5,
+
+        filled: true,
+        fillColor: '--affine-white',
+
+        strokeColor: color,
+        strokeWidth: 3,
+      },
+      expandButton: {
+        width: 24,
+        height: 24,
+        radius: 8,
+
+        filled: true,
+        fillColor: color,
+
+        strokeColor: color,
+        strokeWidth: 0,
+
+        padding: [4, 0],
+
+        color: '--affine-white',
+
+        fontFamily: FontFamily.Inter,
+        fontWeight: FontWeight.Bold,
+        fontSize: 15,
+      },
       node: {
         radius: 8,
 
@@ -178,10 +227,7 @@ export class StyleTwo extends MindmapStyleGetter {
       : this._colorOrders[number];
   }
 
-  getNodeStyle(
-    _: MindmapNode,
-    path: number[]
-  ): { connector: ConnectorStyle; node: NodeStyle } {
+  getNodeStyle(_: MindmapNode, path: number[]) {
     const color = this._getColor(path.length - 2);
 
     return {
@@ -192,6 +238,36 @@ export class StyleTwo extends MindmapStyleGetter {
 
         mode: ConnectorMode.Orthogonal,
       },
+      collapseButton: {
+        width: 16,
+        height: 16,
+        radius: 0.5,
+
+        filled: true,
+        fillColor: '--affine-white',
+
+        strokeColor: '--affine-black',
+        strokeWidth: 3,
+      },
+      expandButton: {
+        width: 24,
+        height: 24,
+        radius: 2,
+
+        filled: true,
+        fillColor: '--affine-black',
+
+        padding: [4, 0],
+
+        strokeColor: '--affine-black',
+        strokeWidth: 0,
+
+        color: '--affine-white',
+
+        fontFamily: FontFamily.Inter,
+        fontWeight: FontWeight.Bold,
+        fontSize: 15,
+      },
       node: {
         radius: 3,
 
@@ -255,10 +331,7 @@ export class StyleThree extends MindmapStyleGetter {
     return this._strokeColor[number % this._strokeColor.length];
   }
 
-  override getNodeStyle(
-    _: MindmapNode,
-    path: number[]
-  ): { connector: ConnectorStyle; node: NodeStyle } {
+  override getNodeStyle(_: MindmapNode, path: number[]) {
     const strokeColor = this._getColor(path.length - 2);
 
     return {
@@ -287,6 +360,36 @@ export class StyleThree extends MindmapStyleGetter {
           color: 'rgba(66, 65, 73, 0.18)',
         },
       },
+      collapseButton: {
+        width: 16,
+        height: 16,
+        radius: 0.5,
+
+        filled: true,
+        fillColor: '--affine-white',
+
+        strokeColor: '#3CBC36',
+        strokeWidth: 3,
+      },
+      expandButton: {
+        width: 24,
+        height: 24,
+        radius: 8,
+
+        filled: true,
+        fillColor: '#3CBC36',
+
+        padding: [4, 0],
+
+        strokeColor: '#3CBC36',
+        strokeWidth: 0,
+
+        color: '#fff',
+
+        fontFamily: FontFamily.Inter,
+        fontWeight: FontWeight.Bold,
+        fontSize: 15,
+      },
       connector: {
         strokeStyle: StrokeStyle.Solid,
         stroke: strokeColor,
@@ -332,10 +435,7 @@ export class StyleFour extends MindmapStyleGetter {
     return this._colors[order % this._colors.length];
   }
 
-  getNodeStyle(
-    _: MindmapNode,
-    path: number[]
-  ): { connector: ConnectorStyle; node: NodeStyle } {
+  getNodeStyle(_: MindmapNode, path: number[]) {
     const stroke = this._getColor(path[1] ?? 0);
 
     return {
@@ -346,6 +446,36 @@ export class StyleFour extends MindmapStyleGetter {
 
         mode: ConnectorMode.Curve,
       },
+      collapseButton: {
+        width: 16,
+        height: 16,
+        radius: 0.5,
+
+        filled: true,
+        fillColor: '--affine-white',
+
+        strokeColor: stroke,
+        strokeWidth: 3,
+      },
+      expandButton: {
+        width: 24,
+        height: 24,
+        radius: 8,
+
+        filled: true,
+        fillColor: stroke,
+
+        padding: [4, 0],
+
+        strokeColor: stroke,
+        strokeWidth: 0,
+
+        color: '--affine-white',
+
+        fontFamily: FontFamily.Inter,
+        fontWeight: FontWeight.Bold,
+        fontSize: 15,
+      },
       node: {
         ...this.root,
 
diff --git a/packages/affine/model/src/elements/shape/shape.ts b/packages/affine/model/src/elements/shape/shape.ts
index 05420559f551..07ffb445812d 100644
--- a/packages/affine/model/src/elements/shape/shape.ts
+++ b/packages/affine/model/src/elements/shape/shape.ts
@@ -12,8 +12,10 @@ import type {
 
 import {
   field,
+  GfxLocalElementModel,
   GfxPrimitiveElementModel,
   local,
+  prop,
 } from '@blocksuite/block-std/gfx';
 import { DocCollection, type Y } from '@blocksuite/store';
 
@@ -59,6 +61,9 @@ export const SHAPE_TEXT_PADDING = 20;
 export const SHAPE_TEXT_VERTICAL_PADDING = 10;
 
 export class ShapeElementModel extends GfxPrimitiveElementModel<ShapeProps> {
+  /**
+   * The bound of the text content.
+   */
   textBound: IBound | null = null;
 
   get type() {
@@ -186,6 +191,80 @@ export class ShapeElementModel extends GfxPrimitiveElementModel<ShapeProps> {
   accessor xywh: SerializedXYWH = '[0,0,100,100]';
 }
 
+export class LocalShapeElementModel extends GfxLocalElementModel {
+  roughness: number = DEFAULT_ROUGHNESS;
+
+  textBound: Bound | null = null;
+
+  textDisplay: boolean = true;
+
+  get type() {
+    return 'shape';
+  }
+
+  @prop()
+  accessor color: Color = '#000000';
+
+  @prop()
+  accessor fillColor: Color = ShapeFillColor.Yellow;
+
+  @prop()
+  accessor filled: boolean = false;
+
+  @prop()
+  accessor fontFamily: string = FontFamily.Inter;
+
+  @prop()
+  accessor fontSize: number = 16;
+
+  @prop()
+  accessor fontStyle: FontStyle = FontStyle.Normal;
+
+  @prop()
+  accessor fontWeight: FontWeight = FontWeight.Regular;
+
+  @prop()
+  accessor padding: [number, number] = [
+    SHAPE_TEXT_VERTICAL_PADDING,
+    SHAPE_TEXT_PADDING,
+  ];
+
+  @prop()
+  accessor radius: number = 0;
+
+  @prop()
+  accessor shadow: {
+    blur: number;
+    offsetX: number;
+    offsetY: number;
+    color: string;
+  } | null = null;
+
+  @prop()
+  accessor shapeStyle: ShapeStyle = ShapeStyle.General;
+
+  @prop()
+  accessor shapeType: ShapeType = ShapeType.Rect;
+
+  @prop()
+  accessor strokeColor: Color = LineColor.Yellow;
+
+  @prop()
+  accessor strokeStyle: StrokeStyle = StrokeStyle.Solid;
+
+  @prop()
+  accessor strokeWidth: number = 4;
+
+  @prop()
+  accessor text: string = '';
+
+  @prop()
+  accessor textAlign: TextAlign = TextAlign.Center;
+
+  @prop()
+  accessor textVerticalAlign: TextVerticalAlign = TextVerticalAlign.Center;
+}
+
 declare global {
   namespace BlockSuite {
     interface SurfaceElementModelMap {
diff --git a/packages/affine/shared/src/services/telemetry-service/telemetry-service.ts b/packages/affine/shared/src/services/telemetry-service/telemetry-service.ts
index cdc6a05f2e0f..b5d23876d44f 100644
--- a/packages/affine/shared/src/services/telemetry-service/telemetry-service.ts
+++ b/packages/affine/shared/src/services/telemetry-service/telemetry-service.ts
@@ -6,6 +6,7 @@ import type {
   DocCreatedEvent,
   ElementCreationEvent,
   ElementLockEvent,
+  MindMapCollapseEvent,
   TelemetryEvent,
 } from './types.js';
 
@@ -17,6 +18,7 @@ export type TelemetryEventMap = OutDatabaseAllEvents &
     SplitNote: TelemetryEvent;
     CanvasElementAdded: ElementCreationEvent;
     EdgelessElementLocked: ElementLockEvent;
+    ExpandedAndCollapsed: MindMapCollapseEvent;
   };
 
 export interface TelemetryService {
diff --git a/packages/affine/shared/src/services/telemetry-service/types.ts b/packages/affine/shared/src/services/telemetry-service/types.ts
index 0d41f5ba52d3..1dfcb02aaef8 100644
--- a/packages/affine/shared/src/services/telemetry-service/types.ts
+++ b/packages/affine/shared/src/services/telemetry-service/types.ts
@@ -46,3 +46,9 @@ export interface ElementLockEvent extends TelemetryEvent {
   module: 'element toolbar';
   control: 'lock' | 'unlock' | 'group-lock';
 }
+
+export interface MindMapCollapseEvent extends TelemetryEvent {
+  page: 'whiteboard editor';
+  segment: 'mind map';
+  type: 'expand' | 'collapse';
+}
diff --git a/packages/blocks/src/_common/edgeless/mindmap/index.ts b/packages/blocks/src/_common/edgeless/mindmap/index.ts
index eab7b064fc05..1b6bc866d14a 100644
--- a/packages/blocks/src/_common/edgeless/mindmap/index.ts
+++ b/packages/blocks/src/_common/edgeless/mindmap/index.ts
@@ -8,7 +8,7 @@ export function isMindmapNode(el: BlockSuite.EdgelessModel) {
   );
 }
 
-export function isSelectSingleMindMap(els: BlockSuite.EdgelessModel[]) {
+export function isSingleMindMapNode(els: BlockSuite.EdgelessModel[]) {
   return els.length === 1 && els[0].group instanceof MindmapElementModel;
 }
 
diff --git a/packages/blocks/src/root-block/edgeless/components/auto-complete/edgeless-auto-complete.ts b/packages/blocks/src/root-block/edgeless/components/auto-complete/edgeless-auto-complete.ts
index 6b476e2f5bd0..03e544a365bc 100644
--- a/packages/blocks/src/root-block/edgeless/components/auto-complete/edgeless-auto-complete.ts
+++ b/packages/blocks/src/root-block/edgeless/components/auto-complete/edgeless-auto-complete.ts
@@ -115,6 +115,10 @@ export class EdgelessAutoComplete extends WithDisposable(LitElement) {
         background 0.3s linear,
         box-shadow 0.2s linear;
     }
+    .edgeless-auto-complete-arrow-wrapper.mindmap {
+      width: 26px;
+      height: 26px;
+    }
 
     .edgeless-auto-complete-arrow-wrapper:hover
       > .edgeless-auto-complete-arrow {
@@ -244,11 +248,15 @@ export class EdgelessAutoComplete extends WithDisposable(LitElement) {
 
     if (!(mindmap instanceof MindmapElementModel)) return;
 
-    const parentNode =
+    const parent =
       target === 'sibling'
         ? (mindmap.getParentNode(this.current.id) ?? this.current)
         : this.current;
 
+    const parentNode = mindmap.getNode(parent.id);
+
+    if (!parentNode) return;
+
     const newNode = mindmap.addNode(
       parentNode.id,
       target === 'sibling' ? this.current.id : undefined,
@@ -256,6 +264,10 @@ export class EdgelessAutoComplete extends WithDisposable(LitElement) {
       undefined
     );
 
+    if (parentNode.detail.collapsed) {
+      mindmap.toggleCollapse(parentNode);
+    }
+
     requestAnimationFrame(() => {
       mountShapeTextEditor(
         this.edgeless.service.getElementById(newNode) as ShapeElementModel,
@@ -399,9 +411,7 @@ export class EdgelessAutoComplete extends WithDisposable(LitElement) {
     }, [] as ShapeElementModel[]);
   }
 
-  private _getMindmapButtons():
-    | [Direction, 'child' | 'sibling', LayoutType.LEFT | LayoutType.RIGHT][]
-    | null {
+  private _getMindmapButtons() {
     const mindmap = this.current.group as MindmapElementModel;
     const mindmapDirection =
       this.current instanceof ShapeElementModel &&
@@ -409,35 +419,45 @@ export class EdgelessAutoComplete extends WithDisposable(LitElement) {
         ? mindmap.getLayoutDir(this.current.id)
         : null;
     const isRoot = mindmap?.tree.id === this.current.id;
+    const mindmapNode = mindmap.getNode(this.current.id);
 
-    let result: ReturnType<typeof this._getMindmapButtons> = null;
+    let buttons: [
+      Direction,
+      'child' | 'sibling',
+      LayoutType.LEFT | LayoutType.RIGHT,
+    ][] = [];
 
     switch (mindmapDirection) {
       case LayoutType.LEFT:
-        result = [[Direction.Left, 'child', LayoutType.LEFT]];
+        buttons = [[Direction.Left, 'child', LayoutType.LEFT]];
 
         if (!isRoot) {
-          result.push([Direction.Bottom, 'sibling', mindmapDirection]);
+          buttons.push([Direction.Bottom, 'sibling', mindmapDirection]);
         }
-        return result;
+        break;
       case LayoutType.RIGHT:
-        result = [[Direction.Right, 'child', LayoutType.RIGHT]];
+        buttons = [[Direction.Right, 'child', LayoutType.RIGHT]];
 
         if (!isRoot) {
-          result.push([Direction.Bottom, 'sibling', mindmapDirection]);
+          buttons.push([Direction.Bottom, 'sibling', mindmapDirection]);
         }
-        return result;
+        break;
       case LayoutType.BALANCE:
-        result = [
+        buttons = [
           [Direction.Right, 'child', LayoutType.RIGHT],
           [Direction.Left, 'child', LayoutType.LEFT],
         ];
-        return result;
+        break;
       default:
-        result = null;
+        buttons = [];
     }
 
-    return result;
+    return buttons.length
+      ? {
+          mindmapNode,
+          buttons,
+        }
+      : null;
   }
 
   private _initOverlay() {
@@ -546,11 +566,14 @@ export class EdgelessAutoComplete extends WithDisposable(LitElement) {
 
     const { selectedRect } = this;
     const { zoom } = this.edgeless.service.viewport;
-    const width = 72;
-    const height = 44;
-    const buttonMargin = height / 2;
-
-    return mindmapButtons.map(type => {
+    const size = 26;
+    const buttonMargin =
+      (mindmapButtons.mindmapNode?.children.length ?? 0) > 0
+        ? size / 2 + 32 * zoom
+        : size / 2 + 6;
+    const verticalMargin = size / 2 + 6;
+
+    return mindmapButtons.buttons.map(type => {
       let transform = '';
 
       const [position, target, layout] = type;
@@ -560,7 +583,7 @@ export class EdgelessAutoComplete extends WithDisposable(LitElement) {
       switch (position) {
         case Direction.Bottom:
           transform += `translate(${selectedRect.width / 2}px, ${
-            selectedRect.height + buttonMargin
+            selectedRect.height + verticalMargin
           }px)`;
           isLeftLayout && (transform += `scale(-1)`);
           break;
@@ -578,7 +601,7 @@ export class EdgelessAutoComplete extends WithDisposable(LitElement) {
           break;
       }
 
-      transform += `translate(${-width / 2}px, ${-height / 2}px)`;
+      transform += `translate(${-size / 2}px, ${-size / 2}px)`;
 
       const arrowWrapperClasses = classMap({
         'edgeless-auto-complete-arrow-wrapper': true,
diff --git a/packages/blocks/src/root-block/edgeless/components/rects/edgeless-selected-rect.ts b/packages/blocks/src/root-block/edgeless/components/rects/edgeless-selected-rect.ts
index fac52c7254fc..6369b9864073 100644
--- a/packages/blocks/src/root-block/edgeless/components/rects/edgeless-selected-rect.ts
+++ b/packages/blocks/src/root-block/edgeless/components/rects/edgeless-selected-rect.ts
@@ -40,6 +40,7 @@ import {
 } from '@blocksuite/affine-shared/utils';
 import { WidgetComponent } from '@blocksuite/block-std';
 import {
+  type CursorType,
   getTopElements,
   GfxControllerIdentifier,
   GfxExtensionIdentifier,
@@ -660,7 +661,7 @@ export class EdgelessSelectedRectWidget extends WidgetComponent<
       point?: IVec;
     }
   ) => {
-    let cursor = 'default';
+    let cursor: CursorType = 'default';
 
     if (dragging && options) {
       const { type, target, point } = options;
@@ -670,10 +671,10 @@ export class EdgelessSelectedRectWidget extends WidgetComponent<
           angle = calcAngle(target, point, 45);
         }
         this._cursorRotate += angle || 0;
-        cursor = generateCursorUrl(this._cursorRotate).toString();
+        cursor = generateCursorUrl(this._cursorRotate);
       } else {
         if (this.resizeMode === 'edge') {
-          cursor = 'ew';
+          cursor = 'ew-resize';
         } else if (target && point) {
           const label = getResizeLabel(target);
           const { width, height, left, top } = this._selectedRect;
@@ -702,12 +703,11 @@ export class EdgelessSelectedRectWidget extends WidgetComponent<
           }
           cursor = rotateResizeCursor((angle * Math.PI) / 180);
         }
-        cursor += '-resize';
       }
     } else {
       this._cursorRotate = 0;
     }
-    this.edgelessSlots.cursorUpdated.emit(cursor);
+    this.gfx.cursor$.value = cursor;
   };
 
   private _updateMode = () => {
diff --git a/packages/blocks/src/root-block/edgeless/components/utils.ts b/packages/blocks/src/root-block/edgeless/components/utils.ts
index 82e270a7dcd3..a66e831485ac 100644
--- a/packages/blocks/src/root-block/edgeless/components/utils.ts
+++ b/packages/blocks/src/root-block/edgeless/components/utils.ts
@@ -1,3 +1,4 @@
+import type { CursorType, StandardCursor } from '@blocksuite/block-std/gfx';
 import type { IVec } from '@blocksuite/global/utils';
 
 import { CommonUtils } from '@blocksuite/affine-block-surface';
@@ -10,7 +11,10 @@ import {
 } from '../../../image-block/components/image-block-fallback.js';
 
 // "<svg width='32' height='32' viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg'><g><path fill='white' d='M13.7,18.5h3.9l0-1.5c0-1.4-1.2-2.6-2.6-2.6h-1.5v3.9l-5.8-5.8l5.8-5.8v3.9h2.3c3.1,0,5.6,2.5,5.6,5.6v2.3h3.9l-5.8,5.8L13.7,18.5z'/><path d='M20.4,19.4v-3.2c0-2.6-2.1-4.7-4.7-4.7h-3.2l0,0V9L9,12.6l3.6,3.6v-2.6l0,0H15c1.9,0,3.5,1.6,3.5,3.5v2.4l0,0h-2.6l3.6,3.6l3.6-3.6L20.4,19.4L20.4,19.4z'/></g></svg>";
-export function generateCursorUrl(angle = 0, fallback = css`default`) {
+export function generateCursorUrl(
+  angle = 0,
+  fallback: StandardCursor = 'default'
+): CursorType {
   return `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cg transform='rotate(${angle} 16 16)'%3E%3Cpath fill='white' d='M13.7,18.5h3.9l0-1.5c0-1.4-1.2-2.6-2.6-2.6h-1.5v3.9l-5.8-5.8l5.8-5.8v3.9h2.3c3.1,0,5.6,2.5,5.6,5.6v2.3h3.9l-5.8,5.8L13.7,18.5z'/%3E%3Cpath d='M20.4,19.4v-3.2c0-2.6-2.1-4.7-4.7-4.7h-3.2l0,0V9L9,12.6l3.6,3.6v-2.6l0,0H15c1.9,0,3.5,1.6,3.5,3.5v2.4l0,0h-2.6l3.6,3.6l3.6-3.6L20.4,19.4L20.4,19.4z'/%3E%3C/g%3E%3C/svg%3E") 16 16, ${fallback}`;
 }
 
@@ -97,11 +101,16 @@ export function readImageSize(file: File) {
   });
 }
 
-const RESIZE_CURSORS = ['ew', 'nwse', 'ns', 'nesw'];
-export function rotateResizeCursor(angle: number) {
+const RESIZE_CURSORS: CursorType[] = [
+  'ew-resize',
+  'nwse-resize',
+  'ns-resize',
+  'nesw-resize',
+];
+export function rotateResizeCursor(angle: number): StandardCursor {
   const a = Math.round(angle / (Math.PI / 4));
   const cursor = RESIZE_CURSORS[a % RESIZE_CURSORS.length];
-  return cursor;
+  return cursor as StandardCursor;
 }
 
 export function calcAngle(target: HTMLElement, point: IVec, offset = 0) {
diff --git a/packages/blocks/src/root-block/edgeless/edgeless-keyboard.ts b/packages/blocks/src/root-block/edgeless/edgeless-keyboard.ts
index 163c0a91d7a7..330767016c0c 100644
--- a/packages/blocks/src/root-block/edgeless/edgeless-keyboard.ts
+++ b/packages/blocks/src/root-block/edgeless/edgeless-keyboard.ts
@@ -28,7 +28,7 @@ import type { EdgelessRootBlockComponent } from './edgeless-root-block.js';
 import {
   getNearestTranslation,
   isElementOutsideViewport,
-  isSelectSingleMindMap,
+  isSingleMindMapNode,
 } from '../../_common/edgeless/mindmap/index.js';
 import { LassoMode } from '../../_common/types.js';
 import { EdgelessTextBlockComponent } from '../../edgeless-text-block/edgeless-text-block.js';
@@ -367,7 +367,7 @@ export class EdgelessPageKeyboardManager extends PageKeyboardManager {
             }
           }
 
-          if (!isSelectSingleMindMap(elements)) {
+          if (!isSingleMindMapNode(elements)) {
             return;
           }
 
@@ -398,7 +398,7 @@ export class EdgelessPageKeyboardManager extends PageKeyboardManager {
           const selection = service.selection;
           const elements = selection.selectedElements;
 
-          if (!isSelectSingleMindMap(elements)) {
+          if (!isSingleMindMapNode(elements)) {
             return;
           }
 
@@ -409,6 +409,10 @@ export class EdgelessPageKeyboardManager extends PageKeyboardManager {
           const id = mindmap.addNode(node.id);
           const target = service.getElementById(id) as ShapeElementModel;
 
+          if (node.detail.collapsed) {
+            mindmap.toggleCollapse(node, { layout: true });
+          }
+
           requestAnimationFrame(() => {
             mountShapeTextEditor(target, rootComponent);
 
@@ -477,7 +481,7 @@ export class EdgelessPageKeyboardManager extends PageKeyboardManager {
           const elements = selection.selectedElements;
           const doc = this.rootComponent.doc;
 
-          if (isSelectSingleMindMap(elements)) {
+          if (isSingleMindMapNode(elements)) {
             const target = service.getElementById(
               elements[0].id
             ) as ShapeElementModel;
@@ -519,7 +523,7 @@ export class EdgelessPageKeyboardManager extends PageKeyboardManager {
     const selectedElements = edgeless.service.selection.selectedElements;
     if (selectedElements.some(e => e.isLocked())) return;
 
-    if (isSelectSingleMindMap(selectedElements)) {
+    if (isSingleMindMapNode(selectedElements)) {
       const node = selectedElements[0];
       const mindmap = node.group as MindmapElementModel;
       const focusNode =
diff --git a/packages/blocks/src/root-block/edgeless/edgeless-root-block.ts b/packages/blocks/src/root-block/edgeless/edgeless-root-block.ts
index b502ff42505b..edd426d105a5 100644
--- a/packages/blocks/src/root-block/edgeless/edgeless-root-block.ts
+++ b/packages/blocks/src/root-block/edgeless/edgeless-root-block.ts
@@ -30,13 +30,8 @@ import {
   type GfxViewportElement,
 } from '@blocksuite/block-std/gfx';
 import { IS_WINDOWS } from '@blocksuite/global/env';
-import {
-  assertExists,
-  Bound,
-  Point,
-  throttle,
-  Vec,
-} from '@blocksuite/global/utils';
+import { assertExists, Bound, Point, Vec } from '@blocksuite/global/utils';
+import { effect } from '@preact/signals-core';
 import { css, html } from 'lit';
 import { query } from 'lit/decorators.js';
 import { repeat } from 'lit/directives/repeat.js';
@@ -46,7 +41,7 @@ import type { EdgelessRootBlockWidgetName } from '../types.js';
 import type { EdgelessSelectedRectWidget } from './components/rects/edgeless-selected-rect.js';
 import type { EdgelessRootService } from './edgeless-root-service.js';
 
-import { isSelectSingleMindMap } from '../../_common/edgeless/mindmap/index.js';
+import { isSingleMindMapNode } from '../../_common/edgeless/mindmap/index.js';
 import { EdgelessClipboardController } from './clipboard/clipboard.js';
 import { EdgelessPageKeyboardManager } from './edgeless-keyboard.js';
 import { getBackgroundGrid, isCanvasElement } from './utils/query.js';
@@ -327,11 +322,9 @@ export class EdgelessRootBlockComponent extends BlockComponent<
     );
 
     disposables.add(
-      slots.cursorUpdated.on(
-        throttle((cursor: string) => {
-          this.style.cursor = cursor;
-        }, 144)
-      )
+      effect(() => {
+        this.style.cursor = this.gfx.cursor$.value;
+      })
     );
 
     let canCopyAsPng = true;
@@ -438,7 +431,7 @@ export class EdgelessRootBlockComponent extends BlockComponent<
         keymap[key] = ctx => {
           const elements = selection.selectedElements;
 
-          if (isSelectSingleMindMap(elements) && !selection.editing) {
+          if (isSingleMindMapNode(elements) && !selection.editing) {
             const target = gfx.getElementById(
               elements[0].id
             ) as ShapeElementModel;
diff --git a/packages/blocks/src/root-block/edgeless/edgeless-root-service.ts b/packages/blocks/src/root-block/edgeless/edgeless-root-service.ts
index 21c2803798fe..3ad8f59bed94 100644
--- a/packages/blocks/src/root-block/edgeless/edgeless-root-service.ts
+++ b/packages/blocks/src/root-block/edgeless/edgeless-root-service.ts
@@ -64,7 +64,6 @@ export class EdgelessRootService extends RootService implements SurfaceContext {
 
   slots = {
     pressShiftKeyUpdated: new Slot<boolean>(),
-    cursorUpdated: new Slot<string>(),
     copyAsPng: new Slot<{
       blocks: BlockSuite.EdgelessBlockModelType[];
       shapes: BlockSuite.SurfaceModel[];
@@ -209,12 +208,12 @@ export class EdgelessRootService extends RootService implements SurfaceContext {
   }
 
   private _initSlotEffects() {
-    const { disposables, slots } = this;
+    const { disposables } = this;
 
     disposables.add(
       effect(() => {
         const value = this.gfx.tool.currentToolOption$.value;
-        slots.cursorUpdated.emit(getCursorMode(value));
+        this.gfx.cursor$.value = getCursorMode(value);
       })
     );
   }
diff --git a/packages/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/drag-utils.ts b/packages/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/drag-utils.ts
index d557ea336a41..63a680d4e3c2 100644
--- a/packages/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/drag-utils.ts
+++ b/packages/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/drag-utils.ts
@@ -52,6 +52,10 @@ const fillResponseArea = (
       rootElmBound.h
     );
 
+    if (node.detail.collapsed) {
+      return;
+    }
+
     if (layoutType === LayoutType.BALANCE) {
       (node as MindmapRoot).right.forEach(child => {
         fillResponseArea(child, LayoutType.RIGHT, node);
@@ -95,7 +99,7 @@ const fillResponseArea = (
       h
     );
 
-    if (node.children.length > 0) {
+    if (node.children.length > 0 && !node.detail.collapsed) {
       let responseArea: Bound;
 
       node.children.forEach(child => {
diff --git a/packages/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/indicator-overlay.ts b/packages/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/indicator-overlay.ts
index f17cff7b324c..d44571503200 100644
--- a/packages/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/indicator-overlay.ts
+++ b/packages/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/indicator-overlay.ts
@@ -267,7 +267,7 @@ export class MindMapIndicatorOverlay extends Overlay {
               insertPosition.layoutDir
             );
     } else {
-      if (parentChildren.length === 0) {
+      if (parentChildren.length === 0 || parent.detail.collapsed) {
         this.targetBound = parentBound.moveDelta(
           (isLeftLayout ? -1 : 1) *
             (NODE_HORIZONTAL_SPACING / 2 + parentBound.w),
diff --git a/packages/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/mind-map-ext.ts b/packages/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/mind-map-ext.ts
index bf3082491d4e..872dd6f83124 100644
--- a/packages/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/mind-map-ext.ts
+++ b/packages/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/mind-map-ext.ts
@@ -23,7 +23,7 @@ import type { MindMapIndicatorOverlay } from './indicator-overlay.js';
 
 import {
   isMindmapNode,
-  isSelectSingleMindMap,
+  isSingleMindMapNode,
 } from '../../../../../_common/edgeless/mindmap/index.js';
 import { DefaultModeDragType, DefaultToolExt, type DragState } from '../ext.js';
 import { calculateResponseArea } from './drag-utils.js';
@@ -391,7 +391,7 @@ export class MindMapExt extends DefaultToolExt {
       return {};
     }
 
-    if (isSelectSingleMindMap(dragState.movedElements)) {
+    if (isSingleMindMapNode(dragState.movedElements)) {
       const mindmap = dragState.movedElements[0].group as MindmapElementModel;
       const mindmapNode = mindmap.getNode(dragState.movedElements[0].id)!;
       const mindmapBound = mindmap.elementBound;
diff --git a/packages/blocks/src/root-block/edgeless/gfx-tool/default-tool.ts b/packages/blocks/src/root-block/edgeless/gfx-tool/default-tool.ts
index 02944e9d620e..5a323f3adef9 100644
--- a/packages/blocks/src/root-block/edgeless/gfx-tool/default-tool.ts
+++ b/packages/blocks/src/root-block/edgeless/gfx-tool/default-tool.ts
@@ -49,7 +49,7 @@ import type { EdgelessFrameManager, FrameOverlay } from '../frame-manager.js';
 import type { EdgelessSnapManager } from '../utils/snap-manager.js';
 import type { DefaultToolExt } from './default-tool-ext/ext.js';
 
-import { isSelectSingleMindMap } from '../../../_common/edgeless/mindmap/index.js';
+import { isSingleMindMapNode } from '../../../_common/edgeless/mindmap/index.js';
 import { prepareCloneData } from '../utils/clone-utils.js';
 import { calPanDelta } from '../utils/panning-utils.js';
 import {
@@ -572,7 +572,7 @@ export class DefaultTool extends BaseTool {
         this._toBeMoved.every(
           ele => !(ele.group instanceof MindmapElementModel)
         )) ||
-      (isSelectSingleMindMap(this._toBeMoved) &&
+      (isSingleMindMapNode(this._toBeMoved) &&
         this._toBeMoved[0].id ===
           (this._toBeMoved[0].group as MindmapElementModel).tree.id)
     ) {
diff --git a/packages/blocks/src/surface-block/mini-mindmap/surface-block.ts b/packages/blocks/src/surface-block/mini-mindmap/surface-block.ts
index ed5dfd207ba8..1399a74db041 100644
--- a/packages/blocks/src/surface-block/mini-mindmap/surface-block.ts
+++ b/packages/blocks/src/surface-block/mini-mindmap/surface-block.ts
@@ -82,7 +82,6 @@ export class MindmapSurfaceBlock extends BlockComponent<SurfaceBlockModel> {
   private _setupRenderer() {
     this._disposables.add(
       this.model.elementUpdated.on(() => {
-        this.renderer?.refresh();
         this.mindmapService.center();
       })
     );
@@ -122,7 +121,9 @@ export class MindmapSurfaceBlock extends BlockComponent<SurfaceBlockModel> {
           ),
       },
       elementRenderers,
+      surfaceModel: this.model,
     });
+    this._disposables.add(this.renderer);
   }
 
   override firstUpdated(_changedProperties: Map<PropertyKey, unknown>): void {
diff --git a/packages/framework/block-std/src/gfx/controller.ts b/packages/framework/block-std/src/gfx/controller.ts
index fd64b6aa953c..e1426fa7ad5e 100644
--- a/packages/framework/block-std/src/gfx/controller.ts
+++ b/packages/framework/block-std/src/gfx/controller.ts
@@ -8,9 +8,11 @@ import {
   type IBound,
   last,
 } from '@blocksuite/global/utils';
+import { Signal } from '@preact/signals-core';
 
 import type { BlockStdScope } from '../scope/block-std-scope.js';
 import type { BlockComponent } from '../view/index.js';
+import type { CursorType } from './cursor.js';
 import type { PointTestOptions } from './model/base.js';
 import type { GfxModel } from './model/model.js';
 import type { SurfaceBlockModel } from './model/surface/surface-model.js';
@@ -39,6 +41,8 @@ export class GfxController extends LifeCycleWatcher {
 
   private _surface: SurfaceBlockModel | null = null;
 
+  readonly cursor$ = new Signal<CursorType>();
+
   readonly grid: GridManager;
 
   readonly keyboard: KeyboardController;
diff --git a/packages/framework/block-std/src/gfx/cursor.ts b/packages/framework/block-std/src/gfx/cursor.ts
new file mode 100644
index 000000000000..ecd8ae11ca55
--- /dev/null
+++ b/packages/framework/block-std/src/gfx/cursor.ts
@@ -0,0 +1,54 @@
+export type StandardCursor =
+  | 'default'
+  | 'pointer'
+  | 'move'
+  | 'text'
+  | 'crosshair'
+  | 'not-allowed'
+  | 'grab'
+  | 'grabbing'
+  | 'nwse-resize'
+  | 'nesw-resize'
+  | 'ew-resize'
+  | 'ns-resize'
+  | 'n-resize'
+  | 's-resize'
+  | 'w-resize'
+  | 'e-resize'
+  | 'ne-resize'
+  | 'se-resize'
+  | 'sw-resize'
+  | 'nw-resize'
+  | 'zoom-in'
+  | 'zoom-out'
+  | 'help'
+  | 'wait'
+  | 'progress'
+  | 'copy'
+  | 'alias'
+  | 'context-menu'
+  | 'cell'
+  | 'vertical-text'
+  | 'no-drop'
+  | 'not-allowed'
+  | 'all-scroll'
+  | 'col-resize'
+  | 'row-resize'
+  | 'none'
+  | 'inherit'
+  | 'initial'
+  | 'unset';
+
+export type URLCursor = `url(${string})`;
+
+export type URLCursorWithCoords = `url(${string}) ${number} ${number}`;
+
+export type URLCursorWithFallback =
+  | `${URLCursor}, ${StandardCursor}`
+  | `${URLCursorWithCoords}, ${StandardCursor}`;
+
+export type CursorType =
+  | StandardCursor
+  | URLCursor
+  | URLCursorWithCoords
+  | URLCursorWithFallback;
diff --git a/packages/framework/block-std/src/gfx/grid.ts b/packages/framework/block-std/src/gfx/grid.ts
index c8e7975e4e19..16f1f64cfd2a 100644
--- a/packages/framework/block-std/src/gfx/grid.ts
+++ b/packages/framework/block-std/src/gfx/grid.ts
@@ -30,6 +30,12 @@ function rangeFromBound(a: IBound): number[] {
 
 function rangeFromElement(ele: GfxModel | GfxLocalElementModel): number[] {
   const bound = ele.elementBound;
+
+  bound.w += ele.responseExtension[0] * 2;
+  bound.h += ele.responseExtension[1] * 2;
+  bound.x -= ele.responseExtension[0];
+  bound.y -= ele.responseExtension[1];
+
   const minRow = getGridIndex(bound.x);
   const maxRow = getGridIndex(bound.maxX);
   const minCol = getGridIndex(bound.y);
@@ -326,10 +332,11 @@ export class GridManager {
         if (!gridElements) continue;
         for (const element of gridElements) {
           if (
+            !(element as GfxPrimitiveElementModel).hidden &&
             filterFunc(element) &&
             (strict
               ? b.contains(element.elementBound)
-              : intersects(element.elementBound, b))
+              : intersects(element.responseBound, b))
           ) {
             results.add(element);
           }
@@ -422,7 +429,11 @@ export class GridManager {
 
       disposables.push(
         surface.elementUpdated.on(payload => {
-          if (payload.props['xywh'] || payload.props['externalXYWH']) {
+          if (
+            payload.props['xywh'] ||
+            payload.props['externalXYWH'] ||
+            payload.props['responseExtension']
+          ) {
             this.update(surface.getElementById(payload.id)!);
           }
         })
@@ -436,7 +447,7 @@ export class GridManager {
 
       disposables.push(
         surface.localElementUpdated.on(payload => {
-          if (payload.props['xywh']) {
+          if (payload.props['xywh'] || payload.props['responseExtension']) {
             this.update(payload.model);
           }
         })
@@ -451,6 +462,9 @@ export class GridManager {
       surface.elementModels.forEach(model => {
         this.add(model);
       });
+      surface.localElementModels.forEach(model => {
+        this.add(model);
+      });
     }
 
     return () => {
diff --git a/packages/framework/block-std/src/gfx/index.ts b/packages/framework/block-std/src/gfx/index.ts
index 4a1e17d89786..d1e297421937 100644
--- a/packages/framework/block-std/src/gfx/index.ts
+++ b/packages/framework/block-std/src/gfx/index.ts
@@ -11,6 +11,7 @@ export {
   hasDescendantElementImpl,
 } from '../utils/tree.js';
 export { GfxController } from './controller.js';
+export type { CursorType, StandardCursor } from './cursor.js';
 export { GfxExtension, GfxExtensionIdentifier } from './extension.js';
 export { GridManager } from './grid.js';
 export { GfxControllerIdentifier } from './identifiers.js';
diff --git a/packages/framework/block-std/src/gfx/layer.ts b/packages/framework/block-std/src/gfx/layer.ts
index e70156ba3fc5..1c1c9a528ed5 100644
--- a/packages/framework/block-std/src/gfx/layer.ts
+++ b/packages/framework/block-std/src/gfx/layer.ts
@@ -292,11 +292,6 @@ export class LayerManager {
   }
 
   private _insertIntoLayer(target: GfxModel, type: 'block' | 'canvas') {
-    if (this.layers.length === 0) {
-      this._initLayers();
-      return;
-    }
-
     const layers = this.layers;
     let cur = layers.length - 1;
 
@@ -347,6 +342,7 @@ export class LayerManager {
     };
 
     if (
+      !last(this.layers) ||
       [SortOrder.AFTER, SortOrder.SAME].includes(
         compare(target, last(last(this.layers)!.elements)!)
       )
diff --git a/packages/framework/block-std/src/gfx/model/base.ts b/packages/framework/block-std/src/gfx/model/base.ts
index 975660823c68..66ceee9216bd 100644
--- a/packages/framework/block-std/src/gfx/model/base.ts
+++ b/packages/framework/block-std/src/gfx/model/base.ts
@@ -35,14 +35,39 @@ export interface GfxCompatibleInterface extends IBound, GfxElementGeometry {
   xywh: SerializedXYWH;
   index: string;
 
+  /**
+   * Defines the extension of the response area beyond the element's bounding box.
+   * This tuple specifies the horizontal and vertical margins to be added to the element's bound.
+   *
+   * The first value represents the horizontal extension (added to both left and right sides),
+   * and the second value represents the vertical extension (added to both top and bottom sides).
+   *
+   * The response area is computed as:
+   * `[x - horizontal, y - vertical, w + 2 * horizontal, h + 2 * vertical]`.
+   *
+   * Example:
+   * - xywh: `[0, 0, 100, 100]`, `responseExtension: [10, 20]`
+   *   Resulting response area: `[-10, -20, 120, 140]`.
+   * - `responseExtension: [0, 0]` keeps the response area equal to the bounding box.
+   */
+  responseExtension: [number, number];
+
   readonly group: GfxGroupCompatibleInterface | null;
 
   readonly groups: GfxGroupCompatibleInterface[];
 
   readonly deserializedXYWH: XYWH;
 
+  /**
+   * The bound of the element without considering the response extension.
+   */
   readonly elementBound: Bound;
 
+  /**
+   * The bound of the element considering the response extension.
+   */
+  readonly responseBound: Bound;
+
   /**
    * Indicates whether the current block is explicitly locked by self.
    * For checking the lock status of the element, use `isLocked` instead.
@@ -115,6 +140,12 @@ export interface PointTestOptions {
    */
   hitThreshold?: number;
 
+  /**
+   * If true, the element bound will be used for the hit testing.
+   * By default, the response bound will be used.
+   */
+  useElementBound?: boolean;
+
   /**
    * The padding of the response area for each element when do the hit testing. The unit is pixel.
    * The first value is the padding for the x-axis, and the second value is the padding for the y-axis.
diff --git a/packages/framework/block-std/src/gfx/model/gfx-block-model.ts b/packages/framework/block-std/src/gfx/model/gfx-block-model.ts
index 38d3cf2e4c84..58ccd9595f4a 100644
--- a/packages/framework/block-std/src/gfx/model/gfx-block-model.ts
+++ b/packages/framework/block-std/src/gfx/model/gfx-block-model.ts
@@ -69,6 +69,23 @@ export class GfxBlockElementModel<
 
   connectable = true;
 
+  /**
+   * Defines the extension of the response area beyond the element's bounding box.
+   * This tuple specifies the horizontal and vertical margins to be added to the element's [x, y, width, height].
+   *
+   * The first value represents the horizontal extension (added to both left and right sides),
+   * and the second value represents the vertical extension (added to both top and bottom sides).
+   *
+   * The response area is computed as:
+   * `[x - horizontal, y - vertical, width + 2 * horizontal, height + 2 * vertical]`.
+   *
+   * Example:
+   * - Bounding box: `[0, 0, 100, 100]`, `responseExtension: [10, 20]`
+   *   Resulting response area: `[-10, -20, 120, 140]`.
+   * - `responseExtension: [0, 0]` keeps the response area equal to the bounding box.
+   */
+  responseExtension: [number, number] = [0, 0];
+
   rotate = 0;
 
   get deserializedXYWH() {
@@ -112,6 +129,10 @@ export class GfxBlockElementModel<
     return this.deserializedXYWH[3];
   }
 
+  get responseBound() {
+    return this.elementBound.expand(this.responseExtension);
+  }
+
   get surface(): SurfaceBlockModel | null {
     const result = this.doc.getBlocksByFlavour('affine:surface');
     if (result.length === 0) return null;
@@ -177,10 +198,10 @@ export class GfxBlockElementModel<
   includesPoint(
     x: number,
     y: number,
-    _: PointTestOptions,
+    opt: PointTestOptions,
     __: EditorHost
   ): boolean {
-    const bound = Bound.deserialize(this.xywh);
+    const bound = opt.useElementBound ? this.elementBound : this.responseBound;
     return bound.isPointInBound([x, y], 0);
   }
 
diff --git a/packages/framework/block-std/src/gfx/model/surface/element-model.ts b/packages/framework/block-std/src/gfx/model/surface/element-model.ts
index 4f0da04b334e..ed6db9e448fd 100644
--- a/packages/framework/block-std/src/gfx/model/surface/element-model.ts
+++ b/packages/framework/block-std/src/gfx/model/surface/element-model.ts
@@ -161,6 +161,10 @@ export abstract class GfxPrimitiveElementModel<
     return this.surface.hasElementById(this.id);
   }
 
+  get responseBound() {
+    return this.elementBound.expand(this.responseExtension);
+  }
+
   abstract get type(): string;
 
   get w() {
@@ -230,10 +234,11 @@ export abstract class GfxPrimitiveElementModel<
   includesPoint(
     x: number,
     y: number,
-    _: PointTestOptions,
+    opt: PointTestOptions,
     __: EditorHost
   ): boolean {
-    return this.elementBound.isPointInBound([x, y]);
+    const bound = opt.useElementBound ? this.elementBound : this.responseBound;
+    return bound.isPointInBound([x, y]);
   }
 
   intersectsBound(bound: Bound): boolean {
@@ -263,6 +268,11 @@ export abstract class GfxPrimitiveElementModel<
 
   onCreated() {}
 
+  onDestroyed() {
+    this._disposable.dispose();
+    this.propsUpdated.dispose();
+  }
+
   pop(prop: keyof Props | string) {
     if (!this._stashed.has(prop)) {
       return;
@@ -354,6 +364,9 @@ export abstract class GfxPrimitiveElementModel<
   @local()
   accessor externalXYWH: SerializedXYWH | undefined = undefined;
 
+  @field(false)
+  accessor hidden: boolean = false;
+
   @field()
   accessor index!: string;
 
@@ -363,6 +376,9 @@ export abstract class GfxPrimitiveElementModel<
   @local()
   accessor opacity: number = 1;
 
+  @local()
+  accessor responseExtension: [number, number] = [0, 0];
+
   @field()
   accessor seed!: number;
 }
@@ -440,6 +456,10 @@ export abstract class GfxGroupLikeElementModel<
     let bound: Bound | undefined;
 
     this.childElements.forEach(child => {
+      if (child instanceof GfxPrimitiveElementModel && child.hidden) {
+        return;
+      }
+
       bound = bound ? bound.unite(child.elementBound) : child.elementBound;
     });
 
@@ -530,6 +550,9 @@ export function syncElementFromY(
         model['_preserved'].set(key, value);
         props[key] = value;
         oldValues[key] = oldValue;
+      } else {
+        model['_preserved'].delete(key);
+        oldValues[key] = oldValue;
       }
     });
 
diff --git a/packages/framework/block-std/src/gfx/model/surface/local-element-model.ts b/packages/framework/block-std/src/gfx/model/surface/local-element-model.ts
index 59b0558aa672..c524d274c501 100644
--- a/packages/framework/block-std/src/gfx/model/surface/local-element-model.ts
+++ b/packages/framework/block-std/src/gfx/model/surface/local-element-model.ts
@@ -98,6 +98,10 @@ export abstract class GfxLocalElementModel implements GfxCompatibleInterface {
     return this.deserializedXYWH[3];
   }
 
+  get responseBound() {
+    return this.elementBound.expand(this.responseExtension);
+  }
+
   get surface() {
     return this._surface;
   }
@@ -186,10 +190,11 @@ export abstract class GfxLocalElementModel implements GfxCompatibleInterface {
   includesPoint(
     x: number,
     y: number,
-    _: PointTestOptions,
+    opt: PointTestOptions,
     __: EditorHost
   ): boolean {
-    return this.elementBound.isPointInBound([x, y]);
+    const bound = opt.useElementBound ? this.elementBound : this.responseBound;
+    return bound.isPointInBound([x, y]);
   }
 
   intersectsBound(bound: Bound): boolean {
@@ -233,6 +238,9 @@ export abstract class GfxLocalElementModel implements GfxCompatibleInterface {
   @prop()
   accessor opacity: number = 1;
 
+  @prop()
+  accessor responseExtension: [number, number] = [0, 0];
+
   @prop()
   accessor rotate: number = 0;
 
diff --git a/packages/framework/block-std/src/gfx/model/surface/surface-model.ts b/packages/framework/block-std/src/gfx/model/surface/surface-model.ts
index c035a0302d87..d92da69bc91b 100644
--- a/packages/framework/block-std/src/gfx/model/surface/surface-model.ts
+++ b/packages/framework/block-std/src/gfx/model/surface/surface-model.ts
@@ -197,7 +197,7 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
 
     const unmount = () => {
       mounted = false;
-      elementModel['_disposable'].dispose();
+      elementModel.onDestroyed();
     };
 
     const mount = () => {
diff --git a/packages/framework/block-std/src/gfx/view/view.ts b/packages/framework/block-std/src/gfx/view/view.ts
index abc5299ccc2d..1f71be7eceb0 100644
--- a/packages/framework/block-std/src/gfx/view/view.ts
+++ b/packages/framework/block-std/src/gfx/view/view.ts
@@ -15,13 +15,13 @@ import type { GfxPrimitiveElementModel } from '../model/surface/element-model.js
 import type { GfxLocalElementModel } from '../model/surface/local-element-model.js';
 
 export type EventsHandlerMap = {
-  click: (e: PointerEventState) => void;
-  dblclick: (e: PointerEventState) => void;
-  pointerdown: (e: PointerEventState) => void;
-  pointerenter: (e: PointerEventState) => void;
-  pointerleave: (e: PointerEventState) => void;
-  pointermove: (e: PointerEventState) => void;
-  pointerup: (e: PointerEventState) => void;
+  click: PointerEventState;
+  dblclick: PointerEventState;
+  pointerdown: PointerEventState;
+  pointerenter: PointerEventState;
+  pointerleave: PointerEventState;
+  pointermove: PointerEventState;
+  pointerup: PointerEventState;
 };
 
 export type SupportedEvent = keyof EventsHandlerMap;
@@ -42,7 +42,8 @@ export class GfxElementModelView<
 
   private _handlers = new Map<
     keyof EventsHandlerMap,
-    ((evt: unknown) => void)[]
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    ((evt: any) => void)[]
   >();
 
   private _isConnected = true;
@@ -92,7 +93,7 @@ export class GfxElementModelView<
 
   dispatch<K extends keyof EventsHandlerMap>(
     event: K,
-    evt: Parameters<EventsHandlerMap[K]>[0]
+    evt: EventsHandlerMap[K]
   ) {
     this._handlers.get(event)?.forEach(callback => callback(evt));
   }
@@ -127,7 +128,10 @@ export class GfxElementModelView<
     );
   }
 
-  off<K extends keyof EventsHandlerMap>(event: K, callback: () => void) {
+  off<K extends keyof EventsHandlerMap>(
+    event: K,
+    callback: (evt: EventsHandlerMap[K]) => void
+  ) {
     if (!this._handlers.has(event)) {
       return;
     }
@@ -140,7 +144,10 @@ export class GfxElementModelView<
     }
   }
 
-  on<K extends keyof EventsHandlerMap>(event: K, callback: () => void) {
+  on<K extends keyof EventsHandlerMap>(
+    event: K,
+    callback: (evt: EventsHandlerMap[K]) => void
+  ) {
     if (!this._handlers.has(event)) {
       this._handlers.set(event, []);
     }
@@ -150,10 +157,13 @@ export class GfxElementModelView<
     return () => this.off(event, callback);
   }
 
-  once<K extends keyof EventsHandlerMap>(event: K, callback: () => void) {
-    const off = this.on(event, () => {
+  once<K extends keyof EventsHandlerMap>(
+    event: K,
+    callback: (evt: EventsHandlerMap[K]) => void
+  ) {
+    const off = this.on(event, evt => {
       off();
-      callback();
+      callback(evt);
     });
 
     return off;
@@ -168,6 +178,7 @@ export class GfxElementModelView<
   onDestroyed() {
     this._isConnected = false;
     this.disposable.dispose();
+    this._handlers.clear();
   }
 
   render(_: RendererContext) {}
diff --git a/packages/framework/block-std/src/utils/layer.ts b/packages/framework/block-std/src/utils/layer.ts
index 35eb30dbd3ac..a09c2b4192ed 100644
--- a/packages/framework/block-std/src/utils/layer.ts
+++ b/packages/framework/block-std/src/utils/layer.ts
@@ -19,7 +19,7 @@ export function getLayerEndZIndex(layers: Layer[], layerIndex: number) {
     ? layer.type === 'block'
       ? layer.zIndex + layer.elements.length - 1
       : layer.zIndex
-    : 1;
+    : 0;
 }
 
 export function updateLayersZIndex(layers: Layer[], startIdx: number) {
diff --git a/packages/framework/global/src/utils/model/bound.ts b/packages/framework/global/src/utils/model/bound.ts
index 5a9bf7e86f46..a0ffffa99788 100644
--- a/packages/framework/global/src/utils/model/bound.ts
+++ b/packages/framework/global/src/utils/model/bound.ts
@@ -182,12 +182,23 @@ export class Bound implements IBound {
     return minX <= x && x <= maxX && minY <= y && y <= maxY;
   }
 
+  expand(margin: [number, number]): Bound;
+  expand(left: number, top?: number, right?: number, bottom?: number): Bound;
   expand(
-    left: number,
-    top: number = left,
-    right: number = left,
-    bottom: number = top
+    left: number | [number, number],
+    top?: number,
+    right?: number,
+    bottom?: number
   ) {
+    if (Array.isArray(left)) {
+      const [x, y] = left;
+      return new Bound(this.x - x, this.y - y, this.w + x * 2, this.h + y * 2);
+    }
+
+    top ??= left;
+    right ??= left;
+    bottom ??= top;
+
     return new Bound(
       this.x - left,
       this.y - top,