mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-09 05:05:52 +08:00
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:
@@ -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];
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user