feat(editor): improve edgeless perf & memory usage (#14591)

#### PR Dependency Tree


* **PR #14591** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* New canvas renderer debug metrics and controls for runtime inspection.
* Mindmap/group reordering now normalizes group targets, improving
reorder consistency.

* **Bug Fixes**
  * Fixed connector behavior for empty/degenerate paths.
* More aggressive viewport invalidation so structural changes display
correctly.
* Improved z-index synchronization during transforms and layer updates.

* **Performance**
* Retained DOM caching for brushes, shapes, and connectors to reduce DOM
churn.
* Targeted canvas refreshes, pooling, and reuse to lower redraw and
memory overhead.

* **Tests**
* Added canvas renderer performance benchmarks and curve edge-case unit
tests.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-03-07 09:12:14 +08:00
committed by GitHub
parent 86d65b2f64
commit 9742e9735e
17 changed files with 1429 additions and 280 deletions
@@ -22,6 +22,7 @@ import {
FrameBlockModel, FrameBlockModel,
ImageBlockModel, ImageBlockModel,
isExternalEmbedModel, isExternalEmbedModel,
MindmapElementModel,
NoteBlockModel, NoteBlockModel,
ParagraphBlockModel, ParagraphBlockModel,
} from '@blocksuite/affine-model'; } from '@blocksuite/affine-model';
@@ -401,7 +402,17 @@ function reorderElements(
) { ) {
if (!models.length) return; 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); const index = ctx.gfx.layer.getReorderedIndex(model, type);
// block should be updated in transaction // block should be updated in transaction
@@ -2,16 +2,24 @@ import { type Color, ColorScheme } from '@blocksuite/affine-model';
import { FeatureFlagService } from '@blocksuite/affine-shared/services'; import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils'; import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
import { DisposableGroup } from '@blocksuite/global/disposable'; import { DisposableGroup } from '@blocksuite/global/disposable';
import type { IBound } from '@blocksuite/global/gfx'; import {
import { getBoundWithRotation, intersects } from '@blocksuite/global/gfx'; Bound,
getBoundWithRotation,
type IBound,
intersects,
} from '@blocksuite/global/gfx';
import type { BlockStdScope } from '@blocksuite/std'; import type { BlockStdScope } from '@blocksuite/std';
import type { import type {
GfxCompatibleInterface, GfxCompatibleInterface,
GfxController,
GfxLocalElementModel,
GridManager, GridManager,
LayerManager, LayerManager,
SurfaceBlockModel, SurfaceBlockModel,
Viewport, Viewport,
} from '@blocksuite/std/gfx'; } from '@blocksuite/std/gfx';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { effect } from '@preact/signals-core';
import last from 'lodash-es/last'; import last from 'lodash-es/last';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@@ -40,11 +48,82 @@ type RendererOptions = {
surfaceModel: SurfaceBlockModel; 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<SurfaceElementModel | GfxLocalElementModel>;
};
const STACKING_CANVAS_PADDING = 32;
export class CanvasRenderer { export class CanvasRenderer {
private _container!: HTMLElement; private _container!: HTMLElement;
private readonly _disposables = new DisposableGroup(); private readonly _disposables = new DisposableGroup();
private readonly _gfx: GfxController;
private readonly _turboEnabled: () => boolean; private readonly _turboEnabled: () => boolean;
private readonly _overlays = new Set<Overlay>(); private readonly _overlays = new Set<Overlay>();
@@ -53,6 +132,37 @@ export class CanvasRenderer {
private _stackingCanvas: HTMLCanvasElement[] = []; private _stackingCanvas: HTMLCanvasElement[] = [];
private readonly _stackingCanvasPool: HTMLCanvasElement[] = [];
private readonly _stackingCanvasState = new WeakMap<
HTMLCanvasElement,
StackingCanvasState
>();
private readonly _dirtyStackingCanvasIndexes = new Set<number>();
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; canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D; ctx: CanvasRenderingContext2D;
@@ -89,6 +199,7 @@ export class CanvasRenderer {
this.layerManager = options.layerManager; this.layerManager = options.layerManager;
this.grid = options.gridManager; this.grid = options.gridManager;
this.provider = options.provider ?? {}; this.provider = options.provider ?? {};
this._gfx = this.std.get(GfxControllerIdentifier);
this._turboEnabled = () => { this._turboEnabled = () => {
const featureFlagService = options.std.get(FeatureFlagService); 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) { private _initStackingCanvas(onCreated?: (canvas: HTMLCanvasElement) => void) {
const layer = this.layerManager; const layer = this.layerManager;
const updateStackingCanvasSize = (canvases: HTMLCanvasElement[]) => {
this._stackingCanvas = canvases;
const sizeUpdater = this._canvasSizeUpdater();
canvases.filter(sizeUpdater.filter).forEach(sizeUpdater.update);
};
const updateStackingCanvas = () => { const updateStackingCanvas = () => {
/** /**
* we already have a main canvas, so the last layer should be skipped * 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 created = i < currentCanvases.length;
const canvas = created const canvas = created
? currentCanvases[i] ? currentCanvases[i]
: document.createElement('canvas'); : this._createCanvasForLayer(onCreated);
if (!created) {
onCreated?.(canvas);
}
canvas.dataset.layerId = `[${layer.indexes[0]}--${layer.indexes[1]}]`; canvas.dataset.layerId = `[${layer.indexes[0]}--${layer.indexes[1]}]`;
canvas.style.zIndex = layer.zIndex.toString(); canvas.style.zIndex = layer.zIndex.toString();
@@ -171,7 +462,6 @@ export class CanvasRenderer {
} }
this._stackingCanvas = canvases; this._stackingCanvas = canvases;
updateStackingCanvasSize(canvases);
if (currentCanvases.length !== canvases.length) { if (currentCanvases.length !== canvases.length) {
const diff = canvases.length - currentCanvases.length; const diff = canvases.length - currentCanvases.length;
@@ -189,12 +479,16 @@ export class CanvasRenderer {
payload.added = canvases.slice(-diff); payload.added = canvases.slice(-diff);
} else { } else {
payload.removed = currentCanvases.slice(diff); payload.removed = currentCanvases.slice(diff);
payload.removed.forEach(canvas => {
this._resetPooledCanvas(canvas);
this._stackingCanvasPool.push(canvas);
});
} }
this.stackingCanvasUpdated.next(payload); this.stackingCanvasUpdated.next(payload);
} }
this.refresh(); this.refresh({ type: 'all' });
}; };
this._disposables.add( this._disposables.add(
@@ -211,7 +505,7 @@ export class CanvasRenderer {
this._disposables.add( this._disposables.add(
this.viewport.viewportUpdated.subscribe(() => { this.viewport.viewportUpdated.subscribe(() => {
this.refresh(); this.refresh({ type: 'all' });
}) })
); );
@@ -222,7 +516,6 @@ export class CanvasRenderer {
sizeUpdatedRafId = null; sizeUpdatedRafId = null;
this._resetSize(); this._resetSize();
this._render(); this._render();
this.refresh();
}, this._container); }, this._container);
}) })
); );
@@ -233,69 +526,212 @@ export class CanvasRenderer {
if (this.usePlaceholder !== shouldRenderPlaceholders) { if (this.usePlaceholder !== shouldRenderPlaceholders) {
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; 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() { private _render() {
const renderStart = performance.now();
const { viewportBounds, zoom } = this.viewport; const { viewportBounds, zoom } = this.viewport;
const { ctx } = this; const { ctx } = this;
const dpr = window.devicePixelRatio; const dpr = window.devicePixelRatio;
const scale = zoom * dpr; const scale = zoom * dpr;
const matrix = new DOMMatrix().scaleSelf(scale); 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 * if a layer does not have a corresponding canvas
* its element will be add to this array and drawing on the * its element will be add to this array and drawing on the
* main canvas * main canvas
*/ */
let fallbackElement: SurfaceElementModel[] = []; let fallbackElement: SurfaceElementModel[] = [];
const allCanvasLayers = this.layerManager.getCanvasLayers();
const viewportBound = Bound.from(viewportBounds);
this.layerManager.getCanvasLayers().forEach((layer, idx) => { for (const idx of stackingIndexesToRender) {
if (!this._stackingCanvas[idx]) { const layer = allCanvasLayers[idx];
fallbackElement = fallbackElement.concat(layer.elements); const canvas = this._stackingCanvas[idx];
return;
if (!layer || !canvas) {
continue;
} }
const canvas = this._stackingCanvas[idx]; const layerRenderBound = this._getLayerRenderBound(
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; layer.elements,
const rc = new RoughCanvas(ctx.canvas); 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.save();
ctx.setTransform(matrix); 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); const canvasMemorySnapshots = this._getCanvasMemorySnapshots();
ctx.save(); const canvasMemoryBytes = canvasMemorySnapshots.reduce(
(sum, snapshot) => sum + snapshot.bytes,
ctx.setTransform(matrix); 0
this._renderByBound(
ctx,
matrix,
new RoughCanvas(ctx.canvas),
viewportBounds,
fallbackElement,
true
); );
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( private _renderByBound(
ctx: CanvasRenderingContext2D | null, ctx: CanvasRenderingContext2D | null,
matrix: DOMMatrix, matrix: DOMMatrix,
rc: RoughCanvas, rc: RoughCanvas,
bound: IBound, bound: IBound,
surfaceElements?: SurfaceElementModel[], surfaceElements?: SurfaceElementModel[],
overLay: boolean = false overLay: boolean = false,
renderStats?: RenderPassStats
) { ) {
if (!ctx) return; if (!ctx) return;
renderStats && (renderStats.renderByBoundCallCount += 1);
const elements = const elements =
surfaceElements ?? surfaceElements ??
(this.grid.search(bound, { (this.grid.search(bound, {
@@ -305,10 +741,12 @@ export class CanvasRenderer {
for (const element of elements) { for (const element of elements) {
const display = (element.display ?? true) && !element.hidden; const display = (element.display ?? true) && !element.hidden;
if (display && intersects(getBoundWithRotation(element), bound)) { if (display && intersects(getBoundWithRotation(element), bound)) {
renderStats && (renderStats.visibleElementCount += 1);
if ( if (
this.usePlaceholder && this.usePlaceholder &&
!(element as GfxCompatibleInterface).forceFullRender !(element as GfxCompatibleInterface).forceFullRender
) { ) {
renderStats && (renderStats.placeholderElementCount += 1);
ctx.save(); ctx.save();
ctx.fillStyle = 'rgba(200, 200, 200, 0.5)'; ctx.fillStyle = 'rgba(200, 200, 200, 0.5)';
const drawX = element.x - bound.x; const drawX = element.x - bound.x;
@@ -316,6 +754,7 @@ export class CanvasRenderer {
ctx.fillRect(drawX, drawY, element.w, element.h); ctx.fillRect(drawX, drawY, element.w, element.h);
ctx.restore(); ctx.restore();
} else { } else {
renderStats && (renderStats.renderedElementCount += 1);
ctx.save(); ctx.save();
const renderFn = this.std.getOptional<ElementRenderer>( const renderFn = this.std.getOptional<ElementRenderer>(
ElementRendererIdentifier(element.type) ElementRendererIdentifier(element.type)
@@ -333,6 +772,7 @@ export class CanvasRenderer {
} }
if (overLay) { if (overLay) {
renderStats && (renderStats.overlayCount += this._overlays.size);
for (const overlay of this._overlays) { for (const overlay of this._overlays) {
ctx.save(); ctx.save();
ctx.translate(-bound.x, -bound.y); ctx.translate(-bound.x, -bound.y);
@@ -348,33 +788,38 @@ export class CanvasRenderer {
const sizeUpdater = this._canvasSizeUpdater(); const sizeUpdater = this._canvasSizeUpdater();
sizeUpdater.update(this.canvas); sizeUpdater.update(this.canvas);
this._invalidate({ type: 'all' });
this._stackingCanvas.forEach(sizeUpdater.update);
this.refresh();
} }
private _watchSurface(surfaceModel: SurfaceBlockModel) { private _watchSurface(surfaceModel: SurfaceBlockModel) {
this._disposables.add( this._disposables.add(
surfaceModel.elementAdded.subscribe(() => this.refresh()) surfaceModel.elementAdded.subscribe(() => this.refresh({ type: 'all' }))
); );
this._disposables.add( this._disposables.add(
surfaceModel.elementRemoved.subscribe(() => this.refresh()) surfaceModel.elementRemoved.subscribe(() => this.refresh({ type: 'all' }))
); );
this._disposables.add( this._disposables.add(
surfaceModel.localElementAdded.subscribe(() => this.refresh()) surfaceModel.localElementAdded.subscribe(() =>
this.refresh({ type: 'all' })
)
); );
this._disposables.add( this._disposables.add(
surfaceModel.localElementDeleted.subscribe(() => this.refresh()) surfaceModel.localElementDeleted.subscribe(() =>
this.refresh({ type: 'all' })
)
); );
this._disposables.add( this._disposables.add(
surfaceModel.localElementUpdated.subscribe(() => this.refresh()) surfaceModel.localElementUpdated.subscribe(({ model }) => {
this.refresh({ type: 'element', element: model });
})
); );
this._disposables.add( this._disposables.add(
surfaceModel.elementUpdated.subscribe(payload => { surfaceModel.elementUpdated.subscribe(payload => {
// ignore externalXYWH update cause it's updated by the renderer // ignore externalXYWH update cause it's updated by the renderer
if (payload.props['externalXYWH']) return; 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) { addOverlay(overlay: Overlay) {
overlay.setRenderer(this); overlay.setRenderer(this);
this._overlays.add(overlay); this._overlays.add(overlay);
this.refresh(); this.refresh({ type: 'main' });
} }
/** /**
@@ -394,7 +839,7 @@ export class CanvasRenderer {
container.append(this.canvas); container.append(this.canvas);
this._resetSize(); this._resetSize();
this.refresh(); this.refresh({ type: 'all' });
} }
dispose(): void { dispose(): void {
@@ -453,8 +898,46 @@ export class CanvasRenderer {
return this.provider.getPropertyValue?.(property) ?? ''; return this.provider.getPropertyValue?.(property) ?? '';
} }
refresh() { getDebugMetrics(): CanvasRendererDebugMetrics {
if (this._refreshRafId !== null) return; 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 = requestConnectedFrame(() => {
this._refreshRafId = null; this._refreshRafId = null;
@@ -469,6 +952,6 @@ export class CanvasRenderer {
overlay.setRenderer(null); overlay.setRenderer(null);
this._overlays.delete(overlay); this._overlays.delete(overlay);
this.refresh(); this.refresh({ type: 'main' });
} }
} }
@@ -354,30 +354,37 @@ export class DomRenderer {
this._disposables.add( this._disposables.add(
surfaceModel.elementAdded.subscribe(payload => { surfaceModel.elementAdded.subscribe(payload => {
this._markElementDirty(payload.id, UpdateType.ELEMENT_ADDED); this._markElementDirty(payload.id, UpdateType.ELEMENT_ADDED);
this._markViewportDirty();
this.refresh(); this.refresh();
}) })
); );
this._disposables.add( this._disposables.add(
surfaceModel.elementRemoved.subscribe(payload => { surfaceModel.elementRemoved.subscribe(payload => {
this._markElementDirty(payload.id, UpdateType.ELEMENT_REMOVED); this._markElementDirty(payload.id, UpdateType.ELEMENT_REMOVED);
this._markViewportDirty();
this.refresh(); this.refresh();
}) })
); );
this._disposables.add( this._disposables.add(
surfaceModel.localElementAdded.subscribe(payload => { surfaceModel.localElementAdded.subscribe(payload => {
this._markElementDirty(payload.id, UpdateType.ELEMENT_ADDED); this._markElementDirty(payload.id, UpdateType.ELEMENT_ADDED);
this._markViewportDirty();
this.refresh(); this.refresh();
}) })
); );
this._disposables.add( this._disposables.add(
surfaceModel.localElementDeleted.subscribe(payload => { surfaceModel.localElementDeleted.subscribe(payload => {
this._markElementDirty(payload.id, UpdateType.ELEMENT_REMOVED); this._markElementDirty(payload.id, UpdateType.ELEMENT_REMOVED);
this._markViewportDirty();
this.refresh(); this.refresh();
}) })
); );
this._disposables.add( this._disposables.add(
surfaceModel.localElementUpdated.subscribe(payload => { surfaceModel.localElementUpdated.subscribe(payload => {
this._markElementDirty(payload.model.id, UpdateType.ELEMENT_UPDATED); this._markElementDirty(payload.model.id, UpdateType.ELEMENT_UPDATED);
if (payload.props['index'] || payload.props['groupId']) {
this._markViewportDirty();
}
this.refresh(); this.refresh();
}) })
); );
@@ -387,6 +394,9 @@ export class DomRenderer {
// ignore externalXYWH update cause it's updated by the renderer // ignore externalXYWH update cause it's updated by the renderer
if (payload.props['externalXYWH']) return; if (payload.props['externalXYWH']) return;
this._markElementDirty(payload.id, UpdateType.ELEMENT_UPDATED); this._markElementDirty(payload.id, UpdateType.ELEMENT_UPDATED);
if (payload.props['index'] || payload.props['childIds']) {
this._markViewportDirty();
}
this.refresh(); this.refresh();
}) })
); );
@@ -5,6 +5,8 @@ import {
import type { BrushElementModel } from '@blocksuite/affine-model'; import type { BrushElementModel } from '@blocksuite/affine-model';
import { DefaultTheme } from '@blocksuite/affine-model'; import { DefaultTheme } from '@blocksuite/affine-model';
import { renderBrushLikeDom } from './shared';
export const BrushDomRendererExtension = DomElementRendererExtension( export const BrushDomRendererExtension = DomElementRendererExtension(
'brush', 'brush',
( (
@@ -12,58 +14,11 @@ export const BrushDomRendererExtension = DomElementRendererExtension(
domElement: HTMLElement, domElement: HTMLElement,
renderer: DomRenderer renderer: DomRenderer
) => { ) => {
const { zoom } = renderer.viewport; renderBrushLikeDom({
const [, , w, h] = model.deserializedXYWH; model,
domElement,
// Early return if invalid dimensions renderer,
if (w <= 0 || h <= 0) { color: renderer.getColorValue(model.color, DefaultTheme.black, true),
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';
} }
); );
@@ -5,6 +5,8 @@ import {
import type { HighlighterElementModel } from '@blocksuite/affine-model'; import type { HighlighterElementModel } from '@blocksuite/affine-model';
import { DefaultTheme } from '@blocksuite/affine-model'; import { DefaultTheme } from '@blocksuite/affine-model';
import { renderBrushLikeDom } from './shared';
export const HighlighterDomRendererExtension = DomElementRendererExtension( export const HighlighterDomRendererExtension = DomElementRendererExtension(
'highlighter', 'highlighter',
( (
@@ -12,62 +14,15 @@ export const HighlighterDomRendererExtension = DomElementRendererExtension(
domElement: HTMLElement, domElement: HTMLElement,
renderer: DomRenderer renderer: DomRenderer
) => { ) => {
const { zoom } = renderer.viewport; renderBrushLikeDom({
const [, , w, h] = model.deserializedXYWH; model,
domElement,
// Early return if invalid dimensions renderer,
if (w <= 0 || h <= 0) { color: renderer.getColorValue(
return; model.color,
} DefaultTheme.hightlighterColor,
true
// 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';
} }
); );
@@ -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<HTMLElement, RetainedBrushDom>();
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';
}
@@ -14,6 +14,8 @@ import { PointLocation, SVGPathBuilder } from '@blocksuite/global/gfx';
import { isConnectorWithLabel } from '../connector-manager'; import { isConnectorWithLabel } from '../connector-manager';
import { DEFAULT_ARROW_SIZE } from './utils'; import { DEFAULT_ARROW_SIZE } from './utils';
const SVG_NS = 'http://www.w3.org/2000/svg';
interface PathBounds { interface PathBounds {
minX: number; minX: number;
minY: number; minY: number;
@@ -21,6 +23,15 @@ interface PathBounds {
maxY: number; maxY: number;
} }
type RetainedConnectorDom = {
defs: SVGDefsElement;
label: HTMLDivElement | null;
path: SVGPathElement;
svg: SVGSVGElement;
};
const retainedConnectorDom = new WeakMap<HTMLElement, RetainedConnectorDom>();
function calculatePathBounds(path: PointLocation[]): PathBounds { function calculatePathBounds(path: PointLocation[]): PathBounds {
if (path.length === 0) { if (path.length === 0) {
return { minX: 0, minY: 0, maxX: 0, maxY: 0 }; return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
@@ -81,10 +92,7 @@ function createArrowMarker(
strokeWidth: number, strokeWidth: number,
isStart: boolean = false isStart: boolean = false
): SVGMarkerElement { ): SVGMarkerElement {
const marker = document.createElementNS( const marker = document.createElementNS(SVG_NS, 'marker');
'http://www.w3.org/2000/svg',
'marker'
);
const size = DEFAULT_ARROW_SIZE * (strokeWidth / 2); const size = DEFAULT_ARROW_SIZE * (strokeWidth / 2);
marker.id = id; marker.id = id;
@@ -98,10 +106,7 @@ function createArrowMarker(
switch (style) { switch (style) {
case 'Arrow': { case 'Arrow': {
const path = document.createElementNS( const path = document.createElementNS(SVG_NS, 'path');
'http://www.w3.org/2000/svg',
'path'
);
path.setAttribute( path.setAttribute(
'd', 'd',
isStart ? 'M 20 5 L 10 10 L 20 15 Z' : 'M 0 5 L 10 10 L 0 15 Z' 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; break;
} }
case 'Triangle': { case 'Triangle': {
const path = document.createElementNS( const path = document.createElementNS(SVG_NS, 'path');
'http://www.w3.org/2000/svg',
'path'
);
path.setAttribute( path.setAttribute(
'd', 'd',
isStart ? 'M 20 7 L 12 10 L 20 13 Z' : 'M 0 7 L 8 10 L 0 13 Z' 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; break;
} }
case 'Circle': { case 'Circle': {
const circle = document.createElementNS( const circle = document.createElementNS(SVG_NS, 'circle');
'http://www.w3.org/2000/svg',
'circle'
);
circle.setAttribute('cx', '10'); circle.setAttribute('cx', '10');
circle.setAttribute('cy', '10'); circle.setAttribute('cy', '10');
circle.setAttribute('r', '4'); circle.setAttribute('r', '4');
@@ -139,10 +138,7 @@ function createArrowMarker(
break; break;
} }
case 'Diamond': { case 'Diamond': {
const path = document.createElementNS( const path = document.createElementNS(SVG_NS, 'path');
'http://www.w3.org/2000/svg',
'path'
);
path.setAttribute('d', 'M 10 6 L 14 10 L 10 14 L 6 10 Z'); path.setAttribute('d', 'M 10 6 L 14 10 L 10 14 L 6 10 Z');
path.setAttribute('fill', color); path.setAttribute('fill', color);
path.setAttribute('stroke', color); path.setAttribute('stroke', color);
@@ -154,13 +150,64 @@ function createArrowMarker(
return marker; 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( function renderConnectorLabel(
model: ConnectorElementModel, model: ConnectorElementModel,
container: HTMLElement, retained: RetainedConnectorDom,
renderer: DomRenderer, renderer: DomRenderer,
zoom: number zoom: number
) { ) {
if (!isConnectorWithLabel(model) || !model.labelXYWH) { if (!isConnectorWithLabel(model) || !model.labelXYWH) {
retained.label?.remove();
retained.label = null;
return; return;
} }
@@ -176,8 +223,7 @@ function renderConnectorLabel(
}, },
} = model; } = model;
// Create label element const labelElement = getOrCreateLabelElement(retained);
const labelElement = document.createElement('div');
labelElement.style.position = 'absolute'; labelElement.style.position = 'absolute';
labelElement.style.left = `${lx * zoom}px`; labelElement.style.left = `${lx * zoom}px`;
labelElement.style.top = `${ly * zoom}px`; labelElement.style.top = `${ly * zoom}px`;
@@ -210,11 +256,7 @@ function renderConnectorLabel(
labelElement.style.wordWrap = 'break-word'; labelElement.style.wordWrap = 'break-word';
// Add text content // Add text content
if (model.text) { labelElement.textContent = model.text ? model.text.toString() : '';
labelElement.textContent = model.text.toString();
}
container.append(labelElement);
} }
/** /**
@@ -241,14 +283,13 @@ export const connectorBaseDomRenderer = (
stroke, stroke,
} = model; } = model;
// Clear previous content
element.innerHTML = '';
// Early return if no path points
if (!points || points.length < 2) { if (!points || points.length < 2) {
clearRetainedConnectorDom(element);
return; return;
} }
const retained = getRetainedConnectorDom(element);
// Calculate bounds for the SVG viewBox // Calculate bounds for the SVG viewBox
const pathBounds = calculatePathBounds(points); const pathBounds = calculatePathBounds(points);
const padding = Math.max(strokeWidth * 2, 20); // Add padding for arrows const padding = Math.max(strokeWidth * 2, 20); // Add padding for arrows
@@ -257,8 +298,7 @@ export const connectorBaseDomRenderer = (
const offsetX = pathBounds.minX - padding; const offsetX = pathBounds.minX - padding;
const offsetY = pathBounds.minY - padding; const offsetY = pathBounds.minY - padding;
// Create SVG element const { defs, path, svg } = retained;
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.style.position = 'absolute'; svg.style.position = 'absolute';
svg.style.left = `${offsetX * zoom}px`; svg.style.left = `${offsetX * zoom}px`;
svg.style.top = `${offsetY * zoom}px`; svg.style.top = `${offsetY * zoom}px`;
@@ -268,49 +308,43 @@ export const connectorBaseDomRenderer = (
svg.style.pointerEvents = 'none'; svg.style.pointerEvents = 'none';
svg.setAttribute('viewBox', `0 0 ${svgWidth / zoom} ${svgHeight / zoom}`); 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( const strokeColor = renderer.getColorValue(
stroke, stroke,
DefaultTheme.connectorColor, DefaultTheme.connectorColor,
true true
); );
// Create markers for endpoints const markers: SVGMarkerElement[] = [];
let startMarkerId = ''; let startMarkerId = '';
let endMarkerId = ''; let endMarkerId = '';
if (frontEndpointStyle !== 'None') { if (frontEndpointStyle !== 'None') {
startMarkerId = `start-marker-${model.id}`; startMarkerId = `start-marker-${model.id}`;
const startMarker = createArrowMarker( markers.push(
startMarkerId, createArrowMarker(
frontEndpointStyle, startMarkerId,
strokeColor, frontEndpointStyle,
strokeWidth, strokeColor,
true strokeWidth,
true
)
); );
defs.append(startMarker);
} }
if (rearEndpointStyle !== 'None') { if (rearEndpointStyle !== 'None') {
endMarkerId = `end-marker-${model.id}`; endMarkerId = `end-marker-${model.id}`;
const endMarker = createArrowMarker( markers.push(
endMarkerId, createArrowMarker(
rearEndpointStyle, endMarkerId,
strokeColor, rearEndpointStyle,
strokeWidth, strokeColor,
false strokeWidth,
false
)
); );
defs.append(endMarker);
} }
// Create path element defs.replaceChildren(...markers);
const pathElement = document.createElementNS(
'http://www.w3.org/2000/svg',
'path'
);
// Adjust points relative to the SVG coordinate system // Adjust points relative to the SVG coordinate system
const adjustedPoints = points.map(point => { const adjustedPoints = points.map(point => {
@@ -334,29 +368,25 @@ export const connectorBaseDomRenderer = (
}); });
const pathData = createConnectorPath(adjustedPoints, mode); const pathData = createConnectorPath(adjustedPoints, mode);
pathElement.setAttribute('d', pathData); path.setAttribute('d', pathData);
pathElement.setAttribute('stroke', strokeColor); path.setAttribute('stroke', strokeColor);
pathElement.setAttribute('stroke-width', String(strokeWidth)); path.setAttribute('stroke-width', String(strokeWidth));
pathElement.setAttribute('fill', 'none');
pathElement.setAttribute('stroke-linecap', 'round');
pathElement.setAttribute('stroke-linejoin', 'round');
// Apply stroke style
if (strokeStyle === 'dash') { if (strokeStyle === 'dash') {
pathElement.setAttribute('stroke-dasharray', '12,12'); path.setAttribute('stroke-dasharray', '12,12');
} else {
path.removeAttribute('stroke-dasharray');
} }
// Apply markers
if (startMarkerId) { if (startMarkerId) {
pathElement.setAttribute('marker-start', `url(#${startMarkerId})`); path.setAttribute('marker-start', `url(#${startMarkerId})`);
} else {
path.removeAttribute('marker-start');
} }
if (endMarkerId) { 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 // Set element size and position
element.style.width = `${model.w * zoom}px`; element.style.width = `${model.w * zoom}px`;
element.style.height = `${model.h * zoom}px`; element.style.height = `${model.h * zoom}px`;
@@ -370,7 +400,11 @@ export const connectorDomRenderer = (
renderer: DomRenderer renderer: DomRenderer
): void => { ): void => {
connectorBaseDomRenderer(model, element, renderer); 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);
}; };
/** /**
@@ -6,6 +6,37 @@ import { SVGShapeBuilder } from '@blocksuite/global/gfx';
import { manageClassNames, setStyles } from './utils'; 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<HTMLElement, RetainedShapeDom>();
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( function applyShapeSpecificStyles(
model: ShapeElementModel, model: ShapeElementModel,
element: HTMLElement, element: HTMLElement,
@@ -14,10 +45,6 @@ function applyShapeSpecificStyles(
// Reset properties that might be set by different shape types // Reset properties that might be set by different shape types
element.style.removeProperty('clip-path'); element.style.removeProperty('clip-path');
element.style.removeProperty('border-radius'); 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) { switch (model.shapeType) {
case 'rect': { case 'rect': {
@@ -42,6 +69,54 @@ function applyShapeSpecificStyles(
// No 'else' needed to clear styles, as they are reset at the beginning of the function. // 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( function applyBorderStyles(
model: ShapeElementModel, model: ShapeElementModel,
element: HTMLElement, element: HTMLElement,
@@ -99,8 +174,7 @@ export const shapeDomRenderer = (
const { zoom } = renderer.viewport; const { zoom } = renderer.viewport;
const unscaledWidth = model.w; const unscaledWidth = model.w;
const unscaledHeight = model.h; const unscaledHeight = model.h;
const retained = getRetainedShapeDom(element);
const newChildren: Element[] = [];
const fillColor = renderer.getColorValue( const fillColor = renderer.getColorValue(
model.fillColor, model.fillColor,
@@ -124,6 +198,7 @@ export const shapeDomRenderer = (
// For diamond and triangle, fill and border are handled by inline SVG // For diamond and triangle, fill and border are handled by inline SVG
element.style.border = 'none'; // Ensure no standard CSS border interferes element.style.border = 'none'; // Ensure no standard CSS border interferes
element.style.backgroundColor = 'transparent'; // Host element is transparent element.style.backgroundColor = 'transparent'; // Host element is transparent
const { polygon, svg } = getOrCreateSvg(retained, element);
const strokeW = model.strokeWidth; const strokeW = model.strokeWidth;
@@ -155,37 +230,30 @@ export const shapeDomRenderer = (
// Determine fill color // Determine fill color
const finalFillColor = model.filled ? fillColor : 'transparent'; 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('viewBox', `0 0 ${unscaledWidth} ${unscaledHeight}`);
svg.setAttribute('preserveAspectRatio', 'none');
const polygon = document.createElementNS(SVG_NS, 'polygon');
polygon.setAttribute('points', svgPoints); polygon.setAttribute('points', svgPoints);
polygon.setAttribute('fill', finalFillColor); polygon.setAttribute('fill', finalFillColor);
polygon.setAttribute('stroke', finalStrokeColor); polygon.setAttribute('stroke', finalStrokeColor);
polygon.setAttribute('stroke-width', String(strokeW)); polygon.setAttribute('stroke-width', String(strokeW));
if (finalStrokeDasharray !== 'none') { if (finalStrokeDasharray !== 'none') {
polygon.setAttribute('stroke-dasharray', finalStrokeDasharray); polygon.setAttribute('stroke-dasharray', finalStrokeDasharray);
} else {
polygon.removeAttribute('stroke-dasharray');
} }
svg.append(polygon);
newChildren.push(svg);
} else { } else {
// Standard rendering for other shapes (e.g., rect, ellipse) // 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'; element.style.backgroundColor = model.filled ? fillColor : 'transparent';
applyBorderStyles(model, element, strokeColor, zoom); // Uses standard CSS border applyBorderStyles(model, element, strokeColor, zoom); // Uses standard CSS border
} }
if (model.textDisplay && model.text) { if (model.textDisplay && model.text) {
const str = model.text.toString(); const str = model.text.toString();
const textElement = document.createElement('div'); const textElement = getOrCreateText(retained, element);
if (isRTL(str)) { if (isRTL(str)) {
textElement.dir = 'rtl'; textElement.dir = 'rtl';
} else {
textElement.removeAttribute('dir');
} }
textElement.style.position = 'absolute'; textElement.style.position = 'absolute';
textElement.style.inset = '0'; textElement.style.inset = '0';
@@ -210,12 +278,10 @@ export const shapeDomRenderer = (
true true
); );
textElement.textContent = str; textElement.textContent = str;
newChildren.push(textElement); } else {
removeText(retained);
} }
// Replace existing children to avoid memory leaks
element.replaceChildren(...newChildren);
applyTransformStyles(model, element); applyTransformStyles(model, element);
manageClassNames(model, element); manageClassNames(model, element);
@@ -177,6 +177,11 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorEle
override getNearestPoint(point: IVec): IVec { override getNearestPoint(point: IVec): IVec {
const { mode, absolutePath: path } = this; const { mode, absolutePath: path } = this;
if (path.length === 0) {
const { x, y } = this;
return [x, y];
}
if (mode === ConnectorMode.Straight) { if (mode === ConnectorMode.Straight) {
const first = path[0]; const first = path[0];
const last = path[path.length - 1]; const last = path[path.length - 1];
@@ -213,6 +218,10 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorEle
h = bounds.h; h = bounds.h;
} }
if (path.length === 0) {
return 0.5;
}
point[0] = Vec.clamp(point[0], x, x + w); point[0] = Vec.clamp(point[0], x, x + w);
point[1] = Vec.clamp(point[1], y, y + h); point[1] = Vec.clamp(point[1], y, y + h);
@@ -258,6 +267,10 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorEle
h = bounds.h; h = bounds.h;
} }
if (path.length === 0) {
return [x + w / 2, y + h / 2];
}
if (mode === ConnectorMode.Orthogonal) { if (mode === ConnectorMode.Orthogonal) {
const points = path.map<IVec>(p => [p[0], p[1]]); const points = path.map<IVec>(p => [p[0], p[1]]);
const point = Polyline.pointAt(points, offsetDistance); const point = Polyline.pointAt(points, offsetDistance);
@@ -300,6 +313,10 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorEle
const { mode, strokeWidth, absolutePath: path } = this; const { mode, strokeWidth, absolutePath: path } = this;
if (path.length === 0) {
return false;
}
const point = const point =
mode === ConnectorMode.Curve mode === ConnectorMode.Curve
? getBezierNearestPoint(getBezierParameters(path), currentPoint) ? getBezierNearestPoint(getBezierParameters(path), currentPoint)
@@ -0,0 +1,22 @@
import { describe, expect, test } from 'vitest';
import { getBezierParameters } from '../gfx/curve.js';
import { PointLocation } from '../gfx/model/index.js';
describe('getBezierParameters', () => {
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]);
});
});
@@ -142,6 +142,11 @@ export function getBezierNearestPoint(
export function getBezierParameters( export function getBezierParameters(
points: PointLocation[] points: PointLocation[]
): BezierCurveParameters { ): 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) // Fallback for degenerate Bezier curve (all points are at the same position)
if (points.length === 1) { if (points.length === 1) {
const point = points[0]; const point = points[0];
+2 -11
View File
@@ -596,7 +596,7 @@ export class LayerManager extends GfxExtension {
private _updateLayer( private _updateLayer(
element: GfxModel | GfxLocalElementModel, element: GfxModel | GfxLocalElementModel,
props?: Record<string, unknown>, props?: Record<string, unknown>,
oldValues?: Record<string, unknown> _oldValues?: Record<string, unknown>
) { ) {
const modelType = this._getModelType(element); const modelType = this._getModelType(element);
const isLocalElem = element instanceof GfxLocalElementModel; const isLocalElem = element instanceof GfxLocalElementModel;
@@ -613,16 +613,7 @@ export class LayerManager extends GfxExtension {
}; };
if (shouldUpdateGroupChildren) { if (shouldUpdateGroupChildren) {
const group = element as GfxModel & GfxGroupCompatibleInterface; this._reset();
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);
return true; return true;
} }
@@ -31,6 +31,13 @@ function updateTransform(element: GfxBlockComponent) {
element.style.transform = element.getCSSTransform(); 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) { function updateBlockVisibility(view: GfxBlockComponent) {
if (view.transformState$.value === 'active') { if (view.transformState$.value === 'active') {
view.style.visibility = 'visible'; view.style.visibility = 'visible';
@@ -58,14 +65,22 @@ function handleGfxConnection(instance: GfxBlockComponent) {
instance.store.slots.blockUpdated.subscribe(({ type, id }) => { instance.store.slots.blockUpdated.subscribe(({ type, id }) => {
if (id === instance.model.id && type === 'update') { if (id === instance.model.id && type === 'update') {
updateTransform(instance); updateTransform(instance);
updateZIndex(instance);
} }
}) })
); );
instance.disposables.add(
instance.gfx.layer.slots.layerUpdated.subscribe(() => {
updateZIndex(instance);
})
);
instance.disposables.add( instance.disposables.add(
effect(() => { effect(() => {
updateBlockVisibility(instance); updateBlockVisibility(instance);
updateTransform(instance); updateTransform(instance);
updateZIndex(instance);
}) })
); );
} }
@@ -6,6 +6,7 @@ import type {
import { ungroupCommand } from '@blocksuite/affine/gfx/group'; import { ungroupCommand } from '@blocksuite/affine/gfx/group';
import type { import type {
GroupElementModel, GroupElementModel,
MindmapElementModel,
NoteBlockModel, NoteBlockModel,
} from '@blocksuite/affine/model'; } from '@blocksuite/affine/model';
import { generateKeyBetween } from '@blocksuite/affine/std/gfx'; import { generateKeyBetween } from '@blocksuite/affine/std/gfx';
@@ -253,6 +254,40 @@ test('blocks should rerender when their z-index changed', async () => {
assertBlocksContent(); assertBlocksContent();
}); });
test('block host z-index should update after reordering', async () => {
const backId = addNote(doc);
const frontId = addNote(doc);
await wait();
const getBlockHost = (id: string) =>
document.querySelector<HTMLElement>(
`affine-edgeless-root gfx-viewport > [data-block-id="${id}"]`
);
const backHost = getBlockHost(backId);
const frontHost = getBlockHost(frontId);
expect(backHost).not.toBeNull();
expect(frontHost).not.toBeNull();
expect(Number(backHost!.style.zIndex)).toBeLessThan(
Number(frontHost!.style.zIndex)
);
service.crud.updateElement(backId, {
index: service.layer.getReorderedIndex(
service.crud.getElementById(backId)!,
'front'
),
});
await wait();
expect(Number(backHost!.style.zIndex)).toBeGreaterThan(
Number(frontHost!.style.zIndex)
);
});
describe('layer reorder functionality', () => { describe('layer reorder functionality', () => {
let ids: string[] = []; let ids: string[] = [];
@@ -428,14 +463,17 @@ describe('group related functionality', () => {
const elements = [ const elements = [
service.crud.addElement('shape', { service.crud.addElement('shape', {
shapeType: 'rect', shapeType: 'rect',
xywh: '[0,0,100,100]',
})!, })!,
addNote(doc), addNote(doc),
service.crud.addElement('shape', { service.crud.addElement('shape', {
shapeType: 'rect', shapeType: 'rect',
xywh: '[120,0,100,100]',
})!, })!,
addNote(doc), addNote(doc),
service.crud.addElement('shape', { service.crud.addElement('shape', {
shapeType: 'rect', shapeType: 'rect',
xywh: '[240,0,100,100]',
})!, })!,
]; ];
@@ -528,6 +566,35 @@ describe('group related functionality', () => {
expect(service.layer.layers[1].elements[0]).toBe(group); 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', () => { test('should keep relative index order of elements after group, ungroup, undo, redo', () => {
const edgeless = getDocRootBlock(doc, editor, 'edgeless'); const edgeless = getDocRootBlock(doc, editor, 'edgeless');
const elementIds = [ const elementIds = [
@@ -769,6 +836,7 @@ test('indexed canvas should be inserted into edgeless portal when switch to edge
service.crud.addElement('shape', { service.crud.addElement('shape', {
shapeType: 'rect', shapeType: 'rect',
xywh: '[0,0,100,100]',
})!; })!;
addNote(doc); addNote(doc);
@@ -777,6 +845,7 @@ test('indexed canvas should be inserted into edgeless portal when switch to edge
service.crud.addElement('shape', { service.crud.addElement('shape', {
shapeType: 'rect', shapeType: 'rect',
xywh: '[120,0,100,100]',
})!; })!;
editor.mode = 'page'; editor.mode = 'page';
@@ -792,10 +861,10 @@ test('indexed canvas should be inserted into edgeless portal when switch to edge
'.indexable-canvas' '.indexable-canvas'
)[0] as HTMLCanvasElement; )[0] as HTMLCanvasElement;
expect(indexedCanvas.width).toBe( expect(indexedCanvas.width).toBeLessThanOrEqual(
(surface.renderer as CanvasRenderer).canvas.width (surface.renderer as CanvasRenderer).canvas.width
); );
expect(indexedCanvas.height).toBe( expect(indexedCanvas.height).toBeLessThanOrEqual(
(surface.renderer as CanvasRenderer).canvas.height (surface.renderer as CanvasRenderer).canvas.height
); );
expect(indexedCanvas.width).not.toBe(0); expect(indexedCanvas.width).not.toBe(0);
@@ -1,3 +1,5 @@
import { Logger } from '@nestjs/common';
import { CleanedTelemetryEvent, Scalar } from './cleaner'; import { CleanedTelemetryEvent, Scalar } from './cleaner';
const GA4_ENDPOINT = 'https://www.google-analytics.com/mp/collect'; const GA4_ENDPOINT = 'https://www.google-analytics.com/mp/collect';
@@ -14,6 +16,7 @@ type Ga4Payload = {
}; };
export class Ga4Client { export class Ga4Client {
private readonly logger = new Logger(Ga4Client.name);
constructor( constructor(
private readonly measurementId: string, private readonly measurementId: string,
private readonly apiSecret: string, private readonly apiSecret: string,
@@ -45,10 +48,13 @@ export class Ga4Client {
try { try {
await this.post(payload); await this.post(payload);
} catch { } 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 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. // 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:', 'Failed to send telemetry event to GA4:',
chunk.map(e => e.eventName).join(', ') chunk.map(e => e.eventName).join(', ')
); );
@@ -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<void>
) => {
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',
});
});
});
+231
View File
@@ -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<string, unknown> | 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<CanvasRendererPerfSnapshot> {
const container = locateEditorContainer(page, editorIndex);
return container.evaluate(container => {
type PerfMemory = {
jsHeapSizeLimit: number;
totalJSHeapSize: number;
usedJSHeapSize: number;
};
type PerfRenderer = {
constructor?: { name?: string };
getDebugMetrics?: () => Record<string, unknown>;
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);
}