diff --git a/blocksuite/affine/blocks/root/src/edgeless/configs/toolbar/more.ts b/blocksuite/affine/blocks/root/src/edgeless/configs/toolbar/more.ts index 6374cf8f08..d4a3651d1f 100644 --- a/blocksuite/affine/blocks/root/src/edgeless/configs/toolbar/more.ts +++ b/blocksuite/affine/blocks/root/src/edgeless/configs/toolbar/more.ts @@ -22,6 +22,7 @@ import { FrameBlockModel, ImageBlockModel, isExternalEmbedModel, + MindmapElementModel, NoteBlockModel, ParagraphBlockModel, } from '@blocksuite/affine-model'; @@ -401,7 +402,17 @@ function reorderElements( ) { if (!models.length) return; - for (const model of models) { + const normalizedModels = Array.from( + new Map( + models.map(model => { + const reorderTarget = + model.group instanceof MindmapElementModel ? model.group : model; + return [reorderTarget.id, reorderTarget]; + }) + ).values() + ); + + for (const model of normalizedModels) { const index = ctx.gfx.layer.getReorderedIndex(model, type); // block should be updated in transaction diff --git a/blocksuite/affine/blocks/surface/src/renderer/canvas-renderer.ts b/blocksuite/affine/blocks/surface/src/renderer/canvas-renderer.ts index 86f53a7398..04c8426255 100644 --- a/blocksuite/affine/blocks/surface/src/renderer/canvas-renderer.ts +++ b/blocksuite/affine/blocks/surface/src/renderer/canvas-renderer.ts @@ -2,16 +2,24 @@ import { type Color, ColorScheme } from '@blocksuite/affine-model'; import { FeatureFlagService } from '@blocksuite/affine-shared/services'; import { requestConnectedFrame } from '@blocksuite/affine-shared/utils'; import { DisposableGroup } from '@blocksuite/global/disposable'; -import type { IBound } from '@blocksuite/global/gfx'; -import { getBoundWithRotation, intersects } from '@blocksuite/global/gfx'; +import { + Bound, + getBoundWithRotation, + type IBound, + intersects, +} from '@blocksuite/global/gfx'; import type { BlockStdScope } from '@blocksuite/std'; import type { GfxCompatibleInterface, + GfxController, + GfxLocalElementModel, GridManager, LayerManager, SurfaceBlockModel, Viewport, } from '@blocksuite/std/gfx'; +import { GfxControllerIdentifier } from '@blocksuite/std/gfx'; +import { effect } from '@preact/signals-core'; import last from 'lodash-es/last'; import { Subject } from 'rxjs'; @@ -40,11 +48,82 @@ type RendererOptions = { surfaceModel: SurfaceBlockModel; }; +export type CanvasRenderPassMetrics = { + overlayCount: number; + placeholderElementCount: number; + renderByBoundCallCount: number; + renderedElementCount: number; + visibleElementCount: number; +}; + +export type CanvasMemorySnapshot = { + bytes: number; + datasetLayerId: string | null; + height: number; + kind: 'main' | 'stacking'; + width: number; + zIndex: string; +}; + +export type CanvasRendererDebugMetrics = { + canvasLayerCount: number; + canvasMemoryBytes: number; + canvasMemorySnapshots: CanvasMemorySnapshot[]; + canvasMemoryMegabytes: number; + canvasPixelCount: number; + coalescedRefreshCount: number; + dirtyLayerRenderCount: number; + fallbackElementCount: number; + lastRenderDurationMs: number; + lastRenderMetrics: CanvasRenderPassMetrics; + maxRenderDurationMs: number; + pooledStackingCanvasCount: number; + refreshCount: number; + renderCount: number; + stackingCanvasCount: number; + totalLayerCount: number; + totalRenderDurationMs: number; + visibleStackingCanvasCount: number; +}; + +type MutableCanvasRendererDebugMetrics = Omit< + CanvasRendererDebugMetrics, + | 'canvasLayerCount' + | 'canvasMemoryBytes' + | 'canvasMemoryMegabytes' + | 'canvasPixelCount' + | 'canvasMemorySnapshots' + | 'pooledStackingCanvasCount' + | 'stackingCanvasCount' + | 'totalLayerCount' + | 'visibleStackingCanvasCount' +>; + +type RenderPassStats = CanvasRenderPassMetrics; + +type StackingCanvasState = { + bound: Bound | null; + layerId: string | null; +}; + +type RefreshTarget = + | { type: 'all' } + | { type: 'main' } + | { type: 'element'; element: SurfaceElementModel | GfxLocalElementModel } + | { + type: 'elements'; + elements: Array; + }; + +const STACKING_CANVAS_PADDING = 32; + export class CanvasRenderer { private _container!: HTMLElement; private readonly _disposables = new DisposableGroup(); + private readonly _gfx: GfxController; + private readonly _turboEnabled: () => boolean; private readonly _overlays = new Set(); @@ -53,6 +132,37 @@ export class CanvasRenderer { private _stackingCanvas: HTMLCanvasElement[] = []; + private readonly _stackingCanvasPool: HTMLCanvasElement[] = []; + + private readonly _stackingCanvasState = new WeakMap< + HTMLCanvasElement, + StackingCanvasState + >(); + + private readonly _dirtyStackingCanvasIndexes = new Set(); + + private _mainCanvasDirty = true; + + private _needsFullRender = true; + + private _debugMetrics: MutableCanvasRendererDebugMetrics = { + refreshCount: 0, + coalescedRefreshCount: 0, + renderCount: 0, + totalRenderDurationMs: 0, + lastRenderDurationMs: 0, + maxRenderDurationMs: 0, + lastRenderMetrics: { + renderByBoundCallCount: 0, + visibleElementCount: 0, + renderedElementCount: 0, + placeholderElementCount: 0, + overlayCount: 0, + }, + dirtyLayerRenderCount: 0, + fallbackElementCount: 0, + }; + canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; @@ -89,6 +199,7 @@ export class CanvasRenderer { this.layerManager = options.layerManager; this.grid = options.gridManager; this.provider = options.provider ?? {}; + this._gfx = this.std.get(GfxControllerIdentifier); this._turboEnabled = () => { const featureFlagService = options.std.get(FeatureFlagService); @@ -132,15 +243,199 @@ export class CanvasRenderer { }; } + private _applyStackingCanvasLayout( + canvas: HTMLCanvasElement, + bound: Bound | null, + dpr = window.devicePixelRatio + ) { + const state = + this._stackingCanvasState.get(canvas) ?? + ({ + bound: null, + layerId: canvas.dataset.layerId ?? null, + } satisfies StackingCanvasState); + + if (!bound || bound.w <= 0 || bound.h <= 0) { + canvas.style.display = 'none'; + canvas.style.left = '0px'; + canvas.style.top = '0px'; + canvas.style.width = '0px'; + canvas.style.height = '0px'; + canvas.style.transform = ''; + canvas.width = 0; + canvas.height = 0; + state.bound = null; + state.layerId = canvas.dataset.layerId ?? null; + this._stackingCanvasState.set(canvas, state); + return; + } + + const { viewportBounds, zoom, viewScale } = this.viewport; + const width = bound.w * zoom; + const height = bound.h * zoom; + const left = (bound.x - viewportBounds.x) * zoom; + const top = (bound.y - viewportBounds.y) * zoom; + const actualWidth = Math.max(1, Math.ceil(width * dpr)); + const actualHeight = Math.max(1, Math.ceil(height * dpr)); + const transform = `translate(${left}px, ${top}px) scale(${1 / viewScale})`; + + if (canvas.style.display !== 'block') { + canvas.style.display = 'block'; + } + if (canvas.style.left !== '0px') { + canvas.style.left = '0px'; + } + if (canvas.style.top !== '0px') { + canvas.style.top = '0px'; + } + if (canvas.style.width !== `${width}px`) { + canvas.style.width = `${width}px`; + } + if (canvas.style.height !== `${height}px`) { + canvas.style.height = `${height}px`; + } + if (canvas.style.transform !== transform) { + canvas.style.transform = transform; + } + if (canvas.style.transformOrigin !== 'top left') { + canvas.style.transformOrigin = 'top left'; + } + + if (canvas.width !== actualWidth) { + canvas.width = actualWidth; + } + + if (canvas.height !== actualHeight) { + canvas.height = actualHeight; + } + + state.bound = bound; + state.layerId = canvas.dataset.layerId ?? null; + this._stackingCanvasState.set(canvas, state); + } + + private _clampBoundToViewport(bound: Bound, viewportBounds: Bound) { + const minX = Math.max(bound.x, viewportBounds.x); + const minY = Math.max(bound.y, viewportBounds.y); + const maxX = Math.min(bound.maxX, viewportBounds.maxX); + const maxY = Math.min(bound.maxY, viewportBounds.maxY); + + if (maxX <= minX || maxY <= minY) { + return null; + } + + return new Bound(minX, minY, maxX - minX, maxY - minY); + } + + private _createCanvasForLayer( + onCreated?: (canvas: HTMLCanvasElement) => void + ) { + const reused = this._stackingCanvasPool.pop(); + + if (reused) { + return reused; + } + + const created = document.createElement('canvas'); + onCreated?.(created); + return created; + } + + private _findLayerIndexByElement( + element: SurfaceElementModel | GfxLocalElementModel + ) { + const canvasLayers = this.layerManager.getCanvasLayers(); + const index = canvasLayers.findIndex(layer => + layer.elements.some(layerElement => layerElement.id === element.id) + ); + + return index === -1 ? null : index; + } + + private _getLayerRenderBound( + elements: SurfaceElementModel[], + viewportBounds: Bound + ) { + let layerBound: Bound | null = null; + + for (const element of elements) { + const display = (element.display ?? true) && !element.hidden; + + if (!display) { + continue; + } + + const elementBound = Bound.from(getBoundWithRotation(element)); + + if (!intersects(elementBound, viewportBounds)) { + continue; + } + + layerBound = layerBound ? layerBound.unite(elementBound) : elementBound; + } + + if (!layerBound) { + return null; + } + + return this._clampBoundToViewport( + layerBound.expand(STACKING_CANVAS_PADDING), + viewportBounds + ); + } + + private _getResolvedStackingCanvasBound( + canvas: HTMLCanvasElement, + bound: Bound | null + ) { + if (!bound || !this._gfx.tool.dragging$.peek()) { + return bound; + } + + const previousBound = this._stackingCanvasState.get(canvas)?.bound; + + return previousBound ? previousBound.unite(bound) : bound; + } + + private _invalidate(target: RefreshTarget = { type: 'all' }) { + if (target.type === 'all') { + this._needsFullRender = true; + this._mainCanvasDirty = true; + this._dirtyStackingCanvasIndexes.clear(); + return; + } + + if (this._needsFullRender) { + return; + } + + if (target.type === 'main') { + this._mainCanvasDirty = true; + return; + } + + const elements = + target.type === 'element' ? [target.element] : target.elements; + + for (const element of elements) { + const layerIndex = this._findLayerIndexByElement(element); + + if (layerIndex === null || layerIndex >= this._stackingCanvas.length) { + this._mainCanvasDirty = true; + continue; + } + + this._dirtyStackingCanvasIndexes.add(layerIndex); + } + } + + private _resetPooledCanvas(canvas: HTMLCanvasElement) { + canvas.dataset.layerId = ''; + this._applyStackingCanvasLayout(canvas, null); + } + private _initStackingCanvas(onCreated?: (canvas: HTMLCanvasElement) => void) { const layer = this.layerManager; - const updateStackingCanvasSize = (canvases: HTMLCanvasElement[]) => { - this._stackingCanvas = canvases; - - const sizeUpdater = this._canvasSizeUpdater(); - - canvases.filter(sizeUpdater.filter).forEach(sizeUpdater.update); - }; const updateStackingCanvas = () => { /** * we already have a main canvas, so the last layer should be skipped @@ -159,11 +454,7 @@ export class CanvasRenderer { const created = i < currentCanvases.length; const canvas = created ? currentCanvases[i] - : document.createElement('canvas'); - - if (!created) { - onCreated?.(canvas); - } + : this._createCanvasForLayer(onCreated); canvas.dataset.layerId = `[${layer.indexes[0]}--${layer.indexes[1]}]`; canvas.style.zIndex = layer.zIndex.toString(); @@ -171,7 +462,6 @@ export class CanvasRenderer { } this._stackingCanvas = canvases; - updateStackingCanvasSize(canvases); if (currentCanvases.length !== canvases.length) { const diff = canvases.length - currentCanvases.length; @@ -189,12 +479,16 @@ export class CanvasRenderer { payload.added = canvases.slice(-diff); } else { payload.removed = currentCanvases.slice(diff); + payload.removed.forEach(canvas => { + this._resetPooledCanvas(canvas); + this._stackingCanvasPool.push(canvas); + }); } this.stackingCanvasUpdated.next(payload); } - this.refresh(); + this.refresh({ type: 'all' }); }; this._disposables.add( @@ -211,7 +505,7 @@ export class CanvasRenderer { this._disposables.add( this.viewport.viewportUpdated.subscribe(() => { - this.refresh(); + this.refresh({ type: 'all' }); }) ); @@ -222,7 +516,6 @@ export class CanvasRenderer { sizeUpdatedRafId = null; this._resetSize(); this._render(); - this.refresh(); }, this._container); }) ); @@ -233,69 +526,212 @@ export class CanvasRenderer { if (this.usePlaceholder !== shouldRenderPlaceholders) { this.usePlaceholder = shouldRenderPlaceholders; - this.refresh(); + this.refresh({ type: 'all' }); } }) ); + let wasDragging = false; + this._disposables.add( + effect(() => { + const isDragging = this._gfx.tool.dragging$.value; + + if (wasDragging && !isDragging) { + this.refresh({ type: 'all' }); + } + + wasDragging = isDragging; + }) + ); + this.usePlaceholder = false; } + private _createRenderPassStats(): RenderPassStats { + return { + renderByBoundCallCount: 0, + visibleElementCount: 0, + renderedElementCount: 0, + placeholderElementCount: 0, + overlayCount: 0, + }; + } + + private _getCanvasMemorySnapshots(): CanvasMemorySnapshot[] { + return [this.canvas, ...this._stackingCanvas].map((canvas, index) => { + return { + kind: index === 0 ? 'main' : 'stacking', + width: canvas.width, + height: canvas.height, + bytes: canvas.width * canvas.height * 4, + zIndex: canvas.style.zIndex, + datasetLayerId: canvas.dataset.layerId ?? null, + }; + }); + } + private _render() { + const renderStart = performance.now(); const { viewportBounds, zoom } = this.viewport; const { ctx } = this; const dpr = window.devicePixelRatio; const scale = zoom * dpr; const matrix = new DOMMatrix().scaleSelf(scale); + const renderStats = this._createRenderPassStats(); + const fullRender = this._needsFullRender; + const stackingIndexesToRender = fullRender + ? this._stackingCanvas.map((_, idx) => idx) + : [...this._dirtyStackingCanvasIndexes]; /** * if a layer does not have a corresponding canvas * its element will be add to this array and drawing on the * main canvas */ let fallbackElement: SurfaceElementModel[] = []; + const allCanvasLayers = this.layerManager.getCanvasLayers(); + const viewportBound = Bound.from(viewportBounds); - this.layerManager.getCanvasLayers().forEach((layer, idx) => { - if (!this._stackingCanvas[idx]) { - fallbackElement = fallbackElement.concat(layer.elements); - return; + for (const idx of stackingIndexesToRender) { + const layer = allCanvasLayers[idx]; + const canvas = this._stackingCanvas[idx]; + + if (!layer || !canvas) { + continue; } - const canvas = this._stackingCanvas[idx]; - const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; - const rc = new RoughCanvas(ctx.canvas); + const layerRenderBound = this._getLayerRenderBound( + layer.elements, + viewportBound + ); + const resolvedLayerRenderBound = this._getResolvedStackingCanvasBound( + canvas, + layerRenderBound + ); - ctx.clearRect(0, 0, canvas.width, canvas.height); + this._applyStackingCanvasLayout(canvas, resolvedLayerRenderBound); + + if ( + !resolvedLayerRenderBound || + canvas.width === 0 || + canvas.height === 0 + ) { + continue; + } + + const layerCtx = canvas.getContext('2d') as CanvasRenderingContext2D; + const layerRc = new RoughCanvas(layerCtx.canvas); + + layerCtx.clearRect(0, 0, canvas.width, canvas.height); + layerCtx.save(); + layerCtx.setTransform(matrix); + + this._renderByBound( + layerCtx, + matrix, + layerRc, + resolvedLayerRenderBound, + layer.elements, + false, + renderStats + ); + } + + if (fullRender || this._mainCanvasDirty) { + allCanvasLayers.forEach((layer, idx) => { + if (!this._stackingCanvas[idx]) { + fallbackElement = fallbackElement.concat(layer.elements); + } + }); + + ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); ctx.save(); ctx.setTransform(matrix); - this._renderByBound(ctx, matrix, rc, viewportBounds, layer.elements); - }); + this._renderByBound( + ctx, + matrix, + new RoughCanvas(ctx.canvas), + viewportBounds, + fallbackElement, + true, + renderStats + ); + } - ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); - ctx.save(); - - ctx.setTransform(matrix); - - this._renderByBound( - ctx, - matrix, - new RoughCanvas(ctx.canvas), - viewportBounds, - fallbackElement, - true + const canvasMemorySnapshots = this._getCanvasMemorySnapshots(); + const canvasMemoryBytes = canvasMemorySnapshots.reduce( + (sum, snapshot) => sum + snapshot.bytes, + 0 ); + const layerTypes = this.layerManager.layers.map(layer => layer.type); + const renderDurationMs = performance.now() - renderStart; + + this._debugMetrics.renderCount += 1; + this._debugMetrics.totalRenderDurationMs += renderDurationMs; + this._debugMetrics.lastRenderDurationMs = renderDurationMs; + this._debugMetrics.maxRenderDurationMs = Math.max( + this._debugMetrics.maxRenderDurationMs, + renderDurationMs + ); + this._debugMetrics.lastRenderMetrics = renderStats; + this._debugMetrics.fallbackElementCount = fallbackElement.length; + this._debugMetrics.dirtyLayerRenderCount = stackingIndexesToRender.length; + + this._lastDebugSnapshot = { + canvasMemorySnapshots, + canvasMemoryBytes, + canvasPixelCount: canvasMemorySnapshots.reduce( + (sum, snapshot) => sum + snapshot.width * snapshot.height, + 0 + ), + stackingCanvasCount: this._stackingCanvas.length, + canvasLayerCount: layerTypes.filter(type => type === 'canvas').length, + totalLayerCount: layerTypes.length, + pooledStackingCanvasCount: this._stackingCanvasPool.length, + visibleStackingCanvasCount: this._stackingCanvas.filter( + canvas => canvas.width > 0 && canvas.height > 0 + ).length, + }; + + this._needsFullRender = false; + this._mainCanvasDirty = false; + this._dirtyStackingCanvasIndexes.clear(); } + private _lastDebugSnapshot: Pick< + CanvasRendererDebugMetrics, + | 'canvasMemoryBytes' + | 'canvasMemorySnapshots' + | 'canvasPixelCount' + | 'canvasLayerCount' + | 'pooledStackingCanvasCount' + | 'stackingCanvasCount' + | 'totalLayerCount' + | 'visibleStackingCanvasCount' + > = { + canvasMemoryBytes: 0, + canvasMemorySnapshots: [], + canvasPixelCount: 0, + canvasLayerCount: 0, + pooledStackingCanvasCount: 0, + stackingCanvasCount: 0, + totalLayerCount: 0, + visibleStackingCanvasCount: 0, + }; + private _renderByBound( ctx: CanvasRenderingContext2D | null, matrix: DOMMatrix, rc: RoughCanvas, bound: IBound, surfaceElements?: SurfaceElementModel[], - overLay: boolean = false + overLay: boolean = false, + renderStats?: RenderPassStats ) { if (!ctx) return; + renderStats && (renderStats.renderByBoundCallCount += 1); + const elements = surfaceElements ?? (this.grid.search(bound, { @@ -305,10 +741,12 @@ export class CanvasRenderer { for (const element of elements) { const display = (element.display ?? true) && !element.hidden; if (display && intersects(getBoundWithRotation(element), bound)) { + renderStats && (renderStats.visibleElementCount += 1); if ( this.usePlaceholder && !(element as GfxCompatibleInterface).forceFullRender ) { + renderStats && (renderStats.placeholderElementCount += 1); ctx.save(); ctx.fillStyle = 'rgba(200, 200, 200, 0.5)'; const drawX = element.x - bound.x; @@ -316,6 +754,7 @@ export class CanvasRenderer { ctx.fillRect(drawX, drawY, element.w, element.h); ctx.restore(); } else { + renderStats && (renderStats.renderedElementCount += 1); ctx.save(); const renderFn = this.std.getOptional( ElementRendererIdentifier(element.type) @@ -333,6 +772,7 @@ export class CanvasRenderer { } if (overLay) { + renderStats && (renderStats.overlayCount += this._overlays.size); for (const overlay of this._overlays) { ctx.save(); ctx.translate(-bound.x, -bound.y); @@ -348,33 +788,38 @@ export class CanvasRenderer { const sizeUpdater = this._canvasSizeUpdater(); sizeUpdater.update(this.canvas); - - this._stackingCanvas.forEach(sizeUpdater.update); - this.refresh(); + this._invalidate({ type: 'all' }); } private _watchSurface(surfaceModel: SurfaceBlockModel) { this._disposables.add( - surfaceModel.elementAdded.subscribe(() => this.refresh()) + surfaceModel.elementAdded.subscribe(() => this.refresh({ type: 'all' })) ); this._disposables.add( - surfaceModel.elementRemoved.subscribe(() => this.refresh()) + surfaceModel.elementRemoved.subscribe(() => this.refresh({ type: 'all' })) ); this._disposables.add( - surfaceModel.localElementAdded.subscribe(() => this.refresh()) + surfaceModel.localElementAdded.subscribe(() => + this.refresh({ type: 'all' }) + ) ); this._disposables.add( - surfaceModel.localElementDeleted.subscribe(() => this.refresh()) + surfaceModel.localElementDeleted.subscribe(() => + this.refresh({ type: 'all' }) + ) ); this._disposables.add( - surfaceModel.localElementUpdated.subscribe(() => this.refresh()) + surfaceModel.localElementUpdated.subscribe(({ model }) => { + this.refresh({ type: 'element', element: model }); + }) ); this._disposables.add( surfaceModel.elementUpdated.subscribe(payload => { // ignore externalXYWH update cause it's updated by the renderer if (payload.props['externalXYWH']) return; - this.refresh(); + const element = surfaceModel.getElementById(payload.id); + this.refresh(element ? { type: 'element', element } : { type: 'all' }); }) ); } @@ -382,7 +827,7 @@ export class CanvasRenderer { addOverlay(overlay: Overlay) { overlay.setRenderer(this); this._overlays.add(overlay); - this.refresh(); + this.refresh({ type: 'main' }); } /** @@ -394,7 +839,7 @@ export class CanvasRenderer { container.append(this.canvas); this._resetSize(); - this.refresh(); + this.refresh({ type: 'all' }); } dispose(): void { @@ -453,8 +898,46 @@ export class CanvasRenderer { return this.provider.getPropertyValue?.(property) ?? ''; } - refresh() { - if (this._refreshRafId !== null) return; + getDebugMetrics(): CanvasRendererDebugMetrics { + return { + ...this._debugMetrics, + ...this._lastDebugSnapshot, + canvasMemoryMegabytes: + this._lastDebugSnapshot.canvasMemoryBytes / 1024 / 1024, + }; + } + + resetDebugMetrics() { + this._debugMetrics = { + refreshCount: 0, + coalescedRefreshCount: 0, + renderCount: 0, + totalRenderDurationMs: 0, + lastRenderDurationMs: 0, + maxRenderDurationMs: 0, + lastRenderMetrics: this._createRenderPassStats(), + dirtyLayerRenderCount: 0, + fallbackElementCount: 0, + }; + this._lastDebugSnapshot = { + canvasMemoryBytes: 0, + canvasMemorySnapshots: [], + canvasPixelCount: 0, + canvasLayerCount: 0, + pooledStackingCanvasCount: 0, + stackingCanvasCount: 0, + totalLayerCount: 0, + visibleStackingCanvasCount: 0, + }; + } + + refresh(target: RefreshTarget = { type: 'all' }) { + this._debugMetrics.refreshCount += 1; + this._invalidate(target); + if (this._refreshRafId !== null) { + this._debugMetrics.coalescedRefreshCount += 1; + return; + } this._refreshRafId = requestConnectedFrame(() => { this._refreshRafId = null; @@ -469,6 +952,6 @@ export class CanvasRenderer { overlay.setRenderer(null); this._overlays.delete(overlay); - this.refresh(); + this.refresh({ type: 'main' }); } } diff --git a/blocksuite/affine/blocks/surface/src/renderer/dom-renderer.ts b/blocksuite/affine/blocks/surface/src/renderer/dom-renderer.ts index b123a31245..b19b03b5c2 100644 --- a/blocksuite/affine/blocks/surface/src/renderer/dom-renderer.ts +++ b/blocksuite/affine/blocks/surface/src/renderer/dom-renderer.ts @@ -354,30 +354,37 @@ export class DomRenderer { this._disposables.add( surfaceModel.elementAdded.subscribe(payload => { this._markElementDirty(payload.id, UpdateType.ELEMENT_ADDED); + this._markViewportDirty(); this.refresh(); }) ); this._disposables.add( surfaceModel.elementRemoved.subscribe(payload => { this._markElementDirty(payload.id, UpdateType.ELEMENT_REMOVED); + this._markViewportDirty(); this.refresh(); }) ); this._disposables.add( surfaceModel.localElementAdded.subscribe(payload => { this._markElementDirty(payload.id, UpdateType.ELEMENT_ADDED); + this._markViewportDirty(); this.refresh(); }) ); this._disposables.add( surfaceModel.localElementDeleted.subscribe(payload => { this._markElementDirty(payload.id, UpdateType.ELEMENT_REMOVED); + this._markViewportDirty(); this.refresh(); }) ); this._disposables.add( surfaceModel.localElementUpdated.subscribe(payload => { this._markElementDirty(payload.model.id, UpdateType.ELEMENT_UPDATED); + if (payload.props['index'] || payload.props['groupId']) { + this._markViewportDirty(); + } this.refresh(); }) ); @@ -387,6 +394,9 @@ export class DomRenderer { // ignore externalXYWH update cause it's updated by the renderer if (payload.props['externalXYWH']) return; this._markElementDirty(payload.id, UpdateType.ELEMENT_UPDATED); + if (payload.props['index'] || payload.props['childIds']) { + this._markViewportDirty(); + } this.refresh(); }) ); diff --git a/blocksuite/affine/gfx/brush/src/renderer/dom/brush.ts b/blocksuite/affine/gfx/brush/src/renderer/dom/brush.ts index 5f5dea340a..e197bd8b70 100644 --- a/blocksuite/affine/gfx/brush/src/renderer/dom/brush.ts +++ b/blocksuite/affine/gfx/brush/src/renderer/dom/brush.ts @@ -5,6 +5,8 @@ import { import type { BrushElementModel } from '@blocksuite/affine-model'; import { DefaultTheme } from '@blocksuite/affine-model'; +import { renderBrushLikeDom } from './shared'; + export const BrushDomRendererExtension = DomElementRendererExtension( 'brush', ( @@ -12,58 +14,11 @@ export const BrushDomRendererExtension = DomElementRendererExtension( domElement: HTMLElement, renderer: DomRenderer ) => { - const { zoom } = renderer.viewport; - const [, , w, h] = model.deserializedXYWH; - - // Early return if invalid dimensions - if (w <= 0 || h <= 0) { - return; - } - - // Early return if no commands - if (!model.commands) { - return; - } - - // Clear previous content - domElement.innerHTML = ''; - - // Get color value - const color = renderer.getColorValue(model.color, DefaultTheme.black, true); - - // Create SVG element - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.style.position = 'absolute'; - svg.style.left = '0'; - svg.style.top = '0'; - svg.style.width = `${w * zoom}px`; - svg.style.height = `${h * zoom}px`; - svg.style.overflow = 'visible'; - svg.style.pointerEvents = 'none'; - svg.setAttribute('viewBox', `0 0 ${w} ${h}`); - - // Apply rotation transform - if (model.rotate !== 0) { - svg.style.transform = `rotate(${model.rotate}deg)`; - svg.style.transformOrigin = 'center'; - } - - // Create path element for the brush stroke - const pathElement = document.createElementNS( - 'http://www.w3.org/2000/svg', - 'path' - ); - pathElement.setAttribute('d', model.commands); - pathElement.setAttribute('fill', color); - pathElement.setAttribute('stroke', 'none'); - - svg.append(pathElement); - domElement.replaceChildren(svg); - - // Set element size and position - domElement.style.width = `${w * zoom}px`; - domElement.style.height = `${h * zoom}px`; - domElement.style.overflow = 'visible'; - domElement.style.pointerEvents = 'none'; + renderBrushLikeDom({ + model, + domElement, + renderer, + color: renderer.getColorValue(model.color, DefaultTheme.black, true), + }); } ); diff --git a/blocksuite/affine/gfx/brush/src/renderer/dom/highlighter.ts b/blocksuite/affine/gfx/brush/src/renderer/dom/highlighter.ts index e15f2a300e..5c410e9f72 100644 --- a/blocksuite/affine/gfx/brush/src/renderer/dom/highlighter.ts +++ b/blocksuite/affine/gfx/brush/src/renderer/dom/highlighter.ts @@ -5,6 +5,8 @@ import { import type { HighlighterElementModel } from '@blocksuite/affine-model'; import { DefaultTheme } from '@blocksuite/affine-model'; +import { renderBrushLikeDom } from './shared'; + export const HighlighterDomRendererExtension = DomElementRendererExtension( 'highlighter', ( @@ -12,62 +14,15 @@ export const HighlighterDomRendererExtension = DomElementRendererExtension( domElement: HTMLElement, renderer: DomRenderer ) => { - const { zoom } = renderer.viewport; - const [, , w, h] = model.deserializedXYWH; - - // Early return if invalid dimensions - if (w <= 0 || h <= 0) { - return; - } - - // Early return if no commands - if (!model.commands) { - return; - } - - // Clear previous content - domElement.innerHTML = ''; - - // Get color value - const color = renderer.getColorValue( - model.color, - DefaultTheme.hightlighterColor, - true - ); - - // Create SVG element - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.style.position = 'absolute'; - svg.style.left = '0'; - svg.style.top = '0'; - svg.style.width = `${w * zoom}px`; - svg.style.height = `${h * zoom}px`; - svg.style.overflow = 'visible'; - svg.style.pointerEvents = 'none'; - svg.setAttribute('viewBox', `0 0 ${w} ${h}`); - - // Apply rotation transform - if (model.rotate !== 0) { - svg.style.transform = `rotate(${model.rotate}deg)`; - svg.style.transformOrigin = 'center'; - } - - // Create path element for the highlighter stroke - const pathElement = document.createElementNS( - 'http://www.w3.org/2000/svg', - 'path' - ); - pathElement.setAttribute('d', model.commands); - pathElement.setAttribute('fill', color); - pathElement.setAttribute('stroke', 'none'); - - svg.append(pathElement); - domElement.replaceChildren(svg); - - // Set element size and position - domElement.style.width = `${w * zoom}px`; - domElement.style.height = `${h * zoom}px`; - domElement.style.overflow = 'visible'; - domElement.style.pointerEvents = 'none'; + renderBrushLikeDom({ + model, + domElement, + renderer, + color: renderer.getColorValue( + model.color, + DefaultTheme.hightlighterColor, + true + ), + }); } ); diff --git a/blocksuite/affine/gfx/brush/src/renderer/dom/shared.ts b/blocksuite/affine/gfx/brush/src/renderer/dom/shared.ts new file mode 100644 index 0000000000..04198b003a --- /dev/null +++ b/blocksuite/affine/gfx/brush/src/renderer/dom/shared.ts @@ -0,0 +1,82 @@ +import type { DomRenderer } from '@blocksuite/affine-block-surface'; +import type { + BrushElementModel, + HighlighterElementModel, +} from '@blocksuite/affine-model'; + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +type BrushLikeModel = BrushElementModel | HighlighterElementModel; + +type RetainedBrushDom = { + path: SVGPathElement; + svg: SVGSVGElement; +}; + +const retainedBrushDom = new WeakMap(); + +function clearBrushLikeDom(domElement: HTMLElement) { + retainedBrushDom.delete(domElement); + domElement.replaceChildren(); +} + +function getRetainedBrushDom(domElement: HTMLElement) { + const existing = retainedBrushDom.get(domElement); + + if (existing) { + return existing; + } + + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.style.position = 'absolute'; + svg.style.left = '0'; + svg.style.top = '0'; + svg.style.overflow = 'visible'; + svg.style.pointerEvents = 'none'; + + const path = document.createElementNS(SVG_NS, 'path'); + path.setAttribute('stroke', 'none'); + svg.append(path); + + const retained = { svg, path }; + retainedBrushDom.set(domElement, retained); + domElement.replaceChildren(svg); + + return retained; +} + +export function renderBrushLikeDom({ + color, + domElement, + model, + renderer, +}: { + color: string; + domElement: HTMLElement; + model: BrushLikeModel; + renderer: DomRenderer; +}) { + const { zoom } = renderer.viewport; + const [, , w, h] = model.deserializedXYWH; + + if (w <= 0 || h <= 0 || !model.commands) { + clearBrushLikeDom(domElement); + return; + } + + const { path, svg } = getRetainedBrushDom(domElement); + + svg.style.width = `${w * zoom}px`; + svg.style.height = `${h * zoom}px`; + svg.style.transform = model.rotate === 0 ? '' : `rotate(${model.rotate}deg)`; + svg.style.transformOrigin = model.rotate === 0 ? '' : 'center'; + svg.setAttribute('viewBox', `0 0 ${w} ${h}`); + + path.setAttribute('d', model.commands); + path.setAttribute('fill', color); + + domElement.style.width = `${w * zoom}px`; + domElement.style.height = `${h * zoom}px`; + domElement.style.overflow = 'visible'; + domElement.style.pointerEvents = 'none'; +} diff --git a/blocksuite/affine/gfx/connector/src/renderer/dom-renderer.ts b/blocksuite/affine/gfx/connector/src/renderer/dom-renderer.ts index 46a6a07112..3f9d992344 100644 --- a/blocksuite/affine/gfx/connector/src/renderer/dom-renderer.ts +++ b/blocksuite/affine/gfx/connector/src/renderer/dom-renderer.ts @@ -14,6 +14,8 @@ import { PointLocation, SVGPathBuilder } from '@blocksuite/global/gfx'; import { isConnectorWithLabel } from '../connector-manager'; import { DEFAULT_ARROW_SIZE } from './utils'; +const SVG_NS = 'http://www.w3.org/2000/svg'; + interface PathBounds { minX: number; minY: number; @@ -21,6 +23,15 @@ interface PathBounds { maxY: number; } +type RetainedConnectorDom = { + defs: SVGDefsElement; + label: HTMLDivElement | null; + path: SVGPathElement; + svg: SVGSVGElement; +}; + +const retainedConnectorDom = new WeakMap(); + function calculatePathBounds(path: PointLocation[]): PathBounds { if (path.length === 0) { return { minX: 0, minY: 0, maxX: 0, maxY: 0 }; @@ -81,10 +92,7 @@ function createArrowMarker( strokeWidth: number, isStart: boolean = false ): SVGMarkerElement { - const marker = document.createElementNS( - 'http://www.w3.org/2000/svg', - 'marker' - ); + const marker = document.createElementNS(SVG_NS, 'marker'); const size = DEFAULT_ARROW_SIZE * (strokeWidth / 2); marker.id = id; @@ -98,10 +106,7 @@ function createArrowMarker( switch (style) { case 'Arrow': { - const path = document.createElementNS( - 'http://www.w3.org/2000/svg', - 'path' - ); + const path = document.createElementNS(SVG_NS, 'path'); path.setAttribute( 'd', isStart ? 'M 20 5 L 10 10 L 20 15 Z' : 'M 0 5 L 10 10 L 0 15 Z' @@ -112,10 +117,7 @@ function createArrowMarker( break; } case 'Triangle': { - const path = document.createElementNS( - 'http://www.w3.org/2000/svg', - 'path' - ); + const path = document.createElementNS(SVG_NS, 'path'); path.setAttribute( 'd', isStart ? 'M 20 7 L 12 10 L 20 13 Z' : 'M 0 7 L 8 10 L 0 13 Z' @@ -126,10 +128,7 @@ function createArrowMarker( break; } case 'Circle': { - const circle = document.createElementNS( - 'http://www.w3.org/2000/svg', - 'circle' - ); + const circle = document.createElementNS(SVG_NS, 'circle'); circle.setAttribute('cx', '10'); circle.setAttribute('cy', '10'); circle.setAttribute('r', '4'); @@ -139,10 +138,7 @@ function createArrowMarker( break; } case 'Diamond': { - const path = document.createElementNS( - 'http://www.w3.org/2000/svg', - 'path' - ); + const path = document.createElementNS(SVG_NS, 'path'); path.setAttribute('d', 'M 10 6 L 14 10 L 10 14 L 6 10 Z'); path.setAttribute('fill', color); path.setAttribute('stroke', color); @@ -154,13 +150,64 @@ function createArrowMarker( return marker; } +function clearRetainedConnectorDom(element: HTMLElement) { + retainedConnectorDom.delete(element); + element.replaceChildren(); +} + +function getRetainedConnectorDom(element: HTMLElement): RetainedConnectorDom { + const existing = retainedConnectorDom.get(element); + + if (existing) { + return existing; + } + + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.style.position = 'absolute'; + svg.style.overflow = 'visible'; + svg.style.pointerEvents = 'none'; + + const defs = document.createElementNS(SVG_NS, 'defs'); + const path = document.createElementNS(SVG_NS, 'path'); + path.setAttribute('fill', 'none'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + + svg.append(defs, path); + element.replaceChildren(svg); + + const retained = { + svg, + defs, + path, + label: null, + }; + retainedConnectorDom.set(element, retained); + + return retained; +} + +function getOrCreateLabelElement(retained: RetainedConnectorDom) { + if (retained.label) { + return retained.label; + } + + const label = document.createElement('div'); + retained.svg.insertAdjacentElement('afterend', label); + retained.label = label; + + return label; +} + function renderConnectorLabel( model: ConnectorElementModel, - container: HTMLElement, + retained: RetainedConnectorDom, renderer: DomRenderer, zoom: number ) { if (!isConnectorWithLabel(model) || !model.labelXYWH) { + retained.label?.remove(); + retained.label = null; return; } @@ -176,8 +223,7 @@ function renderConnectorLabel( }, } = model; - // Create label element - const labelElement = document.createElement('div'); + const labelElement = getOrCreateLabelElement(retained); labelElement.style.position = 'absolute'; labelElement.style.left = `${lx * zoom}px`; labelElement.style.top = `${ly * zoom}px`; @@ -210,11 +256,7 @@ function renderConnectorLabel( labelElement.style.wordWrap = 'break-word'; // Add text content - if (model.text) { - labelElement.textContent = model.text.toString(); - } - - container.append(labelElement); + labelElement.textContent = model.text ? model.text.toString() : ''; } /** @@ -241,14 +283,13 @@ export const connectorBaseDomRenderer = ( stroke, } = model; - // Clear previous content - element.innerHTML = ''; - - // Early return if no path points if (!points || points.length < 2) { + clearRetainedConnectorDom(element); return; } + const retained = getRetainedConnectorDom(element); + // Calculate bounds for the SVG viewBox const pathBounds = calculatePathBounds(points); const padding = Math.max(strokeWidth * 2, 20); // Add padding for arrows @@ -257,8 +298,7 @@ export const connectorBaseDomRenderer = ( const offsetX = pathBounds.minX - padding; const offsetY = pathBounds.minY - padding; - // Create SVG element - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const { defs, path, svg } = retained; svg.style.position = 'absolute'; svg.style.left = `${offsetX * zoom}px`; svg.style.top = `${offsetY * zoom}px`; @@ -268,49 +308,43 @@ export const connectorBaseDomRenderer = ( svg.style.pointerEvents = 'none'; svg.setAttribute('viewBox', `0 0 ${svgWidth / zoom} ${svgHeight / zoom}`); - // Create defs for markers - const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); - svg.append(defs); - const strokeColor = renderer.getColorValue( stroke, DefaultTheme.connectorColor, true ); - // Create markers for endpoints + const markers: SVGMarkerElement[] = []; let startMarkerId = ''; let endMarkerId = ''; if (frontEndpointStyle !== 'None') { startMarkerId = `start-marker-${model.id}`; - const startMarker = createArrowMarker( - startMarkerId, - frontEndpointStyle, - strokeColor, - strokeWidth, - true + markers.push( + createArrowMarker( + startMarkerId, + frontEndpointStyle, + strokeColor, + strokeWidth, + true + ) ); - defs.append(startMarker); } if (rearEndpointStyle !== 'None') { endMarkerId = `end-marker-${model.id}`; - const endMarker = createArrowMarker( - endMarkerId, - rearEndpointStyle, - strokeColor, - strokeWidth, - false + markers.push( + createArrowMarker( + endMarkerId, + rearEndpointStyle, + strokeColor, + strokeWidth, + false + ) ); - defs.append(endMarker); } - // Create path element - const pathElement = document.createElementNS( - 'http://www.w3.org/2000/svg', - 'path' - ); + defs.replaceChildren(...markers); // Adjust points relative to the SVG coordinate system const adjustedPoints = points.map(point => { @@ -334,29 +368,25 @@ export const connectorBaseDomRenderer = ( }); const pathData = createConnectorPath(adjustedPoints, mode); - pathElement.setAttribute('d', pathData); - pathElement.setAttribute('stroke', strokeColor); - pathElement.setAttribute('stroke-width', String(strokeWidth)); - pathElement.setAttribute('fill', 'none'); - pathElement.setAttribute('stroke-linecap', 'round'); - pathElement.setAttribute('stroke-linejoin', 'round'); - - // Apply stroke style + path.setAttribute('d', pathData); + path.setAttribute('stroke', strokeColor); + path.setAttribute('stroke-width', String(strokeWidth)); if (strokeStyle === 'dash') { - pathElement.setAttribute('stroke-dasharray', '12,12'); + path.setAttribute('stroke-dasharray', '12,12'); + } else { + path.removeAttribute('stroke-dasharray'); } - - // Apply markers if (startMarkerId) { - pathElement.setAttribute('marker-start', `url(#${startMarkerId})`); + path.setAttribute('marker-start', `url(#${startMarkerId})`); + } else { + path.removeAttribute('marker-start'); } if (endMarkerId) { - pathElement.setAttribute('marker-end', `url(#${endMarkerId})`); + path.setAttribute('marker-end', `url(#${endMarkerId})`); + } else { + path.removeAttribute('marker-end'); } - svg.append(pathElement); - element.append(svg); - // Set element size and position element.style.width = `${model.w * zoom}px`; element.style.height = `${model.h * zoom}px`; @@ -370,7 +400,11 @@ export const connectorDomRenderer = ( renderer: DomRenderer ): void => { connectorBaseDomRenderer(model, element, renderer); - renderConnectorLabel(model, element, renderer, renderer.viewport.zoom); + + const retained = retainedConnectorDom.get(element); + if (!retained) return; + + renderConnectorLabel(model, retained, renderer, renderer.viewport.zoom); }; /** diff --git a/blocksuite/affine/gfx/shape/src/element-renderer/shape-dom/index.ts b/blocksuite/affine/gfx/shape/src/element-renderer/shape-dom/index.ts index 749ad67660..ff4eaa713f 100644 --- a/blocksuite/affine/gfx/shape/src/element-renderer/shape-dom/index.ts +++ b/blocksuite/affine/gfx/shape/src/element-renderer/shape-dom/index.ts @@ -6,6 +6,37 @@ import { SVGShapeBuilder } from '@blocksuite/global/gfx'; import { manageClassNames, setStyles } from './utils'; +const SVG_NS = 'http://www.w3.org/2000/svg'; + +type RetainedShapeDom = { + polygon: SVGPolygonElement | null; + svg: SVGSVGElement | null; + text: HTMLDivElement | null; +}; + +type RetainedShapeSvg = { + polygon: SVGPolygonElement; + svg: SVGSVGElement; +}; + +const retainedShapeDom = new WeakMap(); + +function getRetainedShapeDom(element: HTMLElement): RetainedShapeDom { + const existing = retainedShapeDom.get(element); + + if (existing) { + return existing; + } + + const retained = { + svg: null, + polygon: null, + text: null, + }; + retainedShapeDom.set(element, retained); + return retained; +} + function applyShapeSpecificStyles( model: ShapeElementModel, element: HTMLElement, @@ -14,10 +45,6 @@ function applyShapeSpecificStyles( // Reset properties that might be set by different shape types element.style.removeProperty('clip-path'); element.style.removeProperty('border-radius'); - // Clear DOM for shapes that don't use SVG, or if type changes from SVG-based to non-SVG-based - if (model.shapeType !== 'diamond' && model.shapeType !== 'triangle') { - while (element.firstChild) element.firstChild.remove(); - } switch (model.shapeType) { case 'rect': { @@ -42,6 +69,54 @@ function applyShapeSpecificStyles( // No 'else' needed to clear styles, as they are reset at the beginning of the function. } +function getOrCreateSvg( + retained: RetainedShapeDom, + element: HTMLElement +): RetainedShapeSvg { + if (retained.svg && retained.polygon) { + return { + svg: retained.svg, + polygon: retained.polygon, + }; + } + + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('width', '100%'); + svg.setAttribute('height', '100%'); + svg.setAttribute('preserveAspectRatio', 'none'); + + const polygon = document.createElementNS(SVG_NS, 'polygon'); + svg.append(polygon); + + retained.svg = svg; + retained.polygon = polygon; + element.prepend(svg); + + return { svg, polygon }; +} + +function removeSvg(retained: RetainedShapeDom) { + retained.svg?.remove(); + retained.svg = null; + retained.polygon = null; +} + +function getOrCreateText(retained: RetainedShapeDom, element: HTMLElement) { + if (retained.text) { + return retained.text; + } + + const text = document.createElement('div'); + retained.text = text; + element.append(text); + return text; +} + +function removeText(retained: RetainedShapeDom) { + retained.text?.remove(); + retained.text = null; +} + function applyBorderStyles( model: ShapeElementModel, element: HTMLElement, @@ -99,8 +174,7 @@ export const shapeDomRenderer = ( const { zoom } = renderer.viewport; const unscaledWidth = model.w; const unscaledHeight = model.h; - - const newChildren: Element[] = []; + const retained = getRetainedShapeDom(element); const fillColor = renderer.getColorValue( model.fillColor, @@ -124,6 +198,7 @@ export const shapeDomRenderer = ( // For diamond and triangle, fill and border are handled by inline SVG element.style.border = 'none'; // Ensure no standard CSS border interferes element.style.backgroundColor = 'transparent'; // Host element is transparent + const { polygon, svg } = getOrCreateSvg(retained, element); const strokeW = model.strokeWidth; @@ -155,37 +230,30 @@ export const shapeDomRenderer = ( // Determine fill color const finalFillColor = model.filled ? fillColor : 'transparent'; - // Build SVG safely with DOM-API - const SVG_NS = 'http://www.w3.org/2000/svg'; - const svg = document.createElementNS(SVG_NS, 'svg'); - svg.setAttribute('width', '100%'); - svg.setAttribute('height', '100%'); svg.setAttribute('viewBox', `0 0 ${unscaledWidth} ${unscaledHeight}`); - svg.setAttribute('preserveAspectRatio', 'none'); - - const polygon = document.createElementNS(SVG_NS, 'polygon'); polygon.setAttribute('points', svgPoints); polygon.setAttribute('fill', finalFillColor); polygon.setAttribute('stroke', finalStrokeColor); polygon.setAttribute('stroke-width', String(strokeW)); if (finalStrokeDasharray !== 'none') { polygon.setAttribute('stroke-dasharray', finalStrokeDasharray); + } else { + polygon.removeAttribute('stroke-dasharray'); } - svg.append(polygon); - - newChildren.push(svg); } else { // Standard rendering for other shapes (e.g., rect, ellipse) - // innerHTML was already cleared by applyShapeSpecificStyles if necessary + removeSvg(retained); element.style.backgroundColor = model.filled ? fillColor : 'transparent'; applyBorderStyles(model, element, strokeColor, zoom); // Uses standard CSS border } if (model.textDisplay && model.text) { const str = model.text.toString(); - const textElement = document.createElement('div'); + const textElement = getOrCreateText(retained, element); if (isRTL(str)) { textElement.dir = 'rtl'; + } else { + textElement.removeAttribute('dir'); } textElement.style.position = 'absolute'; textElement.style.inset = '0'; @@ -210,12 +278,10 @@ export const shapeDomRenderer = ( true ); textElement.textContent = str; - newChildren.push(textElement); + } else { + removeText(retained); } - // Replace existing children to avoid memory leaks - element.replaceChildren(...newChildren); - applyTransformStyles(model, element); manageClassNames(model, element); diff --git a/blocksuite/affine/model/src/elements/connector/connector.ts b/blocksuite/affine/model/src/elements/connector/connector.ts index a242f5a350..86917dcac3 100644 --- a/blocksuite/affine/model/src/elements/connector/connector.ts +++ b/blocksuite/affine/model/src/elements/connector/connector.ts @@ -177,6 +177,11 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel(p => [p[0], p[1]]); const point = Polyline.pointAt(points, offsetDistance); @@ -300,6 +313,10 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel { + test('should handle empty path', () => { + expect(() => getBezierParameters([])).not.toThrow(); + expect(getBezierParameters([])).toEqual([ + new PointLocation(), + new PointLocation(), + new PointLocation(), + new PointLocation(), + ]); + }); + + test('should handle single-point path', () => { + const point = new PointLocation([10, 20]); + + expect(getBezierParameters([point])).toEqual([point, point, point, point]); + }); +}); diff --git a/blocksuite/framework/global/src/gfx/curve.ts b/blocksuite/framework/global/src/gfx/curve.ts index ac08d00fbd..4eebbb86c9 100644 --- a/blocksuite/framework/global/src/gfx/curve.ts +++ b/blocksuite/framework/global/src/gfx/curve.ts @@ -142,6 +142,11 @@ export function getBezierNearestPoint( export function getBezierParameters( points: PointLocation[] ): BezierCurveParameters { + if (points.length === 0) { + const point = new PointLocation(); + return [point, point, point, point]; + } + // Fallback for degenerate Bezier curve (all points are at the same position) if (points.length === 1) { const point = points[0]; diff --git a/blocksuite/framework/std/src/gfx/layer.ts b/blocksuite/framework/std/src/gfx/layer.ts index 4ee2f11e99..12a9522928 100644 --- a/blocksuite/framework/std/src/gfx/layer.ts +++ b/blocksuite/framework/std/src/gfx/layer.ts @@ -596,7 +596,7 @@ export class LayerManager extends GfxExtension { private _updateLayer( element: GfxModel | GfxLocalElementModel, props?: Record, - oldValues?: Record + _oldValues?: Record ) { const modelType = this._getModelType(element); const isLocalElem = element instanceof GfxLocalElementModel; @@ -613,16 +613,7 @@ export class LayerManager extends GfxExtension { }; if (shouldUpdateGroupChildren) { - const group = element as GfxModel & GfxGroupCompatibleInterface; - const oldChildIds = childIdsChanged - ? Array.isArray(oldValues?.['childIds']) - ? (oldValues['childIds'] as string[]) - : this._groupChildSnapshot.get(group.id) - : undefined; - - const relatedElements = this._getRelatedGroupElements(group, oldChildIds); - this._refreshElementsInLayer(relatedElements); - this._syncGroupChildSnapshot(group); + this._reset(); return true; } diff --git a/blocksuite/framework/std/src/view/element/gfx-block-component.ts b/blocksuite/framework/std/src/view/element/gfx-block-component.ts index d957c41101..9231eb754f 100644 --- a/blocksuite/framework/std/src/view/element/gfx-block-component.ts +++ b/blocksuite/framework/std/src/view/element/gfx-block-component.ts @@ -31,6 +31,13 @@ function updateTransform(element: GfxBlockComponent) { element.style.transform = element.getCSSTransform(); } +function updateZIndex(element: GfxBlockComponent) { + const zIndex = element.toZIndex(); + if (element.style.zIndex !== zIndex) { + element.style.zIndex = zIndex; + } +} + function updateBlockVisibility(view: GfxBlockComponent) { if (view.transformState$.value === 'active') { view.style.visibility = 'visible'; @@ -58,14 +65,22 @@ function handleGfxConnection(instance: GfxBlockComponent) { instance.store.slots.blockUpdated.subscribe(({ type, id }) => { if (id === instance.model.id && type === 'update') { updateTransform(instance); + updateZIndex(instance); } }) ); + instance.disposables.add( + instance.gfx.layer.slots.layerUpdated.subscribe(() => { + updateZIndex(instance); + }) + ); + instance.disposables.add( effect(() => { updateBlockVisibility(instance); updateTransform(instance); + updateZIndex(instance); }) ); } diff --git a/blocksuite/integration-test/src/__tests__/edgeless/layer.spec.ts b/blocksuite/integration-test/src/__tests__/edgeless/layer.spec.ts index 1cbb9ec907..cfb1835966 100644 --- a/blocksuite/integration-test/src/__tests__/edgeless/layer.spec.ts +++ b/blocksuite/integration-test/src/__tests__/edgeless/layer.spec.ts @@ -6,6 +6,7 @@ import type { import { ungroupCommand } from '@blocksuite/affine/gfx/group'; import type { GroupElementModel, + MindmapElementModel, NoteBlockModel, } from '@blocksuite/affine/model'; import { generateKeyBetween } from '@blocksuite/affine/std/gfx'; @@ -253,6 +254,40 @@ test('blocks should rerender when their z-index changed', async () => { assertBlocksContent(); }); +test('block host z-index should update after reordering', async () => { + const backId = addNote(doc); + const frontId = addNote(doc); + + await wait(); + + const getBlockHost = (id: string) => + document.querySelector( + `affine-edgeless-root gfx-viewport > [data-block-id="${id}"]` + ); + + const backHost = getBlockHost(backId); + const frontHost = getBlockHost(frontId); + + expect(backHost).not.toBeNull(); + expect(frontHost).not.toBeNull(); + expect(Number(backHost!.style.zIndex)).toBeLessThan( + Number(frontHost!.style.zIndex) + ); + + service.crud.updateElement(backId, { + index: service.layer.getReorderedIndex( + service.crud.getElementById(backId)!, + 'front' + ), + }); + + await wait(); + + expect(Number(backHost!.style.zIndex)).toBeGreaterThan( + Number(frontHost!.style.zIndex) + ); +}); + describe('layer reorder functionality', () => { let ids: string[] = []; @@ -428,14 +463,17 @@ describe('group related functionality', () => { const elements = [ service.crud.addElement('shape', { shapeType: 'rect', + xywh: '[0,0,100,100]', })!, addNote(doc), service.crud.addElement('shape', { shapeType: 'rect', + xywh: '[120,0,100,100]', })!, addNote(doc), service.crud.addElement('shape', { shapeType: 'rect', + xywh: '[240,0,100,100]', })!, ]; @@ -528,6 +566,35 @@ describe('group related functionality', () => { expect(service.layer.layers[1].elements[0]).toBe(group); }); + test("change mindmap index should update its nodes' layer", async () => { + const noteId = addNote(doc); + const mindmapId = service.crud.addElement('mindmap', { + children: { + text: 'root', + children: [{ text: 'child' }], + }, + })!; + + await wait(); + + const note = service.crud.getElementById(noteId)!; + const mindmap = service.crud.getElementById( + mindmapId + )! as MindmapElementModel; + const root = mindmap.tree.element; + + expect(service.layer.getZIndex(root)).toBeGreaterThan( + service.layer.getZIndex(note) + ); + + mindmap.index = service.layer.getReorderedIndex(mindmap, 'back'); + await wait(); + + expect(service.layer.getZIndex(root)).toBeLessThan( + service.layer.getZIndex(note) + ); + }); + test('should keep relative index order of elements after group, ungroup, undo, redo', () => { const edgeless = getDocRootBlock(doc, editor, 'edgeless'); const elementIds = [ @@ -769,6 +836,7 @@ test('indexed canvas should be inserted into edgeless portal when switch to edge service.crud.addElement('shape', { shapeType: 'rect', + xywh: '[0,0,100,100]', })!; addNote(doc); @@ -777,6 +845,7 @@ test('indexed canvas should be inserted into edgeless portal when switch to edge service.crud.addElement('shape', { shapeType: 'rect', + xywh: '[120,0,100,100]', })!; editor.mode = 'page'; @@ -792,10 +861,10 @@ test('indexed canvas should be inserted into edgeless portal when switch to edge '.indexable-canvas' )[0] as HTMLCanvasElement; - expect(indexedCanvas.width).toBe( + expect(indexedCanvas.width).toBeLessThanOrEqual( (surface.renderer as CanvasRenderer).canvas.width ); - expect(indexedCanvas.height).toBe( + expect(indexedCanvas.height).toBeLessThanOrEqual( (surface.renderer as CanvasRenderer).canvas.height ); expect(indexedCanvas.width).not.toBe(0); diff --git a/packages/backend/server/src/core/telemetry/ga4-client.ts b/packages/backend/server/src/core/telemetry/ga4-client.ts index b9e2a18a15..7543b29a8b 100644 --- a/packages/backend/server/src/core/telemetry/ga4-client.ts +++ b/packages/backend/server/src/core/telemetry/ga4-client.ts @@ -1,3 +1,5 @@ +import { Logger } from '@nestjs/common'; + import { CleanedTelemetryEvent, Scalar } from './cleaner'; const GA4_ENDPOINT = 'https://www.google-analytics.com/mp/collect'; @@ -14,6 +16,7 @@ type Ga4Payload = { }; export class Ga4Client { + private readonly logger = new Logger(Ga4Client.name); constructor( private readonly measurementId: string, private readonly apiSecret: string, @@ -45,10 +48,13 @@ export class Ga4Client { try { await this.post(payload); } catch { - if (env.DEPLOYMENT_TYPE === 'affine') { + if ( + env.DEPLOYMENT_TYPE === 'affine' && + env.NODE_ENV === 'production' + ) { // In production, we want to be resilient to GA4 failures, so we catch and ignore errors. // In non-production environments, we rethrow to surface issues during development and testing. - console.info( + this.logger.log( 'Failed to send telemetry event to GA4:', chunk.map(e => e.eventName).join(', ') ); diff --git a/tests/affine-local/e2e/blocksuite/edgeless/canvas-renderer.perf.spec.ts b/tests/affine-local/e2e/blocksuite/edgeless/canvas-renderer.perf.spec.ts new file mode 100644 index 0000000000..a26403efd7 --- /dev/null +++ b/tests/affine-local/e2e/blocksuite/edgeless/canvas-renderer.perf.spec.ts @@ -0,0 +1,197 @@ +import { test } from '@affine-test/kit/playwright'; +import { + type CanvasRendererPerfSnapshot, + deleteEdgelessElements, + getCanvasRendererPerfSnapshot, + resetCanvasRendererPerfMetrics, + seedEdgelessPerfScene, +} from '@affine-test/kit/utils/edgeless-perf'; +import { + clickEdgelessModeButton, + dragView, + fitViewportToContent, + getEdgelessSelectedIds, + getSelectedXYWH, + locateEditorContainer, + setEdgelessTool, + setViewportZoom, +} from '@affine-test/kit/utils/editor'; +import { openHomePage } from '@affine-test/kit/utils/load-page'; +import { + clickNewPageButton, + waitForEditorLoad, +} from '@affine-test/kit/utils/page-logic'; +import { expect } from '@playwright/test'; + +const PERF_ENV = 'AFFINE_RUN_PERF_E2E'; +const perfEnabled = process.env[PERF_ENV] === '1'; +const modKey = process.platform === 'darwin' ? 'Meta' : 'Control'; + +type PerfScenarioResult = { + name: string; + snapshot: CanvasRendererPerfSnapshot; +}; + +test.describe.serial('canvas renderer perf probes', () => { + test.skip(!perfEnabled, `Set ${PERF_ENV}=1 to run manual perf probes`); + + test.beforeEach(async ({ page }) => { + await openHomePage(page); + await waitForEditorLoad(page); + await clickNewPageButton(page); + await clickEdgelessModeButton(page); + await locateEditorContainer(page).click(); + }); + + test('collect metrics for common edgeless canvas scenarios', async ({ + page, + }, testInfo) => { + test.slow(); + + const results: PerfScenarioResult[] = []; + let addedShapeIds: string[] = []; + + const selectWholePerfScene = async () => { + await setEdgelessTool(page, 'default'); + await dragView(page, [80, 140], [2300, 1500]); + await expect + .poll(async () => (await getEdgelessSelectedIds(page)).length) + .toBeGreaterThan(0); + }; + + const recordScenario = async ( + name: string, + action: () => Promise + ) => { + await resetCanvasRendererPerfMetrics(page); + await action(); + await page.waitForTimeout(400); + + const snapshot = await getCanvasRendererPerfSnapshot(page); + results.push({ name, snapshot }); + console.log( + `[canvas-perf] ${name}: ${JSON.stringify(snapshot.metrics, null, 2)}` + ); + }; + + const initial = await seedEdgelessPerfScene(page, { + shapeCount: 120, + rowLength: 12, + startX: 120, + startY: 180, + width: 160, + height: 120, + }); + addedShapeIds = initial.shapeIds; + + await fitViewportToContent(page); + await page.waitForTimeout(500); + + await recordScenario('add-shapes', async () => { + const seeded = await seedEdgelessPerfScene(page, { + shapeCount: 40, + rowLength: 10, + startX: 160, + startY: 1720, + width: 160, + height: 120, + }); + addedShapeIds = addedShapeIds.concat(seeded.shapeIds); + await fitViewportToContent(page); + }); + + await recordScenario('delete-shapes', async () => { + await deleteEdgelessElements(page, addedShapeIds.slice(-20)); + }); + + await recordScenario('box-select', async () => { + await selectWholePerfScene(); + }); + + await recordScenario('group-selection', async () => { + await selectWholePerfScene(); + await page.keyboard.press(`${modKey}+g`); + }); + + await recordScenario('ungroup-selection', async () => { + await page.keyboard.press(`${modKey}+Shift+g`); + }); + + await recordScenario('large-drag-selection', async () => { + await selectWholePerfScene(); + const [x, y, w, h] = await getSelectedXYWH(page); + const center: [number, number] = [x + w / 2, y + h / 2]; + await dragView(page, center, [center[0] + 1200, center[1] + 900]); + }); + + await recordScenario('large-pan', async () => { + await setEdgelessTool(page, 'pan'); + await dragView(page, [1200, 900], [200, 180]); + }); + + await recordScenario('large-zoom', async () => { + await setViewportZoom(page, 0.25); + await page.waitForTimeout(200); + await setViewportZoom(page, 2.2); + await page.waitForTimeout(200); + await fitViewportToContent(page); + }); + + const finalSnapshot = await getCanvasRendererPerfSnapshot(page); + + expect(finalSnapshot.rendererType).toBe('CanvasRenderer'); + expect(results.length).toBeGreaterThanOrEqual(7); + + await testInfo.attach('canvas-renderer-perf-scenarios.json', { + body: JSON.stringify(results, null, 2), + contentType: 'application/json', + }); + }); + + test('collect metrics for interleaved block and canvas layers', async ({ + page, + }, testInfo) => { + test.slow(); + + await seedEdgelessPerfScene(page, { + interleaved: true, + noteCount: 21, + shapeCount: 20, + rowLength: 1, + startX: 120, + startY: 180, + width: 180, + height: 120, + }); + + await fitViewportToContent(page); + await page.waitForTimeout(500); + + const snapshot = await getCanvasRendererPerfSnapshot(page); + const metrics = snapshot.metrics as { + canvasMemoryMegabytes?: number; + lastRenderMetrics?: { + renderByBoundCallCount?: number; + }; + stackingCanvasCount?: number; + visibleStackingCanvasCount?: number; + } | null; + console.log( + `[canvas-perf] interleaved-layers: ${JSON.stringify(snapshot, null, 2)}` + ); + + expect(snapshot.rendererType).toBe('CanvasRenderer'); + expect(metrics).not.toBeNull(); + expect(metrics?.stackingCanvasCount ?? 0).toBeGreaterThan(0); + expect( + metrics?.lastRenderMetrics?.renderByBoundCallCount ?? 0 + ).toBeGreaterThan(1); + expect(metrics?.visibleStackingCanvasCount ?? 0).toBeGreaterThan(0); + expect(metrics?.canvasMemoryMegabytes ?? 0).toBeLessThan(5); + + await testInfo.attach('canvas-renderer-layering.json', { + body: JSON.stringify(snapshot, null, 2), + contentType: 'application/json', + }); + }); +}); diff --git a/tests/kit/src/utils/edgeless-perf.ts b/tests/kit/src/utils/edgeless-perf.ts new file mode 100644 index 0000000000..029784dbd4 --- /dev/null +++ b/tests/kit/src/utils/edgeless-perf.ts @@ -0,0 +1,231 @@ +import type { Page } from '@playwright/test'; + +import { locateEditorContainer } from './editor'; + +export type CanvasRendererPerfSnapshot = { + heapMemory: { + jsHeapSizeLimit: number; + totalJSHeapSize: number; + usedJSHeapSize: number; + } | null; + layerSequence: Array<'block' | 'canvas'>; + metrics: Record | null; + rendererType: string | null; + selectedIds: string[]; +}; + +export type SeedEdgelessPerfSceneOptions = { + height?: number; + interleaved?: boolean; + noteCount?: number; + rowLength?: number; + shapeCount?: number; + startX?: number; + startY?: number; + width?: number; +}; + +export async function getCanvasRendererPerfSnapshot( + page: Page, + editorIndex = 0 +): Promise { + const container = locateEditorContainer(page, editorIndex); + return container.evaluate(container => { + type PerfMemory = { + jsHeapSizeLimit: number; + totalJSHeapSize: number; + usedJSHeapSize: number; + }; + type PerfRenderer = { + constructor?: { name?: string }; + getDebugMetrics?: () => Record; + resetDebugMetrics?: () => void; + }; + + const root = container.querySelector('affine-edgeless-root'); + const surface = container.querySelector('affine-surface'); + + if (!root) { + throw new Error('Edgeless root not found'); + } + + if (!surface) { + throw new Error('Surface block not found'); + } + + const renderer = surface.renderer as PerfRenderer | undefined; + const metrics = + renderer && + typeof renderer.getDebugMetrics === 'function' && + renderer.constructor?.name === 'CanvasRenderer' + ? renderer.getDebugMetrics() + : null; + const memory = ( + performance as Performance & { + memory?: PerfMemory; + } + ).memory; + + return { + rendererType: renderer?.constructor?.name ?? null, + metrics, + selectedIds: [...root.gfx.selection.selectedIds], + layerSequence: root.gfx.layer.layers.map( + (layer: { type: 'block' | 'canvas' }) => layer.type + ), + heapMemory: memory + ? { + jsHeapSizeLimit: memory.jsHeapSizeLimit, + totalJSHeapSize: memory.totalJSHeapSize, + usedJSHeapSize: memory.usedJSHeapSize, + } + : null, + }; + }); +} + +export async function resetCanvasRendererPerfMetrics( + page: Page, + editorIndex = 0 +) { + const container = locateEditorContainer(page, editorIndex); + await container.evaluate(container => { + type PerfRenderer = { + resetDebugMetrics?: () => void; + }; + const surface = container.querySelector('affine-surface'); + + if (!surface) { + throw new Error('Surface block not found'); + } + + const renderer = surface.renderer as PerfRenderer | undefined; + if (!renderer || typeof renderer.resetDebugMetrics !== 'function') { + throw new Error('Canvas renderer debug metrics are unavailable'); + } + + renderer.resetDebugMetrics(); + }); +} + +export async function seedEdgelessPerfScene( + page: Page, + options: SeedEdgelessPerfSceneOptions = {}, + editorIndex = 0 +) { + const container = locateEditorContainer(page, editorIndex); + return container.evaluate((container, options) => { + const root = container.querySelector('affine-edgeless-root'); + + if (!root) { + throw new Error('Edgeless root not found'); + } + + const doc = root.service.doc; + const nextIndex = root.gfx.layer.createIndexGenerator(); + const shapeCount = options.shapeCount ?? 120; + const noteCount = options.noteCount ?? 0; + const rowLength = options.rowLength ?? 12; + const width = options.width ?? 140; + const height = options.height ?? 100; + const startX = options.startX ?? 80; + const startY = options.startY ?? 160; + const interleaved = options.interleaved ?? false; + const gapX = width + 36; + const gapY = height + 36; + + let shapeCursor = 0; + let noteCursor = 0; + const shapeIds: string[] = []; + const noteIds: string[] = []; + + const getPosition = (cursor: number) => { + const row = Math.floor(cursor / rowLength); + const col = cursor % rowLength; + + return { + x: startX + col * gapX, + y: startY + row * gapY, + }; + }; + + const addShape = () => { + const { x, y } = getPosition(shapeCursor++); + const id = root.service.crud.addElement('shape', { + index: nextIndex(), + shapeType: 'rect', + xywh: `[${x}, ${y}, ${width}, ${height}]`, + }); + + if (id) { + shapeIds.push(id); + } + }; + + const addNote = () => { + const { x, y } = getPosition(noteCursor++); + const noteId = doc.addBlock( + 'affine:note', + { + index: nextIndex(), + xywh: `[${x}, ${y}, ${Math.max(width * 2, 260)}, ${height}]`, + }, + doc.root + ); + + doc.addBlock('affine:paragraph', {}, noteId); + noteIds.push(noteId); + }; + + if (interleaved) { + const maxCount = Math.max(shapeCount, noteCount); + + for (let i = 0; i < maxCount; i++) { + if (i < noteCount) { + addNote(); + } + if (i < shapeCount) { + addShape(); + } + } + } else { + for (let i = 0; i < shapeCount; i++) { + addShape(); + } + for (let i = 0; i < noteCount; i++) { + addNote(); + } + } + + return { noteIds, shapeIds }; + }, options); +} + +export async function deleteEdgelessElements( + page: Page, + ids: string[], + editorIndex = 0 +) { + const container = locateEditorContainer(page, editorIndex); + await container.evaluate((container, ids) => { + const root = container.querySelector('affine-edgeless-root'); + + if (!root) { + throw new Error('Edgeless root not found'); + } + + const doc = root.service.doc; + + ids.forEach(id => { + const element = root.service.crud.getElementById(id); + if (element) { + root.service.removeElement(id); + return; + } + + if (doc.getBlock(id)) { + doc.deleteBlock(id); + } + }); + }, ids); +}