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,