feat: improve grouping perf in edgeless (#14442)
fix #14433 #### PR Dependency Tree * **PR #14442** 👈 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** * Level-of-detail thumbnails for large images. * Adaptive pacing for snapping, distribution and other alignment work. * RAF coalescer utility to batch high-frequency updates. * Operation timing utility to measure synchronous work. * **Improvements** * Batch group/ungroup reparenting that preserves element order and selection. * Coalesced panning and drag updates to reduce jitter. * Connector/group indexing for more reliable updates, deletions and sync. * Throttled viewport refresh behavior. * **Documentation** * Docs added for RAF coalescer and measureOperation. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -26,6 +26,11 @@ import {
|
||||
|
||||
@Peekable()
|
||||
export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockModel> {
|
||||
private static readonly LOD_MIN_IMAGE_BYTES = 1024 * 1024;
|
||||
private static readonly LOD_MIN_IMAGE_PIXELS = 1920 * 1080;
|
||||
private static readonly LOD_MAX_ZOOM = 0.4;
|
||||
private static readonly LOD_THUMBNAIL_MAX_EDGE = 256;
|
||||
|
||||
static override styles = css`
|
||||
affine-edgeless-image {
|
||||
position: relative;
|
||||
@@ -63,6 +68,11 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
affine-edgeless-image .resizable-img {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
|
||||
resourceController = new ResourceController(
|
||||
@@ -70,6 +80,12 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
|
||||
'Image'
|
||||
);
|
||||
|
||||
private _lodThumbnailUrl: string | null = null;
|
||||
private _lodSourceUrl: string | null = null;
|
||||
private _lodGeneratingSourceUrl: string | null = null;
|
||||
private _lodGenerationToken = 0;
|
||||
private _lastShouldUseLod = false;
|
||||
|
||||
get blobUrl() {
|
||||
return this.resourceController.blobUrl$.value;
|
||||
}
|
||||
@@ -96,6 +112,134 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
|
||||
});
|
||||
}
|
||||
|
||||
private _isLargeImage() {
|
||||
const { width = 0, height = 0, size = 0 } = this.model.props;
|
||||
const pixels = width * height;
|
||||
return (
|
||||
size >= ImageEdgelessBlockComponent.LOD_MIN_IMAGE_BYTES ||
|
||||
pixels >= ImageEdgelessBlockComponent.LOD_MIN_IMAGE_PIXELS
|
||||
);
|
||||
}
|
||||
|
||||
private _shouldUseLod(blobUrl: string | null, zoom = this.gfx.viewport.zoom) {
|
||||
return (
|
||||
Boolean(blobUrl) &&
|
||||
this._isLargeImage() &&
|
||||
zoom <= ImageEdgelessBlockComponent.LOD_MAX_ZOOM
|
||||
);
|
||||
}
|
||||
|
||||
private _revokeLodThumbnail() {
|
||||
if (!this._lodThumbnailUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(this._lodThumbnailUrl);
|
||||
this._lodThumbnailUrl = null;
|
||||
}
|
||||
|
||||
private _resetLodSource(blobUrl: string | null) {
|
||||
if (this._lodSourceUrl === blobUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._lodGenerationToken += 1;
|
||||
this._lodGeneratingSourceUrl = null;
|
||||
this._lodSourceUrl = blobUrl;
|
||||
this._revokeLodThumbnail();
|
||||
}
|
||||
|
||||
private _createImageElement(src: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.decoding = 'async';
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('Failed to load image'));
|
||||
image.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
private _createThumbnailBlob(image: HTMLImageElement) {
|
||||
const maxEdge = ImageEdgelessBlockComponent.LOD_THUMBNAIL_MAX_EDGE;
|
||||
const longestEdge = Math.max(image.naturalWidth, image.naturalHeight);
|
||||
const scale = longestEdge > maxEdge ? maxEdge / longestEdge : 1;
|
||||
const targetWidth = Math.max(1, Math.round(image.naturalWidth * scale));
|
||||
const targetHeight = Math.max(1, Math.round(image.naturalHeight * scale));
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return Promise.resolve<Blob | null>(null);
|
||||
}
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'low';
|
||||
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
|
||||
|
||||
return new Promise<Blob | null>(resolve => {
|
||||
canvas.toBlob(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
private _ensureLodThumbnail(blobUrl: string) {
|
||||
if (
|
||||
this._lodThumbnailUrl ||
|
||||
this._lodGeneratingSourceUrl === blobUrl ||
|
||||
!this._shouldUseLod(blobUrl)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = ++this._lodGenerationToken;
|
||||
this._lodGeneratingSourceUrl = blobUrl;
|
||||
|
||||
void this._createImageElement(blobUrl)
|
||||
.then(image => this._createThumbnailBlob(image))
|
||||
.then(blob => {
|
||||
if (!blob || token !== this._lodGenerationToken || !this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const thumbnailUrl = URL.createObjectURL(blob);
|
||||
if (token !== this._lodGenerationToken || !this.isConnected) {
|
||||
URL.revokeObjectURL(thumbnailUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
this._revokeLodThumbnail();
|
||||
this._lodThumbnailUrl = thumbnailUrl;
|
||||
|
||||
if (this._shouldUseLod(this.blobUrl)) {
|
||||
this.requestUpdate();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
if (token !== this._lodGenerationToken || !this.isConnected) {
|
||||
return;
|
||||
}
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
if (token === this._lodGenerationToken) {
|
||||
this._lodGeneratingSourceUrl = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _updateLodFromViewport(zoom: number) {
|
||||
const shouldUseLod = this._shouldUseLod(this.blobUrl, zoom);
|
||||
if (shouldUseLod === this._lastShouldUseLod) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._lastShouldUseLod = shouldUseLod;
|
||||
if (shouldUseLod && this.blobUrl) {
|
||||
this._ensureLodThumbnail(this.blobUrl);
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
@@ -108,14 +252,32 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
|
||||
|
||||
this.disposables.add(
|
||||
this.model.props.sourceId$.subscribe(() => {
|
||||
this._resetLodSource(null);
|
||||
this.refreshData();
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(
|
||||
this.gfx.viewport.viewportUpdated.subscribe(({ zoom }) => {
|
||||
this._updateLodFromViewport(zoom);
|
||||
})
|
||||
);
|
||||
|
||||
this._lastShouldUseLod = this._shouldUseLod(this.blobUrl);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
this._lodGenerationToken += 1;
|
||||
this._lodGeneratingSourceUrl = null;
|
||||
this._lodSourceUrl = null;
|
||||
this._revokeLodThumbnail();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override renderGfxBlock() {
|
||||
const blobUrl = this.blobUrl;
|
||||
const { rotate = 0, size = 0, caption = 'Image' } = this.model.props;
|
||||
this._resetLodSource(blobUrl);
|
||||
|
||||
const containerStyleMap = styleMap({
|
||||
display: 'flex',
|
||||
@@ -138,6 +300,13 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
|
||||
});
|
||||
|
||||
const { loading, icon, description, error, needUpload } = resovledState;
|
||||
const shouldUseLod = this._shouldUseLod(blobUrl);
|
||||
if (shouldUseLod && blobUrl) {
|
||||
this._ensureLodThumbnail(blobUrl);
|
||||
}
|
||||
this._lastShouldUseLod = shouldUseLod;
|
||||
const imageUrl =
|
||||
shouldUseLod && this._lodThumbnailUrl ? this._lodThumbnailUrl : blobUrl;
|
||||
|
||||
return html`
|
||||
<div class="affine-image-container" style=${containerStyleMap}>
|
||||
@@ -149,7 +318,7 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
|
||||
class="drag-target"
|
||||
draggable="false"
|
||||
loading="lazy"
|
||||
src=${blobUrl}
|
||||
src=${imageUrl ?? ''}
|
||||
alt=${caption}
|
||||
@error=${this._handleError}
|
||||
/>
|
||||
|
||||
@@ -33,7 +33,11 @@ import {
|
||||
ReleaseFromGroupIcon,
|
||||
UnlockIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import type { GfxModel } from '@blocksuite/std/gfx';
|
||||
import {
|
||||
batchAddChildren,
|
||||
batchRemoveChildren,
|
||||
type GfxModel,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { html } from 'lit';
|
||||
|
||||
import { renderAlignmentMenu } from './alignment';
|
||||
@@ -61,14 +65,13 @@ export const builtinMiscToolbarConfig = {
|
||||
|
||||
const group = firstModel.group;
|
||||
|
||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
||||
group.removeChild(firstModel);
|
||||
batchRemoveChildren(group, [firstModel]);
|
||||
|
||||
firstModel.index = ctx.gfx.layer.generateIndex();
|
||||
|
||||
const parent = group.group;
|
||||
if (parent && parent instanceof GroupElementModel) {
|
||||
parent.addChild(firstModel);
|
||||
batchAddChildren(parent, [firstModel]);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -255,9 +258,12 @@ export const builtinMiscToolbarConfig = {
|
||||
|
||||
// release other elements from their groups and group with top element
|
||||
otherElements.forEach(element => {
|
||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
||||
element.group?.removeChild(element);
|
||||
topElement.group?.addChild(element);
|
||||
if (element.group) {
|
||||
batchRemoveChildren(element.group, [element]);
|
||||
}
|
||||
if (topElement.group) {
|
||||
batchAddChildren(topElement.group, [element]);
|
||||
}
|
||||
});
|
||||
|
||||
if (otherElements.length === 0) {
|
||||
|
||||
@@ -40,10 +40,146 @@ export const SurfaceBlockSchemaExtension =
|
||||
|
||||
export class SurfaceBlockModel extends BaseSurfaceModel {
|
||||
private readonly _disposables: DisposableGroup = new DisposableGroup();
|
||||
private readonly _connectorIdsByEndpoint = new Map<string, Set<string>>();
|
||||
private readonly _connectorIndexDisposables = new DisposableGroup();
|
||||
private readonly _connectorEndpoints = new Map<
|
||||
string,
|
||||
{ sourceId: string | null; targetId: string | null }
|
||||
>();
|
||||
|
||||
private _addConnectorEndpoint(endpointId: string, connectorId: string) {
|
||||
const connectorIds = this._connectorIdsByEndpoint.get(endpointId);
|
||||
|
||||
if (connectorIds) {
|
||||
connectorIds.add(connectorId);
|
||||
return;
|
||||
}
|
||||
|
||||
this._connectorIdsByEndpoint.set(endpointId, new Set([connectorId]));
|
||||
}
|
||||
|
||||
private _isConnectorModel(model: unknown): model is ConnectorElementModel {
|
||||
return (
|
||||
!!model &&
|
||||
typeof model === 'object' &&
|
||||
'type' in model &&
|
||||
(model as { type?: string }).type === 'connector'
|
||||
);
|
||||
}
|
||||
|
||||
private _removeConnectorEndpoint(endpointId: string, connectorId: string) {
|
||||
const connectorIds = this._connectorIdsByEndpoint.get(endpointId);
|
||||
|
||||
if (!connectorIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
connectorIds.delete(connectorId);
|
||||
|
||||
if (connectorIds.size === 0) {
|
||||
this._connectorIdsByEndpoint.delete(endpointId);
|
||||
}
|
||||
}
|
||||
|
||||
private _removeConnectorFromIndex(connectorId: string) {
|
||||
const endpoints = this._connectorEndpoints.get(connectorId);
|
||||
|
||||
if (!endpoints) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (endpoints.sourceId) {
|
||||
this._removeConnectorEndpoint(endpoints.sourceId, connectorId);
|
||||
}
|
||||
|
||||
if (endpoints.targetId) {
|
||||
this._removeConnectorEndpoint(endpoints.targetId, connectorId);
|
||||
}
|
||||
|
||||
this._connectorEndpoints.delete(connectorId);
|
||||
}
|
||||
|
||||
private _rebuildConnectorIndex() {
|
||||
this._connectorIdsByEndpoint.clear();
|
||||
this._connectorEndpoints.clear();
|
||||
|
||||
this.getElementsByType('connector').forEach(connector => {
|
||||
this._setConnectorEndpoints(connector as ConnectorElementModel);
|
||||
});
|
||||
}
|
||||
|
||||
private _setConnectorEndpoints(connector: ConnectorElementModel) {
|
||||
const sourceId = connector.source?.id ?? null;
|
||||
const targetId = connector.target?.id ?? null;
|
||||
const previousEndpoints = this._connectorEndpoints.get(connector.id);
|
||||
|
||||
if (
|
||||
previousEndpoints?.sourceId === sourceId &&
|
||||
previousEndpoints?.targetId === targetId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousEndpoints?.sourceId) {
|
||||
this._removeConnectorEndpoint(previousEndpoints.sourceId, connector.id);
|
||||
}
|
||||
|
||||
if (previousEndpoints?.targetId) {
|
||||
this._removeConnectorEndpoint(previousEndpoints.targetId, connector.id);
|
||||
}
|
||||
|
||||
if (sourceId) {
|
||||
this._addConnectorEndpoint(sourceId, connector.id);
|
||||
}
|
||||
|
||||
if (targetId) {
|
||||
this._addConnectorEndpoint(targetId, connector.id);
|
||||
}
|
||||
|
||||
this._connectorEndpoints.set(connector.id, {
|
||||
sourceId,
|
||||
targetId,
|
||||
});
|
||||
}
|
||||
|
||||
override _init() {
|
||||
this._extendElement(elementsCtorMap);
|
||||
super._init();
|
||||
this._rebuildConnectorIndex();
|
||||
this._connectorIndexDisposables.add(
|
||||
this.elementAdded.subscribe(({ id }) => {
|
||||
const model = this.getElementById(id);
|
||||
|
||||
if (this._isConnectorModel(model)) {
|
||||
this._setConnectorEndpoints(model);
|
||||
}
|
||||
})
|
||||
);
|
||||
this._connectorIndexDisposables.add(
|
||||
this.elementUpdated.subscribe(({ id, props }) => {
|
||||
if (!props['source'] && !props['target']) {
|
||||
return;
|
||||
}
|
||||
|
||||
const model = this.getElementById(id);
|
||||
|
||||
if (this._isConnectorModel(model)) {
|
||||
this._setConnectorEndpoints(model);
|
||||
}
|
||||
})
|
||||
);
|
||||
this._connectorIndexDisposables.add(
|
||||
this.elementRemoved.subscribe(({ id, type }) => {
|
||||
if (type === 'connector') {
|
||||
this._removeConnectorFromIndex(id);
|
||||
}
|
||||
})
|
||||
);
|
||||
this.deleted.subscribe(() => {
|
||||
this._connectorIndexDisposables.dispose();
|
||||
this._connectorIdsByEndpoint.clear();
|
||||
this._connectorEndpoints.clear();
|
||||
});
|
||||
this.store.provider
|
||||
.getAll(surfaceMiddlewareIdentifier)
|
||||
.forEach(({ middleware }) => {
|
||||
@@ -52,13 +188,31 @@ export class SurfaceBlockModel extends BaseSurfaceModel {
|
||||
}
|
||||
|
||||
getConnectors(id: string) {
|
||||
const connectors = this.getElementsByType(
|
||||
'connector'
|
||||
) as unknown[] as ConnectorElementModel[];
|
||||
const connectorIds = this._connectorIdsByEndpoint.get(id);
|
||||
|
||||
return connectors.filter(
|
||||
connector => connector.source?.id === id || connector.target?.id === id
|
||||
);
|
||||
if (!connectorIds?.size) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const staleConnectorIds: string[] = [];
|
||||
const connectors: ConnectorElementModel[] = [];
|
||||
|
||||
connectorIds.forEach(connectorId => {
|
||||
const model = this.getElementById(connectorId);
|
||||
|
||||
if (!this._isConnectorModel(model)) {
|
||||
staleConnectorIds.push(connectorId);
|
||||
return;
|
||||
}
|
||||
|
||||
connectors.push(model);
|
||||
});
|
||||
|
||||
staleConnectorIds.forEach(connectorId => {
|
||||
this._removeConnectorFromIndex(connectorId);
|
||||
});
|
||||
|
||||
return connectors;
|
||||
}
|
||||
|
||||
override getElementsByType<K extends keyof SurfaceElementModelMap>(
|
||||
|
||||
Reference in New Issue
Block a user