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

View File

@@ -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

View File

@@ -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<SurfaceElementModel | GfxLocalElementModel>;
};
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<Overlay>();
@@ -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<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;
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<ElementRenderer>(
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' });
}
}

View File

@@ -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();
})
);

View File

@@ -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),
});
}
);

View File

@@ -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
),
});
}
);

View File

@@ -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';
}

View File

@@ -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<HTMLElement, RetainedConnectorDom>();
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);
};
/**

View File

@@ -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<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(
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);

View File

@@ -177,6 +177,11 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorEle
override getNearestPoint(point: IVec): IVec {
const { mode, absolutePath: path } = this;
if (path.length === 0) {
const { x, y } = this;
return [x, y];
}
if (mode === ConnectorMode.Straight) {
const first = path[0];
const last = path[path.length - 1];
@@ -213,6 +218,10 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorEle
h = bounds.h;
}
if (path.length === 0) {
return 0.5;
}
point[0] = Vec.clamp(point[0], x, x + w);
point[1] = Vec.clamp(point[1], y, y + h);
@@ -258,6 +267,10 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorEle
h = bounds.h;
}
if (path.length === 0) {
return [x + w / 2, y + h / 2];
}
if (mode === ConnectorMode.Orthogonal) {
const points = path.map<IVec>(p => [p[0], p[1]]);
const point = Polyline.pointAt(points, offsetDistance);
@@ -300,6 +313,10 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorEle
const { mode, strokeWidth, absolutePath: path } = this;
if (path.length === 0) {
return false;
}
const point =
mode === ConnectorMode.Curve
? getBezierNearestPoint(getBezierParameters(path), currentPoint)

View File

@@ -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]);
});
});

View File

@@ -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];

View File

@@ -596,7 +596,7 @@ export class LayerManager extends GfxExtension {
private _updateLayer(
element: GfxModel | GfxLocalElementModel,
props?: Record<string, unknown>,
oldValues?: Record<string, unknown>
_oldValues?: Record<string, unknown>
) {
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;
}

View File

@@ -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);
})
);
}

View File

@@ -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<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', () => {
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);

View File

@@ -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(', ')
);

View File

@@ -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',
});
});
});

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);
}