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()
|
@Peekable()
|
||||||
export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockModel> {
|
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`
|
static override styles = css`
|
||||||
affine-edgeless-image {
|
affine-edgeless-image {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -63,6 +68,11 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
affine-edgeless-image .resizable-img {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
resourceController = new ResourceController(
|
resourceController = new ResourceController(
|
||||||
@@ -70,6 +80,12 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
|
|||||||
'Image'
|
'Image'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private _lodThumbnailUrl: string | null = null;
|
||||||
|
private _lodSourceUrl: string | null = null;
|
||||||
|
private _lodGeneratingSourceUrl: string | null = null;
|
||||||
|
private _lodGenerationToken = 0;
|
||||||
|
private _lastShouldUseLod = false;
|
||||||
|
|
||||||
get blobUrl() {
|
get blobUrl() {
|
||||||
return this.resourceController.blobUrl$.value;
|
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() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
|
||||||
@@ -108,14 +252,32 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
|
|||||||
|
|
||||||
this.disposables.add(
|
this.disposables.add(
|
||||||
this.model.props.sourceId$.subscribe(() => {
|
this.model.props.sourceId$.subscribe(() => {
|
||||||
|
this._resetLodSource(null);
|
||||||
this.refreshData();
|
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() {
|
override renderGfxBlock() {
|
||||||
const blobUrl = this.blobUrl;
|
const blobUrl = this.blobUrl;
|
||||||
const { rotate = 0, size = 0, caption = 'Image' } = this.model.props;
|
const { rotate = 0, size = 0, caption = 'Image' } = this.model.props;
|
||||||
|
this._resetLodSource(blobUrl);
|
||||||
|
|
||||||
const containerStyleMap = styleMap({
|
const containerStyleMap = styleMap({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -138,6 +300,13 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { loading, icon, description, error, needUpload } = resovledState;
|
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`
|
return html`
|
||||||
<div class="affine-image-container" style=${containerStyleMap}>
|
<div class="affine-image-container" style=${containerStyleMap}>
|
||||||
@@ -149,7 +318,7 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
|
|||||||
class="drag-target"
|
class="drag-target"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
src=${blobUrl}
|
src=${imageUrl ?? ''}
|
||||||
alt=${caption}
|
alt=${caption}
|
||||||
@error=${this._handleError}
|
@error=${this._handleError}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ import {
|
|||||||
ReleaseFromGroupIcon,
|
ReleaseFromGroupIcon,
|
||||||
UnlockIcon,
|
UnlockIcon,
|
||||||
} from '@blocksuite/icons/lit';
|
} 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 { html } from 'lit';
|
||||||
|
|
||||||
import { renderAlignmentMenu } from './alignment';
|
import { renderAlignmentMenu } from './alignment';
|
||||||
@@ -61,14 +65,13 @@ export const builtinMiscToolbarConfig = {
|
|||||||
|
|
||||||
const group = firstModel.group;
|
const group = firstModel.group;
|
||||||
|
|
||||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
batchRemoveChildren(group, [firstModel]);
|
||||||
group.removeChild(firstModel);
|
|
||||||
|
|
||||||
firstModel.index = ctx.gfx.layer.generateIndex();
|
firstModel.index = ctx.gfx.layer.generateIndex();
|
||||||
|
|
||||||
const parent = group.group;
|
const parent = group.group;
|
||||||
if (parent && parent instanceof GroupElementModel) {
|
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
|
// release other elements from their groups and group with top element
|
||||||
otherElements.forEach(element => {
|
otherElements.forEach(element => {
|
||||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
if (element.group) {
|
||||||
element.group?.removeChild(element);
|
batchRemoveChildren(element.group, [element]);
|
||||||
topElement.group?.addChild(element);
|
}
|
||||||
|
if (topElement.group) {
|
||||||
|
batchAddChildren(topElement.group, [element]);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (otherElements.length === 0) {
|
if (otherElements.length === 0) {
|
||||||
|
|||||||
@@ -40,10 +40,146 @@ export const SurfaceBlockSchemaExtension =
|
|||||||
|
|
||||||
export class SurfaceBlockModel extends BaseSurfaceModel {
|
export class SurfaceBlockModel extends BaseSurfaceModel {
|
||||||
private readonly _disposables: DisposableGroup = new DisposableGroup();
|
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() {
|
override _init() {
|
||||||
this._extendElement(elementsCtorMap);
|
this._extendElement(elementsCtorMap);
|
||||||
super._init();
|
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
|
this.store.provider
|
||||||
.getAll(surfaceMiddlewareIdentifier)
|
.getAll(surfaceMiddlewareIdentifier)
|
||||||
.forEach(({ middleware }) => {
|
.forEach(({ middleware }) => {
|
||||||
@@ -52,13 +188,31 @@ export class SurfaceBlockModel extends BaseSurfaceModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getConnectors(id: string) {
|
getConnectors(id: string) {
|
||||||
const connectors = this.getElementsByType(
|
const connectorIds = this._connectorIdsByEndpoint.get(id);
|
||||||
'connector'
|
|
||||||
) as unknown[] as ConnectorElementModel[];
|
|
||||||
|
|
||||||
return connectors.filter(
|
if (!connectorIds?.size) {
|
||||||
connector => connector.source?.id === id || connector.target?.id === id
|
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>(
|
override getElementsByType<K extends keyof SurfaceElementModelMap>(
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ export const connectorWatcher: SurfaceMiddleware = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
pendingFlag = false;
|
||||||
|
pendingList.clear();
|
||||||
disposables.forEach(d => d.unsubscribe());
|
disposables.forEach(d => d.unsubscribe());
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"@preact/signals-core": "^1.8.0",
|
"@preact/signals-core": "^1.8.0",
|
||||||
"@toeverything/theme": "^1.1.23",
|
"@toeverything/theme": "^1.1.23",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
"fractional-indexing": "^3.2.0",
|
||||||
"lit": "^3.2.0",
|
"lit": "^3.2.0",
|
||||||
"lodash-es": "^4.17.23",
|
"lodash-es": "^4.17.23",
|
||||||
"minimatch": "^10.1.1",
|
"minimatch": "^10.1.1",
|
||||||
@@ -33,6 +34,9 @@
|
|||||||
"yjs": "^13.6.27",
|
"yjs": "^13.6.27",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./view": "./src/view.ts",
|
"./view": "./src/view.ts",
|
||||||
|
|||||||
152
blocksuite/affine/gfx/group/src/__tests__/group-api.unit.spec.ts
Normal file
152
blocksuite/affine/gfx/group/src/__tests__/group-api.unit.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('fractional-indexing', () => ({
|
||||||
|
generateKeyBetween: vi.fn(),
|
||||||
|
generateNKeysBetween: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
|
||||||
|
|
||||||
|
import { ungroupCommand } from '../command/group-api.js';
|
||||||
|
|
||||||
|
type TestElement = {
|
||||||
|
id: string;
|
||||||
|
index: string;
|
||||||
|
group: TestElement | null;
|
||||||
|
childElements: TestElement[];
|
||||||
|
removeChildren?: (elements: TestElement[]) => void;
|
||||||
|
addChildren?: (elements: TestElement[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockedGenerateNKeysBetween = vi.mocked(generateNKeysBetween);
|
||||||
|
const mockedGenerateKeyBetween = vi.mocked(generateKeyBetween);
|
||||||
|
|
||||||
|
const createElement = (
|
||||||
|
id: string,
|
||||||
|
index: string,
|
||||||
|
group: TestElement | null
|
||||||
|
): TestElement => ({
|
||||||
|
id,
|
||||||
|
index,
|
||||||
|
group,
|
||||||
|
childElements: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createUngroupFixture = () => {
|
||||||
|
const parent = createElement('parent', 'p0', null);
|
||||||
|
const left = createElement('left', 'a0', parent);
|
||||||
|
const right = createElement('right', 'a0', parent);
|
||||||
|
const group = createElement('group', 'm0', parent);
|
||||||
|
const childA = createElement('child-a', 'c0', group);
|
||||||
|
const childB = createElement('child-b', 'c1', group);
|
||||||
|
|
||||||
|
group.childElements = [childB, childA];
|
||||||
|
parent.childElements = [left, group, right];
|
||||||
|
|
||||||
|
parent.removeChildren = vi.fn();
|
||||||
|
parent.addChildren = vi.fn();
|
||||||
|
group.removeChildren = vi.fn();
|
||||||
|
|
||||||
|
const elementOrder = new Map<TestElement, number>([
|
||||||
|
[left, 0],
|
||||||
|
[group, 1],
|
||||||
|
[right, 2],
|
||||||
|
[childA, 3],
|
||||||
|
[childB, 4],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const selectionSet = vi.fn();
|
||||||
|
const gfx = {
|
||||||
|
layer: {
|
||||||
|
compare: (a: TestElement, b: TestElement) =>
|
||||||
|
(elementOrder.get(a) ?? 0) - (elementOrder.get(b) ?? 0),
|
||||||
|
},
|
||||||
|
selection: {
|
||||||
|
set: selectionSet,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const std = {
|
||||||
|
get: vi.fn(() => gfx),
|
||||||
|
store: {
|
||||||
|
transact: (callback: () => void) => callback(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
childA,
|
||||||
|
childB,
|
||||||
|
group,
|
||||||
|
parent,
|
||||||
|
selectionSet,
|
||||||
|
std,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ungroupCommand', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedGenerateNKeysBetween.mockReset();
|
||||||
|
mockedGenerateKeyBetween.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to open-ended key generation when sibling interval is invalid', () => {
|
||||||
|
const fixture = createUngroupFixture();
|
||||||
|
mockedGenerateNKeysBetween
|
||||||
|
.mockImplementationOnce(() => {
|
||||||
|
throw new Error('interval reversed');
|
||||||
|
})
|
||||||
|
.mockReturnValueOnce(['n0', 'n1']);
|
||||||
|
|
||||||
|
const next = vi.fn();
|
||||||
|
ungroupCommand(
|
||||||
|
{
|
||||||
|
std: fixture.std,
|
||||||
|
group: fixture.group as any,
|
||||||
|
} as any,
|
||||||
|
next
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockedGenerateNKeysBetween).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
'a0',
|
||||||
|
'a0',
|
||||||
|
2
|
||||||
|
);
|
||||||
|
expect(mockedGenerateNKeysBetween).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'a0',
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
expect(fixture.childA.index).toBe('n0');
|
||||||
|
expect(fixture.childB.index).toBe('n1');
|
||||||
|
expect(fixture.selectionSet).toHaveBeenCalledWith({
|
||||||
|
editing: false,
|
||||||
|
elements: ['child-a', 'child-b'],
|
||||||
|
});
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to key-by-key generation when all batched strategies fail', () => {
|
||||||
|
const fixture = createUngroupFixture();
|
||||||
|
mockedGenerateNKeysBetween.mockImplementation(() => {
|
||||||
|
throw new Error('invalid range');
|
||||||
|
});
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
mockedGenerateKeyBetween.mockImplementation(() => `k${seq++}`);
|
||||||
|
|
||||||
|
ungroupCommand(
|
||||||
|
{
|
||||||
|
std: fixture.std,
|
||||||
|
group: fixture.group as any,
|
||||||
|
} as any,
|
||||||
|
vi.fn()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockedGenerateNKeysBetween).toHaveBeenCalledTimes(4);
|
||||||
|
expect(mockedGenerateKeyBetween).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fixture.childA.index).toBe('k0');
|
||||||
|
expect(fixture.childB.index).toBe('k1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,80 @@ import {
|
|||||||
MindmapElementModel,
|
MindmapElementModel,
|
||||||
} from '@blocksuite/affine-model';
|
} from '@blocksuite/affine-model';
|
||||||
import type { Command } from '@blocksuite/std';
|
import type { Command } from '@blocksuite/std';
|
||||||
import { GfxControllerIdentifier, type GfxModel } from '@blocksuite/std/gfx';
|
import {
|
||||||
|
batchAddChildren,
|
||||||
|
batchRemoveChildren,
|
||||||
|
type GfxController,
|
||||||
|
GfxControllerIdentifier,
|
||||||
|
type GfxModel,
|
||||||
|
measureOperation,
|
||||||
|
} from '@blocksuite/std/gfx';
|
||||||
|
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
|
||||||
|
|
||||||
|
const getTopLevelOrderedElements = (gfx: GfxController) => {
|
||||||
|
const topLevelElements = gfx.layer.layers.reduce<GfxModel[]>(
|
||||||
|
(elements, layer) => {
|
||||||
|
layer.elements.forEach(element => {
|
||||||
|
if (element.group === null) {
|
||||||
|
elements.push(element as GfxModel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
topLevelElements.sort((a, b) => gfx.layer.compare(a, b));
|
||||||
|
return topLevelElements;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildUngroupIndexes = (
|
||||||
|
orderedElements: GfxModel[],
|
||||||
|
afterIndex: string | null,
|
||||||
|
beforeIndex: string | null,
|
||||||
|
fallbackAnchorIndex: string
|
||||||
|
) => {
|
||||||
|
if (orderedElements.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = orderedElements.length;
|
||||||
|
const tryGenerateN = (left: string | null, right: string | null) => {
|
||||||
|
try {
|
||||||
|
const generated = generateNKeysBetween(left, right, count);
|
||||||
|
return generated.length === count ? generated : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryGenerateOneByOne = (left: string | null, right: string | null) => {
|
||||||
|
try {
|
||||||
|
let cursor = left;
|
||||||
|
return orderedElements.map(() => {
|
||||||
|
cursor = generateKeyBetween(cursor, right);
|
||||||
|
return cursor;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Preferred: keep ungrouped children in the original group slot.
|
||||||
|
return (
|
||||||
|
tryGenerateN(afterIndex, beforeIndex) ??
|
||||||
|
// Fallback: ignore the upper bound when legacy/broken data has reversed interval.
|
||||||
|
tryGenerateN(afterIndex, null) ??
|
||||||
|
// Fallback: use group index as anchor when sibling interval is unavailable.
|
||||||
|
tryGenerateN(fallbackAnchorIndex, null) ??
|
||||||
|
// Last resort: always valid.
|
||||||
|
tryGenerateN(null, null) ??
|
||||||
|
// Defensive fallback for unexpected library behavior.
|
||||||
|
tryGenerateOneByOne(null, null) ??
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const createGroupCommand: Command<
|
export const createGroupCommand: Command<
|
||||||
{ elements: GfxModel[] | string[] },
|
{ elements: GfxModel[] | string[] },
|
||||||
@@ -39,6 +112,7 @@ export const createGroupFromSelectedCommand: Command<
|
|||||||
{},
|
{},
|
||||||
{ groupId: string }
|
{ groupId: string }
|
||||||
> = (ctx, next) => {
|
> = (ctx, next) => {
|
||||||
|
measureOperation('edgeless:create-group-from-selected', () => {
|
||||||
const { std } = ctx;
|
const { std } = ctx;
|
||||||
const gfx = std.get(GfxControllerIdentifier);
|
const gfx = std.get(GfxControllerIdentifier);
|
||||||
const { selection, surface } = gfx;
|
const { selection, surface } = gfx;
|
||||||
@@ -58,77 +132,98 @@ export const createGroupFromSelectedCommand: Command<
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parent = selection.firstElement.group as GroupElementModel;
|
const parent = selection.firstElement.group;
|
||||||
|
let groupId: string | undefined;
|
||||||
if (parent !== null) {
|
std.store.transact(() => {
|
||||||
selection.selectedElements.forEach(element => {
|
|
||||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
|
||||||
parent.removeChild(element);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const [_, result] = std.command.exec(createGroupCommand, {
|
const [_, result] = std.command.exec(createGroupCommand, {
|
||||||
elements: selection.selectedElements,
|
elements: selection.selectedElements,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.groupId) {
|
if (!result.groupId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const group = surface.getElementById(result.groupId);
|
|
||||||
|
groupId = result.groupId;
|
||||||
|
const group = surface.getElementById(groupId);
|
||||||
|
|
||||||
if (parent !== null && group) {
|
if (parent !== null && group) {
|
||||||
parent.addChild(group);
|
batchRemoveChildren(parent, selection.selectedElements);
|
||||||
|
batchAddChildren(parent, [group]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!groupId) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
selection.set({
|
selection.set({
|
||||||
editing: false,
|
editing: false,
|
||||||
elements: [result.groupId],
|
elements: [groupId],
|
||||||
});
|
});
|
||||||
|
|
||||||
next({ groupId: result.groupId });
|
next({ groupId });
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ungroupCommand: Command<{ group: GroupElementModel }, {}> = (
|
export const ungroupCommand: Command<{ group: GroupElementModel }, {}> = (
|
||||||
ctx,
|
ctx,
|
||||||
next
|
next
|
||||||
) => {
|
) => {
|
||||||
|
measureOperation('edgeless:ungroup', () => {
|
||||||
const { std, group } = ctx;
|
const { std, group } = ctx;
|
||||||
const gfx = std.get(GfxControllerIdentifier);
|
const gfx = std.get(GfxControllerIdentifier);
|
||||||
const { selection } = gfx;
|
const { selection } = gfx;
|
||||||
const parent = group.group as GroupElementModel;
|
const parent = group.group;
|
||||||
const elements = group.childElements;
|
const elements = [...group.childElements];
|
||||||
|
|
||||||
if (group instanceof MindmapElementModel) {
|
if (group instanceof MindmapElementModel) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const orderedElements = [...elements].sort((a, b) =>
|
||||||
|
gfx.layer.compare(a, b)
|
||||||
|
);
|
||||||
|
const siblings = parent
|
||||||
|
? [...parent.childElements].sort((a, b) => gfx.layer.compare(a, b))
|
||||||
|
: getTopLevelOrderedElements(gfx);
|
||||||
|
const groupPosition = siblings.indexOf(group);
|
||||||
|
const beforeSiblingIndex =
|
||||||
|
groupPosition > 0 ? (siblings[groupPosition - 1]?.index ?? null) : null;
|
||||||
|
const afterSiblingIndex =
|
||||||
|
groupPosition === -1
|
||||||
|
? null
|
||||||
|
: (siblings[groupPosition + 1]?.index ?? null);
|
||||||
|
const nextIndexes = buildUngroupIndexes(
|
||||||
|
orderedElements,
|
||||||
|
beforeSiblingIndex,
|
||||||
|
afterSiblingIndex,
|
||||||
|
group.index
|
||||||
|
);
|
||||||
|
|
||||||
|
std.store.transact(() => {
|
||||||
if (parent !== null) {
|
if (parent !== null) {
|
||||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
batchRemoveChildren(parent, [group]);
|
||||||
parent.removeChild(group);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
elements.forEach(element => {
|
batchRemoveChildren(group, elements);
|
||||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
|
||||||
group.removeChild(element);
|
|
||||||
});
|
|
||||||
|
|
||||||
// keep relative index order of group children after ungroup
|
// keep relative index order of group children after ungroup
|
||||||
elements
|
orderedElements.forEach((element, idx) => {
|
||||||
.sort((a, b) => gfx.layer.compare(a, b))
|
const index = nextIndexes[idx];
|
||||||
.forEach(element => {
|
if (element.index !== index) {
|
||||||
std.store.transact(() => {
|
element.index = index;
|
||||||
element.index = gfx.layer.generateIndex();
|
}
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (parent !== null) {
|
if (parent !== null) {
|
||||||
elements.forEach(element => {
|
batchAddChildren(parent, orderedElements);
|
||||||
parent.addChild(element);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
selection.set({
|
selection.set({
|
||||||
editing: false,
|
editing: false,
|
||||||
elements: elements.map(ele => ele.id),
|
elements: orderedElements.map(ele => ele.id),
|
||||||
});
|
});
|
||||||
next();
|
next();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
25
blocksuite/affine/gfx/group/vitest.config.ts
Normal file
25
blocksuite/affine/gfx/group/vitest.config.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
esbuild: {
|
||||||
|
target: 'es2018',
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globalSetup: '../../../scripts/vitest-global.js',
|
||||||
|
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||||
|
testTimeout: 1000,
|
||||||
|
coverage: {
|
||||||
|
provider: 'istanbul',
|
||||||
|
reporter: ['lcov'],
|
||||||
|
reportsDirectory: '../../../.coverage/affine-gfx-group',
|
||||||
|
},
|
||||||
|
onConsoleLog(log, type) {
|
||||||
|
if (log.includes('lit.dev/msg/dev-mode')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.warn(`Unexpected ${type} log`, log);
|
||||||
|
throw new Error(log);
|
||||||
|
},
|
||||||
|
environment: 'happy-dom',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -32,6 +32,9 @@
|
|||||||
"yjs": "^13.6.27",
|
"yjs": "^13.6.27",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./view": "./src/view.ts"
|
"./view": "./src/view.ts"
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AdaptiveCooldownController,
|
||||||
|
AdaptiveStrideController,
|
||||||
|
} from '../snap/adaptive-load-controller.js';
|
||||||
|
|
||||||
|
describe('AdaptiveStrideController', () => {
|
||||||
|
test('increases stride under heavy cost and respects maxStride', () => {
|
||||||
|
const controller = new AdaptiveStrideController({
|
||||||
|
heavyCostMs: 6,
|
||||||
|
maxStride: 3,
|
||||||
|
recoveryCostMs: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.reportCost(10);
|
||||||
|
controller.reportCost(12);
|
||||||
|
controller.reportCost(15);
|
||||||
|
|
||||||
|
// stride should be capped at 3, so only every 3rd tick runs.
|
||||||
|
expect(controller.shouldSkip()).toBe(false);
|
||||||
|
expect(controller.shouldSkip()).toBe(true);
|
||||||
|
expect(controller.shouldSkip()).toBe(true);
|
||||||
|
expect(controller.shouldSkip()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decreases stride when cost recovers and reset clears state', () => {
|
||||||
|
const controller = new AdaptiveStrideController({
|
||||||
|
heavyCostMs: 8,
|
||||||
|
maxStride: 4,
|
||||||
|
recoveryCostMs: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.reportCost(12);
|
||||||
|
controller.reportCost(12);
|
||||||
|
controller.reportCost(1);
|
||||||
|
|
||||||
|
// From stride 3 recovered to stride 2: run every other tick.
|
||||||
|
expect(controller.shouldSkip()).toBe(false);
|
||||||
|
expect(controller.shouldSkip()).toBe(true);
|
||||||
|
expect(controller.shouldSkip()).toBe(false);
|
||||||
|
|
||||||
|
controller.reset();
|
||||||
|
expect(controller.shouldSkip()).toBe(false);
|
||||||
|
expect(controller.shouldSkip()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AdaptiveCooldownController', () => {
|
||||||
|
test('enters cooldown when cost exceeds threshold', () => {
|
||||||
|
const controller = new AdaptiveCooldownController({
|
||||||
|
cooldownFrames: 2,
|
||||||
|
maxCostMs: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.reportCost(9);
|
||||||
|
expect(controller.shouldRun()).toBe(false);
|
||||||
|
expect(controller.shouldRun()).toBe(false);
|
||||||
|
expect(controller.shouldRun()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset exits cooldown immediately', () => {
|
||||||
|
const controller = new AdaptiveCooldownController({
|
||||||
|
cooldownFrames: 3,
|
||||||
|
maxCostMs: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.reportCost(6);
|
||||||
|
expect(controller.shouldRun()).toBe(false);
|
||||||
|
controller.reset();
|
||||||
|
expect(controller.shouldRun()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
||||||
|
import { MouseButton } from '@blocksuite/std/gfx';
|
||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { PanTool } from '../tools/pan-tool.js';
|
||||||
|
|
||||||
|
type PointerDownHandler = (event: {
|
||||||
|
raw: {
|
||||||
|
button: number;
|
||||||
|
preventDefault: () => void;
|
||||||
|
};
|
||||||
|
}) => unknown;
|
||||||
|
|
||||||
|
const mockRaf = () => {
|
||||||
|
let callback: FrameRequestCallback | undefined;
|
||||||
|
const requestAnimationFrameMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((cb: FrameRequestCallback) => {
|
||||||
|
callback = cb;
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
const cancelAnimationFrameMock = vi.fn();
|
||||||
|
|
||||||
|
vi.stubGlobal('requestAnimationFrame', requestAnimationFrameMock);
|
||||||
|
vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrameMock);
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCallback: () => callback,
|
||||||
|
requestAnimationFrameMock,
|
||||||
|
cancelAnimationFrameMock,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createToolFixture = (options?: {
|
||||||
|
currentToolName?: string;
|
||||||
|
currentToolOptions?: Record<string, unknown>;
|
||||||
|
}) => {
|
||||||
|
const applyDeltaCenter = vi.fn();
|
||||||
|
const selectionSet = vi.fn();
|
||||||
|
const setTool = vi.fn();
|
||||||
|
const navigatorSettingUpdated = {
|
||||||
|
next: vi.fn(),
|
||||||
|
};
|
||||||
|
const currentToolName = options?.currentToolName;
|
||||||
|
const currentToolOption = {
|
||||||
|
toolType: currentToolName
|
||||||
|
? ({
|
||||||
|
toolName: currentToolName,
|
||||||
|
} as any)
|
||||||
|
: undefined,
|
||||||
|
options: options?.currentToolOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const gfx = {
|
||||||
|
viewport: {
|
||||||
|
zoom: 2,
|
||||||
|
applyDeltaCenter,
|
||||||
|
},
|
||||||
|
selection: {
|
||||||
|
surfaceSelections: [{ elements: ['shape-1'] }],
|
||||||
|
set: selectionSet,
|
||||||
|
},
|
||||||
|
tool: {
|
||||||
|
currentTool$: {
|
||||||
|
peek: () => null,
|
||||||
|
},
|
||||||
|
currentToolOption$: {
|
||||||
|
peek: () => currentToolOption,
|
||||||
|
},
|
||||||
|
setTool,
|
||||||
|
},
|
||||||
|
std: {
|
||||||
|
get: (identifier: unknown) => {
|
||||||
|
if (identifier === EdgelessLegacySlotIdentifier) {
|
||||||
|
return { navigatorSettingUpdated };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
doc: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tool = new PanTool(gfx as any);
|
||||||
|
|
||||||
|
return {
|
||||||
|
applyDeltaCenter,
|
||||||
|
navigatorSettingUpdated,
|
||||||
|
selectionSet,
|
||||||
|
setTool,
|
||||||
|
tool,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PanTool', () => {
|
||||||
|
test('flushes accumulated delta on dragEnd', () => {
|
||||||
|
mockRaf();
|
||||||
|
const { tool, applyDeltaCenter } = createToolFixture();
|
||||||
|
|
||||||
|
tool.dragStart({ x: 100, y: 100 } as any);
|
||||||
|
tool.dragMove({ x: 80, y: 60 } as any);
|
||||||
|
tool.dragMove({ x: 70, y: 40 } as any);
|
||||||
|
|
||||||
|
expect(applyDeltaCenter).not.toHaveBeenCalled();
|
||||||
|
tool.dragEnd({} as any);
|
||||||
|
|
||||||
|
expect(applyDeltaCenter).toHaveBeenCalledTimes(1);
|
||||||
|
expect(applyDeltaCenter).toHaveBeenCalledWith(15, 30);
|
||||||
|
expect(tool.panning$.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancel in unmounted drops pending deltas', () => {
|
||||||
|
mockRaf();
|
||||||
|
const { tool, applyDeltaCenter } = createToolFixture();
|
||||||
|
|
||||||
|
tool.dragStart({ x: 100, y: 100 } as any);
|
||||||
|
tool.dragMove({ x: 80, y: 60 } as any);
|
||||||
|
tool.unmounted();
|
||||||
|
tool.dragEnd({} as any);
|
||||||
|
|
||||||
|
expect(applyDeltaCenter).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('middle click temporary pan restores frameNavigator with restoredAfterPan', () => {
|
||||||
|
const { tool, navigatorSettingUpdated, selectionSet, setTool } =
|
||||||
|
createToolFixture({
|
||||||
|
currentToolName: 'frameNavigator',
|
||||||
|
currentToolOptions: { mode: 'fit' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const hooks: Partial<Record<'pointerDown', PointerDownHandler>> = {};
|
||||||
|
(tool as any).eventTarget = {
|
||||||
|
addHook: (eventName: 'pointerDown', handler: PointerDownHandler) => {
|
||||||
|
hooks[eventName] = handler;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tool.mounted();
|
||||||
|
|
||||||
|
const preventDefault = vi.fn();
|
||||||
|
const pointerDown = hooks.pointerDown!;
|
||||||
|
const ret = pointerDown({
|
||||||
|
raw: {
|
||||||
|
button: MouseButton.MIDDLE,
|
||||||
|
preventDefault,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ret).toBe(false);
|
||||||
|
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||||||
|
expect(navigatorSettingUpdated.next).toHaveBeenCalledWith({
|
||||||
|
blackBackground: false,
|
||||||
|
});
|
||||||
|
expect(setTool).toHaveBeenNthCalledWith(1, PanTool, {
|
||||||
|
panning: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
document.dispatchEvent(
|
||||||
|
new PointerEvent('pointerup', { button: MouseButton.MIDDLE })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(selectionSet).toHaveBeenCalledWith([{ elements: ['shape-1'] }]);
|
||||||
|
expect(setTool).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({
|
||||||
|
toolName: 'frameNavigator',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
mode: 'fit',
|
||||||
|
restoredAfterPan: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
export class AdaptiveStrideController {
|
||||||
|
private _stride = 1;
|
||||||
|
|
||||||
|
private _ticks = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly _options: {
|
||||||
|
heavyCostMs: number;
|
||||||
|
maxStride: number;
|
||||||
|
recoveryCostMs: number;
|
||||||
|
}
|
||||||
|
) {}
|
||||||
|
|
||||||
|
reportCost(costMs: number) {
|
||||||
|
if (costMs > this._options.heavyCostMs) {
|
||||||
|
this._stride = Math.min(this._options.maxStride, this._stride + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (costMs < this._options.recoveryCostMs && this._stride > 1) {
|
||||||
|
this._stride -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this._stride = 1;
|
||||||
|
this._ticks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldSkip() {
|
||||||
|
const shouldSkip = this._stride > 1 && this._ticks % this._stride !== 0;
|
||||||
|
this._ticks += 1;
|
||||||
|
return shouldSkip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdaptiveCooldownController {
|
||||||
|
private _remainingFrames = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly _options: {
|
||||||
|
cooldownFrames: number;
|
||||||
|
maxCostMs: number;
|
||||||
|
}
|
||||||
|
) {}
|
||||||
|
|
||||||
|
reportCost(costMs: number) {
|
||||||
|
if (costMs > this._options.maxCostMs) {
|
||||||
|
this._remainingFrames = this._options.cooldownFrames;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this._remainingFrames = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldRun() {
|
||||||
|
if (this._remainingFrames <= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._remainingFrames -= 1;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,18 @@ import {
|
|||||||
InteractivityExtension,
|
InteractivityExtension,
|
||||||
} from '@blocksuite/std/gfx';
|
} from '@blocksuite/std/gfx';
|
||||||
|
|
||||||
|
import { AdaptiveStrideController } from './adaptive-load-controller';
|
||||||
import type { SnapOverlay } from './snap-overlay';
|
import type { SnapOverlay } from './snap-overlay';
|
||||||
|
|
||||||
export class SnapExtension extends InteractivityExtension {
|
export class SnapExtension extends InteractivityExtension {
|
||||||
static override key = 'snap-manager';
|
static override key = 'snap-manager';
|
||||||
|
|
||||||
|
private static readonly MAX_ALIGN_SKIP_STRIDE = 3;
|
||||||
|
|
||||||
|
private static readonly ALIGN_HEAVY_COST_MS = 5;
|
||||||
|
|
||||||
|
private static readonly ALIGN_RECOVERY_COST_MS = 2;
|
||||||
|
|
||||||
get snapOverlay() {
|
get snapOverlay() {
|
||||||
return this.std.getOptional(
|
return this.std.getOptional(
|
||||||
OverlayIdentifier('snap-manager')
|
OverlayIdentifier('snap-manager')
|
||||||
@@ -29,6 +36,11 @@ export class SnapExtension extends InteractivityExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let alignBound: Bound | null = null;
|
let alignBound: Bound | null = null;
|
||||||
|
const alignStride = new AdaptiveStrideController({
|
||||||
|
heavyCostMs: SnapExtension.ALIGN_HEAVY_COST_MS,
|
||||||
|
maxStride: SnapExtension.MAX_ALIGN_SKIP_STRIDE,
|
||||||
|
recoveryCostMs: SnapExtension.ALIGN_RECOVERY_COST_MS,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onDragStart() {
|
onDragStart() {
|
||||||
@@ -42,6 +54,7 @@ export class SnapExtension extends InteractivityExtension {
|
|||||||
return pre;
|
return pre;
|
||||||
}, [] as GfxModel[])
|
}, [] as GfxModel[])
|
||||||
);
|
);
|
||||||
|
alignStride.reset();
|
||||||
},
|
},
|
||||||
onDragMove(context: ExtensionDragMoveContext) {
|
onDragMove(context: ExtensionDragMoveContext) {
|
||||||
if (
|
if (
|
||||||
@@ -53,14 +66,22 @@ export class SnapExtension extends InteractivityExtension {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (alignStride.shouldSkip()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const currentBound = alignBound.moveDelta(context.dx, context.dy);
|
const currentBound = alignBound.moveDelta(context.dx, context.dy);
|
||||||
|
const alignStart = performance.now();
|
||||||
const alignRst = snapOverlay.align(currentBound);
|
const alignRst = snapOverlay.align(currentBound);
|
||||||
|
const alignCost = performance.now() - alignStart;
|
||||||
|
alignStride.reportCost(alignCost);
|
||||||
|
|
||||||
context.dx = alignRst.dx + context.dx;
|
context.dx = alignRst.dx + context.dx;
|
||||||
context.dy = alignRst.dy + context.dy;
|
context.dy = alignRst.dy + context.dy;
|
||||||
},
|
},
|
||||||
clear() {
|
clear() {
|
||||||
alignBound = null;
|
alignBound = null;
|
||||||
|
alignStride.reset();
|
||||||
snapOverlay.clear();
|
snapOverlay.clear();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
import { almostEqual, Bound, type IVec, Point } from '@blocksuite/global/gfx';
|
import { almostEqual, Bound, type IVec, Point } from '@blocksuite/global/gfx';
|
||||||
import type { GfxModel } from '@blocksuite/std/gfx';
|
import type { GfxModel } from '@blocksuite/std/gfx';
|
||||||
|
|
||||||
|
import { AdaptiveCooldownController } from './adaptive-load-controller';
|
||||||
|
|
||||||
interface Distance {
|
interface Distance {
|
||||||
horiz?: {
|
horiz?: {
|
||||||
/**
|
/**
|
||||||
@@ -35,6 +37,9 @@ interface Distance {
|
|||||||
const ALIGN_THRESHOLD = 8;
|
const ALIGN_THRESHOLD = 8;
|
||||||
const DISTRIBUTION_LINE_OFFSET = 1;
|
const DISTRIBUTION_LINE_OFFSET = 1;
|
||||||
const STROKE_WIDTH = 2;
|
const STROKE_WIDTH = 2;
|
||||||
|
const DISTRIBUTE_ALIGN_MAX_CANDIDATES = 160;
|
||||||
|
const DISTRIBUTE_ALIGN_MAX_COST_MS = 5;
|
||||||
|
const DISTRIBUTE_ALIGN_COOLDOWN_FRAMES = 2;
|
||||||
|
|
||||||
export class SnapOverlay extends Overlay {
|
export class SnapOverlay extends Overlay {
|
||||||
static override overlayName: string = 'snap-manager';
|
static override overlayName: string = 'snap-manager';
|
||||||
@@ -75,6 +80,11 @@ export class SnapOverlay extends Overlay {
|
|||||||
vertical: [],
|
vertical: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private readonly _distributeCooldown = new AdaptiveCooldownController({
|
||||||
|
cooldownFrames: DISTRIBUTE_ALIGN_COOLDOWN_FRAMES,
|
||||||
|
maxCostMs: DISTRIBUTE_ALIGN_MAX_COST_MS,
|
||||||
|
});
|
||||||
|
|
||||||
override clear() {
|
override clear() {
|
||||||
this._referenceBounds = {
|
this._referenceBounds = {
|
||||||
vertical: [],
|
vertical: [],
|
||||||
@@ -87,6 +97,7 @@ export class SnapOverlay extends Overlay {
|
|||||||
};
|
};
|
||||||
this._distributedAlignLines = [];
|
this._distributedAlignLines = [];
|
||||||
this._skippedElements.clear();
|
this._skippedElements.clear();
|
||||||
|
this._distributeCooldown.reset();
|
||||||
|
|
||||||
super.clear();
|
super.clear();
|
||||||
}
|
}
|
||||||
@@ -673,6 +684,13 @@ export class SnapOverlay extends Overlay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldTryDistribute =
|
||||||
|
this._referenceBounds.all.length <= DISTRIBUTE_ALIGN_MAX_CANDIDATES &&
|
||||||
|
this._distributeCooldown.shouldRun();
|
||||||
|
|
||||||
|
if (shouldTryDistribute) {
|
||||||
|
const distributeStart = performance.now();
|
||||||
|
|
||||||
// point align priority is higher than distribute align
|
// point align priority is higher than distribute align
|
||||||
if (rst.dx === 0) {
|
if (rst.dx === 0) {
|
||||||
this._alignDistributeHorizontally(rst, bound, threshold, viewport);
|
this._alignDistributeHorizontally(rst, bound, threshold, viewport);
|
||||||
@@ -682,6 +700,10 @@ export class SnapOverlay extends Overlay {
|
|||||||
this._alignDistributeVertically(rst, bound, threshold, viewport);
|
this._alignDistributeVertically(rst, bound, threshold, viewport);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const distributeCost = performance.now() - distributeStart;
|
||||||
|
this._distributeCooldown.reportCost(distributeCost);
|
||||||
|
}
|
||||||
|
|
||||||
this._renderer?.refresh();
|
this._renderer?.refresh();
|
||||||
|
|
||||||
return rst;
|
return rst;
|
||||||
@@ -776,24 +798,26 @@ export class SnapOverlay extends Overlay {
|
|||||||
});
|
});
|
||||||
const verticalBounds: Bound[] = [];
|
const verticalBounds: Bound[] = [];
|
||||||
const horizBounds: Bound[] = [];
|
const horizBounds: Bound[] = [];
|
||||||
const allBounds: Bound[] = [];
|
const allCandidateElements = new Set<GfxModel>();
|
||||||
|
|
||||||
vertCandidates.forEach(candidate => {
|
vertCandidates.forEach(candidate => {
|
||||||
if (skipped.has(candidate) || this._isSkippedElement(candidate)) return;
|
if (skipped.has(candidate) || this._isSkippedElement(candidate)) return;
|
||||||
verticalBounds.push(candidate.elementBound);
|
const bound = candidate.elementBound;
|
||||||
allBounds.push(candidate.elementBound);
|
verticalBounds.push(bound);
|
||||||
|
allCandidateElements.add(candidate);
|
||||||
});
|
});
|
||||||
|
|
||||||
horizCandidates.forEach(candidate => {
|
horizCandidates.forEach(candidate => {
|
||||||
if (skipped.has(candidate) || this._isSkippedElement(candidate)) return;
|
if (skipped.has(candidate) || this._isSkippedElement(candidate)) return;
|
||||||
horizBounds.push(candidate.elementBound);
|
const bound = candidate.elementBound;
|
||||||
allBounds.push(candidate.elementBound);
|
horizBounds.push(bound);
|
||||||
|
allCandidateElements.add(candidate);
|
||||||
});
|
});
|
||||||
|
|
||||||
this._referenceBounds = {
|
this._referenceBounds = {
|
||||||
horizontal: horizBounds,
|
horizontal: horizBounds,
|
||||||
vertical: verticalBounds,
|
vertical: verticalBounds,
|
||||||
all: allBounds,
|
all: [...allCandidateElements].map(element => element.elementBound),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import {
|
|||||||
} from '@blocksuite/affine-block-surface';
|
} from '@blocksuite/affine-block-surface';
|
||||||
import { on } from '@blocksuite/affine-shared/utils';
|
import { on } from '@blocksuite/affine-shared/utils';
|
||||||
import type { PointerEventState } from '@blocksuite/std';
|
import type { PointerEventState } from '@blocksuite/std';
|
||||||
import { BaseTool, MouseButton, type ToolOptions } from '@blocksuite/std/gfx';
|
import {
|
||||||
|
BaseTool,
|
||||||
|
createRafCoalescer,
|
||||||
|
MouseButton,
|
||||||
|
type ToolOptions,
|
||||||
|
} from '@blocksuite/std/gfx';
|
||||||
import { Signal } from '@preact/signals-core';
|
import { Signal } from '@preact/signals-core';
|
||||||
|
|
||||||
interface RestorablePresentToolOptions {
|
interface RestorablePresentToolOptions {
|
||||||
@@ -21,13 +26,30 @@ export class PanTool extends BaseTool<PanToolOption> {
|
|||||||
|
|
||||||
private _lastPoint: [number, number] | null = null;
|
private _lastPoint: [number, number] | null = null;
|
||||||
|
|
||||||
|
private _pendingDelta: [number, number] = [0, 0];
|
||||||
|
|
||||||
|
private readonly _deltaFlushCoalescer = createRafCoalescer<void>(() => {
|
||||||
|
this._flushPendingDelta();
|
||||||
|
});
|
||||||
|
|
||||||
readonly panning$ = new Signal<boolean>(false);
|
readonly panning$ = new Signal<boolean>(false);
|
||||||
|
|
||||||
|
private _flushPendingDelta() {
|
||||||
|
if (this._pendingDelta[0] === 0 && this._pendingDelta[1] === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [deltaX, deltaY] = this._pendingDelta;
|
||||||
|
this._pendingDelta = [0, 0];
|
||||||
|
this.gfx.viewport.applyDeltaCenter(deltaX, deltaY);
|
||||||
|
}
|
||||||
|
|
||||||
override get allowDragWithRightButton(): boolean {
|
override get allowDragWithRightButton(): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
override dragEnd(_: PointerEventState): void {
|
override dragEnd(_: PointerEventState): void {
|
||||||
|
this._deltaFlushCoalescer.flush();
|
||||||
this._lastPoint = null;
|
this._lastPoint = null;
|
||||||
this.panning$.value = false;
|
this.panning$.value = false;
|
||||||
}
|
}
|
||||||
@@ -43,12 +65,14 @@ export class PanTool extends BaseTool<PanToolOption> {
|
|||||||
const deltaY = lastY - e.y;
|
const deltaY = lastY - e.y;
|
||||||
|
|
||||||
this._lastPoint = [e.x, e.y];
|
this._lastPoint = [e.x, e.y];
|
||||||
|
this._pendingDelta[0] += deltaX / zoom;
|
||||||
viewport.applyDeltaCenter(deltaX / zoom, deltaY / zoom);
|
this._pendingDelta[1] += deltaY / zoom;
|
||||||
|
this._deltaFlushCoalescer.schedule(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
override dragStart(e: PointerEventState): void {
|
override dragStart(e: PointerEventState): void {
|
||||||
this._lastPoint = [e.x, e.y];
|
this._lastPoint = [e.x, e.y];
|
||||||
|
this._pendingDelta = [0, 0];
|
||||||
this.panning$.value = true;
|
this.panning$.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,4 +144,8 @@ export class PanTool extends BaseTool<PanToolOption> {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override unmounted(): void {
|
||||||
|
this._deltaFlushCoalescer.cancel();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
blocksuite/affine/gfx/pointer/vitest.config.ts
Normal file
25
blocksuite/affine/gfx/pointer/vitest.config.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
esbuild: {
|
||||||
|
target: 'es2018',
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globalSetup: '../../../scripts/vitest-global.js',
|
||||||
|
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||||
|
testTimeout: 1000,
|
||||||
|
coverage: {
|
||||||
|
provider: 'istanbul',
|
||||||
|
reporter: ['lcov'],
|
||||||
|
reportsDirectory: '../../../.coverage/affine-gfx-pointer',
|
||||||
|
},
|
||||||
|
onConsoleLog(log, type) {
|
||||||
|
if (log.includes('lit.dev/msg/dev-mode')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.warn(`Unexpected ${type} log`, log);
|
||||||
|
throw new Error(log);
|
||||||
|
},
|
||||||
|
environment: 'happy-dom',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -155,9 +155,22 @@ export class FrameBlockModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeChild(element: GfxModel): void {
|
removeChild(element: GfxModel): void {
|
||||||
|
this.removeChildren([element]);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChildren(elements: GfxModel[]): void {
|
||||||
|
const childIds = [...new Set(elements.map(element => element.id))];
|
||||||
|
if (!this.props.childElementIds || childIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.store.transact(() => {
|
this.store.transact(() => {
|
||||||
this.props.childElementIds &&
|
const childElementIds = this.props.childElementIds;
|
||||||
delete this.props.childElementIds[element.id];
|
if (!childElementIds) return;
|
||||||
|
|
||||||
|
childIds.forEach(childId => {
|
||||||
|
delete childElementIds[childId];
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,13 +54,22 @@ export class GroupElementModel extends GfxGroupLikeElementModel<GroupElementProp
|
|||||||
}
|
}
|
||||||
|
|
||||||
override addChild(element: GfxModel) {
|
override addChild(element: GfxModel) {
|
||||||
if (!canSafeAddToContainer(this, element)) {
|
this.addChildren([element]);
|
||||||
|
}
|
||||||
|
|
||||||
|
addChildren(elements: GfxModel[]) {
|
||||||
|
elements = [...new Set(elements)].filter(element =>
|
||||||
|
canSafeAddToContainer(this, element)
|
||||||
|
);
|
||||||
|
if (elements.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.surface.store.transact(() => {
|
this.surface.store.transact(() => {
|
||||||
|
elements.forEach(element => {
|
||||||
this.children.set(element.id, true);
|
this.children.set(element.id, true);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
override containsBound(bound: Bound): boolean {
|
override containsBound(bound: Bound): boolean {
|
||||||
@@ -76,11 +85,22 @@ export class GroupElementModel extends GfxGroupLikeElementModel<GroupElementProp
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeChild(element: GfxModel) {
|
removeChild(element: GfxModel) {
|
||||||
|
this.removeChildren([element]);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChildren(elements: GfxModel[]) {
|
||||||
if (!this.children) {
|
if (!this.children) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const childIds = [...new Set(elements.map(element => element.id))];
|
||||||
|
if (childIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.surface.store.transact(() => {
|
this.surface.store.transact(() => {
|
||||||
this.children.delete(element.id);
|
childIds.forEach(childId => {
|
||||||
|
this.children.delete(childId);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
- [canSafeAddToContainer](functions/canSafeAddToContainer.md)
|
- [canSafeAddToContainer](functions/canSafeAddToContainer.md)
|
||||||
- [compareLayer](functions/compareLayer.md)
|
- [compareLayer](functions/compareLayer.md)
|
||||||
- [convert](functions/convert.md)
|
- [convert](functions/convert.md)
|
||||||
|
- [createRafCoalescer](functions/createRafCoalescer.md)
|
||||||
- [derive](functions/derive.md)
|
- [derive](functions/derive.md)
|
||||||
- [generateKeyBetween](functions/generateKeyBetween.md)
|
- [generateKeyBetween](functions/generateKeyBetween.md)
|
||||||
- [generateKeyBetweenV2](functions/generateKeyBetweenV2.md)
|
- [generateKeyBetweenV2](functions/generateKeyBetweenV2.md)
|
||||||
@@ -42,5 +43,6 @@
|
|||||||
- [GfxCompatible](functions/GfxCompatible.md)
|
- [GfxCompatible](functions/GfxCompatible.md)
|
||||||
- [isGfxGroupCompatibleModel](functions/isGfxGroupCompatibleModel.md)
|
- [isGfxGroupCompatibleModel](functions/isGfxGroupCompatibleModel.md)
|
||||||
- [local](functions/local.md)
|
- [local](functions/local.md)
|
||||||
|
- [measureOperation](functions/measureOperation.md)
|
||||||
- [observe](functions/observe.md)
|
- [observe](functions/observe.md)
|
||||||
- [watch](functions/watch.md)
|
- [watch](functions/watch.md)
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[**BlockSuite API Documentation**](../../../../README.md)
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
[BlockSuite API Documentation](../../../../README.md) / [@blocksuite/std](../../README.md) / [gfx](../README.md) / createRafCoalescer
|
||||||
|
|
||||||
|
# Function: createRafCoalescer()
|
||||||
|
|
||||||
|
> **createRafCoalescer**\<`T`\>(`apply`): `RafCoalescer`\<`T`\>
|
||||||
|
|
||||||
|
Coalesce high-frequency updates and only process the latest payload in one frame.
|
||||||
|
|
||||||
|
## Type Parameters
|
||||||
|
|
||||||
|
### T
|
||||||
|
|
||||||
|
`T`
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
### apply
|
||||||
|
|
||||||
|
(`payload`) => `void`
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
`RafCoalescer`\<`T`\>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
[**BlockSuite API Documentation**](../../../../README.md)
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
[BlockSuite API Documentation](../../../../README.md) / [@blocksuite/std](../../README.md) / [gfx](../README.md) / measureOperation
|
||||||
|
|
||||||
|
# Function: measureOperation()
|
||||||
|
|
||||||
|
> **measureOperation**\<`T`\>(`name`, `fn`): `T`
|
||||||
|
|
||||||
|
Measure operation cost via Performance API when available.
|
||||||
|
|
||||||
|
Marks are always cleared, while measure entries are intentionally retained
|
||||||
|
so callers can inspect them from Performance tools.
|
||||||
|
|
||||||
|
## Type Parameters
|
||||||
|
|
||||||
|
### T
|
||||||
|
|
||||||
|
`T`
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
### name
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
### fn
|
||||||
|
|
||||||
|
() => `T`
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
`T`
|
||||||
@@ -356,3 +356,63 @@ describe('convert decorator', () => {
|
|||||||
expect(elementModel.shapeType).toBe('rect');
|
expect(elementModel.shapeType).toBe('rect');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('surface group index cache', () => {
|
||||||
|
test('syncGroupChildrenIndex should replace outdated parent mappings', () => {
|
||||||
|
const { surfaceModel } = commonSetup();
|
||||||
|
const model = surfaceModel as any;
|
||||||
|
|
||||||
|
model._syncGroupChildrenIndex('group-1', ['a', 'b'], []);
|
||||||
|
expect(model._parentGroupMap.get('a')).toBe('group-1');
|
||||||
|
expect(model._parentGroupMap.get('b')).toBe('group-1');
|
||||||
|
|
||||||
|
model._syncGroupChildrenIndex('group-1', ['b', 'c']);
|
||||||
|
expect(model._parentGroupMap.has('a')).toBe(false);
|
||||||
|
expect(model._parentGroupMap.get('b')).toBe('group-1');
|
||||||
|
expect(model._parentGroupMap.get('c')).toBe('group-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeGroupFromChildrenIndex should clear both child snapshot and reverse lookup', () => {
|
||||||
|
const { surfaceModel } = commonSetup();
|
||||||
|
const model = surfaceModel as any;
|
||||||
|
|
||||||
|
model._syncGroupChildrenIndex('group-2', ['x', 'y'], []);
|
||||||
|
model._removeGroupFromChildrenIndex('group-2');
|
||||||
|
|
||||||
|
expect(model._groupChildIdsMap.has('group-2')).toBe(false);
|
||||||
|
expect(model._parentGroupMap.has('x')).toBe(false);
|
||||||
|
expect(model._parentGroupMap.has('y')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getGroup should recover from stale cache and update reverse lookup', () => {
|
||||||
|
const { surfaceModel } = commonSetup();
|
||||||
|
const model = surfaceModel as any;
|
||||||
|
|
||||||
|
const shapeId = surfaceModel.addElement({
|
||||||
|
type: 'testShape',
|
||||||
|
});
|
||||||
|
const shape = surfaceModel.getElementById(shapeId)!;
|
||||||
|
|
||||||
|
const fakeGroup = {
|
||||||
|
id: 'group-fallback',
|
||||||
|
hasChild: (element: { id: string }) => element.id === shapeId,
|
||||||
|
};
|
||||||
|
|
||||||
|
model._groupLikeModels.set(fakeGroup.id, fakeGroup);
|
||||||
|
model._parentGroupMap.set(shapeId, 'stale-group-id');
|
||||||
|
|
||||||
|
expect(surfaceModel.getGroup(shapeId)).toBe(fakeGroup);
|
||||||
|
expect(model._parentGroupMap.get(shapeId)).toBe(fakeGroup.id);
|
||||||
|
expect(model._parentGroupMap.has('stale-group-id')).toBe(false);
|
||||||
|
|
||||||
|
const otherShapeId = surfaceModel.addElement({
|
||||||
|
type: 'testShape',
|
||||||
|
});
|
||||||
|
model._parentGroupMap.set(otherShapeId, 'another-missing-group');
|
||||||
|
expect(surfaceModel.getGroup(otherShapeId)).toBeNull();
|
||||||
|
expect(model._parentGroupMap.has(otherShapeId)).toBe(false);
|
||||||
|
|
||||||
|
// keep one explicit check on element-based lookup path
|
||||||
|
expect(surfaceModel.getGroup(shape as any)).toBe(fakeGroup);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
165
blocksuite/framework/std/src/__tests__/gfx/tree.unit.spec.ts
Normal file
165
blocksuite/framework/std/src/__tests__/gfx/tree.unit.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type GfxGroupCompatibleInterface,
|
||||||
|
gfxGroupCompatibleSymbol,
|
||||||
|
} from '../../gfx/model/base.js';
|
||||||
|
import type { GfxModel } from '../../gfx/model/model.js';
|
||||||
|
import {
|
||||||
|
batchAddChildren,
|
||||||
|
batchRemoveChildren,
|
||||||
|
canSafeAddToContainer,
|
||||||
|
descendantElementsImpl,
|
||||||
|
getTopElements,
|
||||||
|
} from '../../utils/tree.js';
|
||||||
|
|
||||||
|
type TestElement = {
|
||||||
|
id: string;
|
||||||
|
group: TestGroup | null;
|
||||||
|
groups: TestGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type TestGroup = TestElement & {
|
||||||
|
[gfxGroupCompatibleSymbol]: true;
|
||||||
|
childIds: string[];
|
||||||
|
childElements: GfxModel[];
|
||||||
|
addChild: (element: GfxModel) => void;
|
||||||
|
removeChild: (element: GfxModel) => void;
|
||||||
|
hasChild: (element: GfxModel) => boolean;
|
||||||
|
hasDescendant: (element: GfxModel) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createElement = (id: string): TestElement => ({
|
||||||
|
id,
|
||||||
|
group: null,
|
||||||
|
groups: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createGroup = (id: string): TestGroup => {
|
||||||
|
const group: TestGroup = {
|
||||||
|
id,
|
||||||
|
[gfxGroupCompatibleSymbol]: true,
|
||||||
|
group: null,
|
||||||
|
groups: [],
|
||||||
|
childIds: [],
|
||||||
|
childElements: [],
|
||||||
|
addChild(element: GfxModel) {
|
||||||
|
const child = element as unknown as TestElement;
|
||||||
|
if (this.childElements.includes(element)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.childElements.push(element);
|
||||||
|
this.childIds.push(child.id);
|
||||||
|
child.group = this;
|
||||||
|
child.groups = [...this.groups, this];
|
||||||
|
},
|
||||||
|
removeChild(element: GfxModel) {
|
||||||
|
const child = element as unknown as TestElement;
|
||||||
|
this.childElements = this.childElements.filter(item => item !== element);
|
||||||
|
this.childIds = this.childIds.filter(id => id !== child.id);
|
||||||
|
if (child.group === this) {
|
||||||
|
child.group = null;
|
||||||
|
child.groups = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hasChild(element: GfxModel) {
|
||||||
|
return this.childElements.includes(element);
|
||||||
|
},
|
||||||
|
hasDescendant(element: GfxModel) {
|
||||||
|
return descendantElementsImpl(
|
||||||
|
this as unknown as GfxGroupCompatibleInterface
|
||||||
|
).includes(element);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return group;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('tree utils', () => {
|
||||||
|
test('batchAddChildren prefers container.addChildren and deduplicates', () => {
|
||||||
|
const a = createElement('a') as unknown as GfxModel;
|
||||||
|
const b = createElement('b') as unknown as GfxModel;
|
||||||
|
const container = {
|
||||||
|
addChildren: vi.fn(),
|
||||||
|
addChild: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
batchAddChildren(container as any, [a, a, b]);
|
||||||
|
|
||||||
|
expect(container.addChildren).toHaveBeenCalledTimes(1);
|
||||||
|
expect(container.addChildren).toHaveBeenCalledWith([a, b]);
|
||||||
|
expect(container.addChild).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('batchRemoveChildren falls back to container.removeChild and deduplicates', () => {
|
||||||
|
const a = createElement('a') as unknown as GfxModel;
|
||||||
|
const b = createElement('b') as unknown as GfxModel;
|
||||||
|
const container = {
|
||||||
|
removeChild: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
batchRemoveChildren(container as any, [a, a, b]);
|
||||||
|
|
||||||
|
expect(container.removeChild).toHaveBeenCalledTimes(2);
|
||||||
|
expect(container.removeChild).toHaveBeenNthCalledWith(1, a);
|
||||||
|
expect(container.removeChild).toHaveBeenNthCalledWith(2, b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getTopElements removes descendants when ancestors are selected', () => {
|
||||||
|
const root = createGroup('root');
|
||||||
|
const nested = createGroup('nested');
|
||||||
|
const leafA = createElement('leaf-a');
|
||||||
|
const leafB = createElement('leaf-b');
|
||||||
|
const leafC = createElement('leaf-c');
|
||||||
|
|
||||||
|
root.addChild(leafA as unknown as GfxModel);
|
||||||
|
root.addChild(nested as unknown as GfxModel);
|
||||||
|
nested.addChild(leafB as unknown as GfxModel);
|
||||||
|
|
||||||
|
const result = getTopElements([
|
||||||
|
root as unknown as GfxModel,
|
||||||
|
nested as unknown as GfxModel,
|
||||||
|
leafA as unknown as GfxModel,
|
||||||
|
leafB as unknown as GfxModel,
|
||||||
|
leafC as unknown as GfxModel,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
root as unknown as GfxModel,
|
||||||
|
leafC as unknown as GfxModel,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('descendantElementsImpl stops on cyclic graph', () => {
|
||||||
|
const groupA = createGroup('group-a');
|
||||||
|
const groupB = createGroup('group-b');
|
||||||
|
groupA.addChild(groupB as unknown as GfxModel);
|
||||||
|
groupB.addChild(groupA as unknown as GfxModel);
|
||||||
|
|
||||||
|
const descendants = descendantElementsImpl(groupA as unknown as any);
|
||||||
|
|
||||||
|
expect(descendants).toHaveLength(2);
|
||||||
|
expect(new Set(descendants).size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canSafeAddToContainer blocks self and circular descendants', () => {
|
||||||
|
const parent = createGroup('parent');
|
||||||
|
const child = createGroup('child');
|
||||||
|
const unrelated = createElement('plain');
|
||||||
|
|
||||||
|
parent.addChild(child as unknown as GfxModel);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
canSafeAddToContainer(parent as unknown as any, parent as unknown as any)
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
canSafeAddToContainer(child as unknown as any, parent as unknown as any)
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
canSafeAddToContainer(
|
||||||
|
parent as unknown as any,
|
||||||
|
unrelated as unknown as any
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,8 @@ export {
|
|||||||
SortOrder,
|
SortOrder,
|
||||||
} from '../utils/layer.js';
|
} from '../utils/layer.js';
|
||||||
export {
|
export {
|
||||||
|
batchAddChildren,
|
||||||
|
batchRemoveChildren,
|
||||||
canSafeAddToContainer,
|
canSafeAddToContainer,
|
||||||
descendantElementsImpl,
|
descendantElementsImpl,
|
||||||
getTopElements,
|
getTopElements,
|
||||||
@@ -94,6 +96,8 @@ export {
|
|||||||
type SurfaceBlockProps,
|
type SurfaceBlockProps,
|
||||||
type SurfaceMiddleware,
|
type SurfaceMiddleware,
|
||||||
} from './model/surface/surface-model.js';
|
} from './model/surface/surface-model.js';
|
||||||
|
export { measureOperation } from './perf.js';
|
||||||
|
export { createRafCoalescer, type RafCoalescer } from './raf-coalescer.js';
|
||||||
export { GfxSelectionManager } from './selection.js';
|
export { GfxSelectionManager } from './selection.js';
|
||||||
export {
|
export {
|
||||||
SurfaceMiddlewareBuilder,
|
SurfaceMiddlewareBuilder,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { GfxExtension, GfxExtensionIdentifier } from '../extension.js';
|
|||||||
import { GfxBlockElementModel } from '../model/gfx-block-model.js';
|
import { GfxBlockElementModel } from '../model/gfx-block-model.js';
|
||||||
import type { GfxModel } from '../model/model.js';
|
import type { GfxModel } from '../model/model.js';
|
||||||
import { GfxPrimitiveElementModel } from '../model/surface/element-model.js';
|
import { GfxPrimitiveElementModel } from '../model/surface/element-model.js';
|
||||||
|
import { createRafCoalescer } from '../raf-coalescer.js';
|
||||||
import type { GfxElementModelView } from '../view/view.js';
|
import type { GfxElementModelView } from '../view/view.js';
|
||||||
import { createInteractionContext, type SupportedEvents } from './event.js';
|
import { createInteractionContext, type SupportedEvents } from './event.js';
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +56,20 @@ export const InteractivityIdentifier = GfxExtensionIdentifier(
|
|||||||
'interactivity-manager'
|
'interactivity-manager'
|
||||||
) as ServiceIdentifier<InteractivityManager>;
|
) as ServiceIdentifier<InteractivityManager>;
|
||||||
|
|
||||||
|
const DRAG_MOVE_RAF_THRESHOLD = 100;
|
||||||
|
const DRAG_MOVE_HEAVY_COST_MS = 4;
|
||||||
|
|
||||||
|
const shouldAllowDragMoveCoalescing = (
|
||||||
|
elements: { model: GfxModel }[]
|
||||||
|
): boolean => {
|
||||||
|
return elements.every(({ model }) => {
|
||||||
|
const isConnector = 'type' in model && model.type === 'connector';
|
||||||
|
const isContainer = 'childIds' in model;
|
||||||
|
|
||||||
|
return !isConnector && !isContainer;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export class InteractivityManager extends GfxExtension {
|
export class InteractivityManager extends GfxExtension {
|
||||||
static override key = 'interactivity-manager';
|
static override key = 'interactivity-manager';
|
||||||
|
|
||||||
@@ -381,11 +396,18 @@ export class InteractivityManager extends GfxExtension {
|
|||||||
};
|
};
|
||||||
let dragLastPos = internal.dragStartPos;
|
let dragLastPos = internal.dragStartPos;
|
||||||
let lastEvent = event;
|
let lastEvent = event;
|
||||||
|
let lastMoveDelta: [number, number] | null = null;
|
||||||
|
const canCoalesceDragMove = shouldAllowDragMoveCoalescing(
|
||||||
|
internal.elements
|
||||||
|
);
|
||||||
|
let shouldCoalesceDragMove =
|
||||||
|
canCoalesceDragMove &&
|
||||||
|
internal.elements.length >= DRAG_MOVE_RAF_THRESHOLD;
|
||||||
|
|
||||||
|
const applyDragMove = (event: PointerEvent) => {
|
||||||
|
const moveStart = performance.now();
|
||||||
|
lastEvent = event;
|
||||||
|
|
||||||
const viewportWatcher = this.gfx.viewport.viewportMoved.subscribe(() => {
|
|
||||||
onDragMove(lastEvent as PointerEvent);
|
|
||||||
});
|
|
||||||
const onDragMove = (event: PointerEvent) => {
|
|
||||||
dragLastPos = Point.from(
|
dragLastPos = Point.from(
|
||||||
this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y])
|
this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y])
|
||||||
);
|
);
|
||||||
@@ -407,6 +429,16 @@ export class InteractivityManager extends GfxExtension {
|
|||||||
moveContext[direction] = 0;
|
moveContext[direction] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
lastMoveDelta &&
|
||||||
|
lastMoveDelta[0] === moveContext.dx &&
|
||||||
|
lastMoveDelta[1] === moveContext.dy
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastMoveDelta = [moveContext.dx, moveContext.dy];
|
||||||
|
|
||||||
this._safeExecute(() => {
|
this._safeExecute(() => {
|
||||||
activeExtensionHandlers.forEach(handler =>
|
activeExtensionHandlers.forEach(handler =>
|
||||||
handler?.onDragMove?.(moveContext)
|
handler?.onDragMove?.(moveContext)
|
||||||
@@ -423,13 +455,39 @@ export class InteractivityManager extends GfxExtension {
|
|||||||
elements: internal.elements,
|
elements: internal.elements,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
canCoalesceDragMove &&
|
||||||
|
!shouldCoalesceDragMove &&
|
||||||
|
performance.now() - moveStart > DRAG_MOVE_HEAVY_COST_MS
|
||||||
|
) {
|
||||||
|
shouldCoalesceDragMove = true;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dragMoveCoalescer = createRafCoalescer<PointerEvent>(applyDragMove);
|
||||||
|
|
||||||
|
const flushPendingDragMove = () => {
|
||||||
|
dragMoveCoalescer.flush();
|
||||||
|
};
|
||||||
|
const onDragMove = (event: PointerEvent) => {
|
||||||
|
if (!shouldCoalesceDragMove) {
|
||||||
|
applyDragMove(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dragMoveCoalescer.schedule(event);
|
||||||
|
};
|
||||||
|
const viewportWatcher = this.gfx.viewport.viewportMoved.subscribe(() => {
|
||||||
|
onDragMove(lastEvent as PointerEvent);
|
||||||
|
});
|
||||||
const onDragEnd = (event: PointerEvent) => {
|
const onDragEnd = (event: PointerEvent) => {
|
||||||
this.activeInteraction$.value = null;
|
this.activeInteraction$.value = null;
|
||||||
|
|
||||||
host.removeEventListener('pointermove', onDragMove, false);
|
host.removeEventListener('pointermove', onDragMove, false);
|
||||||
host.removeEventListener('pointerup', onDragEnd, false);
|
host.removeEventListener('pointerup', onDragEnd, false);
|
||||||
viewportWatcher.unsubscribe();
|
viewportWatcher.unsubscribe();
|
||||||
|
flushPendingDragMove();
|
||||||
|
|
||||||
dragLastPos = Point.from(
|
dragLastPos = Point.from(
|
||||||
this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y])
|
this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y])
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ export class LayerManager extends GfxExtension {
|
|||||||
|
|
||||||
layers: Layer[] = [];
|
layers: Layer[] = [];
|
||||||
|
|
||||||
|
private readonly _groupChildSnapshot = new Map<string, string[]>();
|
||||||
|
|
||||||
slots = {
|
slots = {
|
||||||
layerUpdated: new Subject<{
|
layerUpdated: new Subject<{
|
||||||
type: 'delete' | 'add' | 'update';
|
type: 'delete' | 'add' | 'update';
|
||||||
@@ -148,6 +150,43 @@ export class LayerManager extends GfxExtension {
|
|||||||
: 'block';
|
: 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _getModelById(id: string): GfxModel | null {
|
||||||
|
if (!this._surface) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
this._surface.getElementById(id) ??
|
||||||
|
(this._doc.getModelById(id) as GfxModel | undefined) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getRelatedGroupElements(
|
||||||
|
group: GfxModel & GfxGroupCompatibleInterface,
|
||||||
|
oldChildIds?: string[]
|
||||||
|
) {
|
||||||
|
const elements = new Set<GfxModel>([group, ...group.descendantElements]);
|
||||||
|
|
||||||
|
oldChildIds?.forEach(id => {
|
||||||
|
const model = this._getModelById(id);
|
||||||
|
if (!model) return;
|
||||||
|
|
||||||
|
elements.add(model);
|
||||||
|
if (isGfxGroupCompatibleModel(model)) {
|
||||||
|
model.descendantElements.forEach(descendant => {
|
||||||
|
elements.add(descendant);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...elements];
|
||||||
|
}
|
||||||
|
|
||||||
|
private _syncGroupChildSnapshot(
|
||||||
|
group: GfxModel & GfxGroupCompatibleInterface
|
||||||
|
) {
|
||||||
|
this._groupChildSnapshot.set(group.id, [...group.childIds]);
|
||||||
|
}
|
||||||
|
|
||||||
private _initLayers() {
|
private _initLayers() {
|
||||||
let blockIdx = 0;
|
let blockIdx = 0;
|
||||||
let canvasIdx = 0;
|
let canvasIdx = 0;
|
||||||
@@ -487,6 +526,29 @@ export class LayerManager extends GfxExtension {
|
|||||||
updateLayersZIndex(layers, index);
|
updateLayersZIndex(layers, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _refreshElementsInLayer(elements: GfxModel[]) {
|
||||||
|
const uniqueElements = [...new Set(elements)];
|
||||||
|
|
||||||
|
uniqueElements.forEach(element => {
|
||||||
|
const modelType = this._getModelType(element);
|
||||||
|
if (modelType === 'canvas') {
|
||||||
|
removeFromOrderedArray(this.canvasElements, element);
|
||||||
|
insertToOrderedArray(this.canvasElements, element);
|
||||||
|
} else {
|
||||||
|
removeFromOrderedArray(this.blocks, element);
|
||||||
|
insertToOrderedArray(this.blocks, element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
uniqueElements.forEach(element => {
|
||||||
|
this._removeFromLayer(element, this._getModelType(element));
|
||||||
|
});
|
||||||
|
|
||||||
|
uniqueElements.sort(compare).forEach(element => {
|
||||||
|
this._insertIntoLayer(element, this._getModelType(element));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private _reset() {
|
private _reset() {
|
||||||
const elements = (
|
const elements = (
|
||||||
this._doc
|
this._doc
|
||||||
@@ -512,6 +574,17 @@ export class LayerManager extends GfxExtension {
|
|||||||
|
|
||||||
this.canvasElements.sort(compare);
|
this.canvasElements.sort(compare);
|
||||||
this.blocks.sort(compare);
|
this.blocks.sort(compare);
|
||||||
|
this._groupChildSnapshot.clear();
|
||||||
|
this.canvasElements.forEach(element => {
|
||||||
|
if (isGfxGroupCompatibleModel(element)) {
|
||||||
|
this._syncGroupChildSnapshot(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.blocks.forEach(element => {
|
||||||
|
if (isGfxGroupCompatibleModel(element)) {
|
||||||
|
this._syncGroupChildSnapshot(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this._initLayers();
|
this._initLayers();
|
||||||
this._buildCanvasLayers();
|
this._buildCanvasLayers();
|
||||||
@@ -522,7 +595,8 @@ 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>
|
||||||
) {
|
) {
|
||||||
const modelType = this._getModelType(element);
|
const modelType = this._getModelType(element);
|
||||||
const isLocalElem = element instanceof GfxLocalElementModel;
|
const isLocalElem = element instanceof GfxLocalElementModel;
|
||||||
@@ -539,7 +613,16 @@ export class LayerManager extends GfxExtension {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (shouldUpdateGroupChildren) {
|
if (shouldUpdateGroupChildren) {
|
||||||
this._reset();
|
const group = element as GfxModel & GfxGroupCompatibleInterface;
|
||||||
|
const oldChildIds = childIdsChanged
|
||||||
|
? Array.isArray(oldValues?.['childIds'])
|
||||||
|
? (oldValues['childIds'] as string[])
|
||||||
|
: this._groupChildSnapshot.get(group.id)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const relatedElements = this._getRelatedGroupElements(group, oldChildIds);
|
||||||
|
this._refreshElementsInLayer(relatedElements);
|
||||||
|
this._syncGroupChildSnapshot(group);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,6 +664,13 @@ export class LayerManager extends GfxExtension {
|
|||||||
element
|
element
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isContainer) {
|
||||||
|
this._syncGroupChildSnapshot(
|
||||||
|
element as GfxModel & GfxGroupCompatibleInterface
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this._insertIntoLayer(element as GfxModel, modelType);
|
this._insertIntoLayer(element as GfxModel, modelType);
|
||||||
|
|
||||||
if (isContainer) {
|
if (isContainer) {
|
||||||
@@ -648,7 +738,26 @@ export class LayerManager extends GfxExtension {
|
|||||||
const isLocalElem = element instanceof GfxLocalElementModel;
|
const isLocalElem = element instanceof GfxLocalElementModel;
|
||||||
|
|
||||||
if (isGroup) {
|
if (isGroup) {
|
||||||
this._reset();
|
const groupElements = this._getRelatedGroupElements(
|
||||||
|
element as GfxModel & GfxGroupCompatibleInterface
|
||||||
|
);
|
||||||
|
const descendants = groupElements.filter(model => model !== element);
|
||||||
|
|
||||||
|
if (!isLocalElem) {
|
||||||
|
const groupType = this._getModelType(element);
|
||||||
|
if (groupType === 'canvas') {
|
||||||
|
removeFromOrderedArray(this.canvasElements, element);
|
||||||
|
} else {
|
||||||
|
removeFromOrderedArray(this.blocks, element);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._removeFromLayer(element, groupType);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._groupChildSnapshot.delete(element.id);
|
||||||
|
|
||||||
|
this._refreshElementsInLayer(descendants);
|
||||||
|
this._buildCanvasLayers();
|
||||||
this.slots.layerUpdated.next({
|
this.slots.layerUpdated.next({
|
||||||
type: 'delete',
|
type: 'delete',
|
||||||
initiatingElement: element as GfxModel,
|
initiatingElement: element as GfxModel,
|
||||||
@@ -680,6 +789,7 @@ export class LayerManager extends GfxExtension {
|
|||||||
|
|
||||||
override unmounted() {
|
override unmounted() {
|
||||||
this.slots.layerUpdated.complete();
|
this.slots.layerUpdated.complete();
|
||||||
|
this._groupChildSnapshot.clear();
|
||||||
this._disposable.dispose();
|
this._disposable.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,9 +887,10 @@ export class LayerManager extends GfxExtension {
|
|||||||
|
|
||||||
update(
|
update(
|
||||||
element: GfxModel | GfxLocalElementModel,
|
element: GfxModel | GfxLocalElementModel,
|
||||||
props?: Record<string, unknown>
|
props?: Record<string, unknown>,
|
||||||
|
oldValues?: Record<string, unknown>
|
||||||
) {
|
) {
|
||||||
if (this._updateLayer(element, props)) {
|
if (this._updateLayer(element, props, oldValues)) {
|
||||||
this._buildCanvasLayers();
|
this._buildCanvasLayers();
|
||||||
this.slots.layerUpdated.next({
|
this.slots.layerUpdated.next({
|
||||||
type: 'update',
|
type: 'update',
|
||||||
@@ -867,7 +978,11 @@ export class LayerManager extends GfxExtension {
|
|||||||
this._disposable.add(
|
this._disposable.add(
|
||||||
surface.elementUpdated.subscribe(payload => {
|
surface.elementUpdated.subscribe(payload => {
|
||||||
if (payload.props['index'] || payload.props['childIds']) {
|
if (payload.props['index'] || payload.props['childIds']) {
|
||||||
this.update(surface.getElementById(payload.id)!, payload.props);
|
this.update(
|
||||||
|
surface.getElementById(payload.id)!,
|
||||||
|
payload.props,
|
||||||
|
payload.oldValues
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { signal } from '@preact/signals-core';
|
|||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
|
import { measureOperation } from '../../perf.js';
|
||||||
import {
|
import {
|
||||||
type GfxGroupCompatibleInterface,
|
type GfxGroupCompatibleInterface,
|
||||||
isGfxGroupCompatibleModel,
|
isGfxGroupCompatibleModel,
|
||||||
@@ -74,6 +75,10 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
|||||||
|
|
||||||
protected _groupLikeModels = new Map<string, GfxGroupModel>();
|
protected _groupLikeModels = new Map<string, GfxGroupModel>();
|
||||||
|
|
||||||
|
protected _parentGroupMap = new Map<string, string>();
|
||||||
|
|
||||||
|
protected _groupChildIdsMap = new Map<string, string[]>();
|
||||||
|
|
||||||
protected _middlewares: SurfaceMiddleware[] = [];
|
protected _middlewares: SurfaceMiddleware[] = [];
|
||||||
|
|
||||||
protected _surfaceBlockModel = true;
|
protected _surfaceBlockModel = true;
|
||||||
@@ -133,6 +138,44 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _collectElementsToDelete(
|
||||||
|
id: string,
|
||||||
|
deleteElementIds: Set<string>,
|
||||||
|
orderedDeleteIds: string[],
|
||||||
|
deleteBlockIds: Set<string>
|
||||||
|
) {
|
||||||
|
if (deleteElementIds.has(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = this.getElementById(id);
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteElementIds.add(id);
|
||||||
|
|
||||||
|
if (element instanceof GfxGroupLikeElementModel) {
|
||||||
|
element.childIds.forEach(childId => {
|
||||||
|
if (this.hasElementById(childId)) {
|
||||||
|
this._collectElementsToDelete(
|
||||||
|
childId,
|
||||||
|
deleteElementIds,
|
||||||
|
orderedDeleteIds,
|
||||||
|
deleteBlockIds
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.store.hasBlock(childId)) {
|
||||||
|
deleteBlockIds.add(childId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
orderedDeleteIds.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
private _createElementFromProps(
|
private _createElementFromProps(
|
||||||
props: Record<string, unknown>,
|
props: Record<string, unknown>,
|
||||||
options: {
|
options: {
|
||||||
@@ -247,6 +290,26 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _emitElementUpdated(
|
||||||
|
model: GfxPrimitiveElementModel,
|
||||||
|
payload: ElementUpdatedData
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
isGfxGroupCompatibleModel(model) &&
|
||||||
|
('childIds' in payload.props || 'childIds' in payload.oldValues)
|
||||||
|
) {
|
||||||
|
const oldChildIds = Array.isArray(payload.oldValues['childIds'])
|
||||||
|
? (payload.oldValues['childIds'] as string[])
|
||||||
|
: undefined;
|
||||||
|
this._syncGroupChildrenIndex(model.id, model.childIds, oldChildIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.elementUpdated.next(payload);
|
||||||
|
Object.keys(payload.props).forEach(key => {
|
||||||
|
model.propsUpdated.next({ key });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private _initElementModels() {
|
private _initElementModels() {
|
||||||
const elementsYMap = this.elements.getValue()!;
|
const elementsYMap = this.elements.getValue()!;
|
||||||
const addToType = (type: string, model: GfxPrimitiveElementModel) => {
|
const addToType = (type: string, model: GfxPrimitiveElementModel) => {
|
||||||
@@ -260,6 +323,7 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
|||||||
|
|
||||||
if (isGfxGroupCompatibleModel(model)) {
|
if (isGfxGroupCompatibleModel(model)) {
|
||||||
this._groupLikeModels.set(model.id, model);
|
this._groupLikeModels.set(model.id, model);
|
||||||
|
this._syncGroupChildrenIndex(model.id, model.childIds, []);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const removeFromType = (type: string, model: GfxPrimitiveElementModel) => {
|
const removeFromType = (type: string, model: GfxPrimitiveElementModel) => {
|
||||||
@@ -270,7 +334,10 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
|||||||
sameTypeElements.splice(index, 1);
|
sameTypeElements.splice(index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._groupLikeModels.has(model.id)) {
|
this._parentGroupMap.delete(model.id);
|
||||||
|
|
||||||
|
if (isGfxGroupCompatibleModel(model)) {
|
||||||
|
this._removeGroupFromChildrenIndex(model.id);
|
||||||
this._groupLikeModels.delete(model.id);
|
this._groupLikeModels.delete(model.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -304,9 +371,9 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
|||||||
element,
|
element,
|
||||||
{
|
{
|
||||||
onChange: payload => {
|
onChange: payload => {
|
||||||
this.elementUpdated.next(payload);
|
this._emitElementUpdated(model.model, {
|
||||||
Object.keys(payload.props).forEach(key => {
|
...payload,
|
||||||
model.model.propsUpdated.next({ key });
|
id,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
skipFieldInit: true,
|
skipFieldInit: true,
|
||||||
@@ -351,10 +418,10 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
|||||||
val,
|
val,
|
||||||
{
|
{
|
||||||
onChange: payload => {
|
onChange: payload => {
|
||||||
(this.elementUpdated.next(payload),
|
this._emitElementUpdated(model.model, {
|
||||||
Object.keys(payload.props).forEach(key => {
|
...payload,
|
||||||
model.model.propsUpdated.next({ key });
|
id: key,
|
||||||
}));
|
});
|
||||||
},
|
},
|
||||||
skipFieldInit: true,
|
skipFieldInit: true,
|
||||||
}
|
}
|
||||||
@@ -371,9 +438,12 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
|||||||
Object.values(this.store.blocks.peek()).forEach(block => {
|
Object.values(this.store.blocks.peek()).forEach(block => {
|
||||||
if (isGfxGroupCompatibleModel(block.model)) {
|
if (isGfxGroupCompatibleModel(block.model)) {
|
||||||
this._groupLikeModels.set(block.id, block.model);
|
this._groupLikeModels.set(block.id, block.model);
|
||||||
|
this._syncGroupChildrenIndex(block.id, block.model.childIds, []);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this._rebuildGroupChildrenIndex();
|
||||||
|
|
||||||
elementsYMap.observe(onElementsMapChange);
|
elementsYMap.observe(onElementsMapChange);
|
||||||
|
|
||||||
const subscription = this.store.slots.blockUpdated.subscribe(payload => {
|
const subscription = this.store.slots.blockUpdated.subscribe(payload => {
|
||||||
@@ -381,11 +451,17 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
|||||||
case 'add':
|
case 'add':
|
||||||
if (isGfxGroupCompatibleModel(payload.model)) {
|
if (isGfxGroupCompatibleModel(payload.model)) {
|
||||||
this._groupLikeModels.set(payload.id, payload.model);
|
this._groupLikeModels.set(payload.id, payload.model);
|
||||||
|
this._syncGroupChildrenIndex(
|
||||||
|
payload.id,
|
||||||
|
payload.model.childIds,
|
||||||
|
[]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
if (isGfxGroupCompatibleModel(payload.model)) {
|
if (isGfxGroupCompatibleModel(payload.model)) {
|
||||||
|
this._removeGroupFromChildrenIndex(payload.id);
|
||||||
this._groupLikeModels.delete(payload.id);
|
this._groupLikeModels.delete(payload.id);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -395,6 +471,16 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
|||||||
group.removeChild(payload.model as GfxModel);
|
group.removeChild(payload.model as GfxModel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this._parentGroupMap.delete(payload.id);
|
||||||
|
|
||||||
|
break;
|
||||||
|
case 'update':
|
||||||
|
if (payload.props.key === 'childElementIds') {
|
||||||
|
const group = this.store.getBlock(payload.id)?.model;
|
||||||
|
if (group && isGfxGroupCompatibleModel(group)) {
|
||||||
|
this._syncGroupChildrenIndex(group.id, group.childIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -403,6 +489,8 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
|||||||
this.deleted.subscribe(() => {
|
this.deleted.subscribe(() => {
|
||||||
elementsYMap.unobserve(onElementsMapChange);
|
elementsYMap.unobserve(onElementsMapChange);
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
|
this._groupChildIdsMap.clear();
|
||||||
|
this._parentGroupMap.clear();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,6 +588,71 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
|||||||
return this._elementCtorMap[type];
|
return this._elementCtorMap[type];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _rebuildGroupChildrenIndex() {
|
||||||
|
this._groupChildIdsMap.clear();
|
||||||
|
this._parentGroupMap.clear();
|
||||||
|
|
||||||
|
this._groupLikeModels.forEach(group => {
|
||||||
|
this._syncGroupChildrenIndex(group.id, group.childIds, []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _removeFromParentGroupIfNeeded(
|
||||||
|
element: GfxModel,
|
||||||
|
deleteElementIds: Set<string>
|
||||||
|
) {
|
||||||
|
const parentGroupId = this._parentGroupMap.get(element.id);
|
||||||
|
|
||||||
|
if (parentGroupId && deleteElementIds.has(parentGroupId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parentGroup: GfxGroupModel | null = null;
|
||||||
|
|
||||||
|
if (parentGroupId) {
|
||||||
|
parentGroup = this._groupLikeModels.get(parentGroupId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
parentGroup = parentGroup ?? this.getGroup(element.id);
|
||||||
|
|
||||||
|
if (parentGroup && !deleteElementIds.has(parentGroup.id)) {
|
||||||
|
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
||||||
|
parentGroup.removeChild(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _removeGroupFromChildrenIndex(groupId: string) {
|
||||||
|
const previousChildIds = this._groupChildIdsMap.get(groupId) ?? [];
|
||||||
|
|
||||||
|
previousChildIds.forEach(childId => {
|
||||||
|
if (this._parentGroupMap.get(childId) === groupId) {
|
||||||
|
this._parentGroupMap.delete(childId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._groupChildIdsMap.delete(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _syncGroupChildrenIndex(
|
||||||
|
groupId: string,
|
||||||
|
nextChildIds: string[],
|
||||||
|
previousChildIds?: string[]
|
||||||
|
) {
|
||||||
|
const prev = previousChildIds ?? this._groupChildIdsMap.get(groupId) ?? [];
|
||||||
|
|
||||||
|
prev.forEach(childId => {
|
||||||
|
if (this._parentGroupMap.get(childId) === groupId) {
|
||||||
|
this._parentGroupMap.delete(childId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nextChildIds.forEach(childId => {
|
||||||
|
this._parentGroupMap.set(childId, groupId);
|
||||||
|
});
|
||||||
|
|
||||||
|
this._groupChildIdsMap.set(groupId, [...nextChildIds]);
|
||||||
|
}
|
||||||
|
|
||||||
addElement<T extends object = Record<string, unknown>>(
|
addElement<T extends object = Record<string, unknown>>(
|
||||||
props: Partial<T> & { type: string }
|
props: Partial<T> & { type: string }
|
||||||
) {
|
) {
|
||||||
@@ -526,9 +679,9 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
|||||||
|
|
||||||
const elementModel = this._createElementFromProps(props, {
|
const elementModel = this._createElementFromProps(props, {
|
||||||
onChange: payload => {
|
onChange: payload => {
|
||||||
this.elementUpdated.next(payload);
|
this._emitElementUpdated(elementModel.model, {
|
||||||
Object.keys(payload.props).forEach(key => {
|
...payload,
|
||||||
elementModel.model.propsUpdated.next({ key });
|
id,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -560,24 +713,48 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
measureOperation('edgeless:delete-element', () => {
|
||||||
|
const deleteElementIds = new Set<string>();
|
||||||
|
const orderedDeleteIds: string[] = [];
|
||||||
|
const deleteBlockIds = new Set<string>();
|
||||||
|
|
||||||
|
this._collectElementsToDelete(
|
||||||
|
id,
|
||||||
|
deleteElementIds,
|
||||||
|
orderedDeleteIds,
|
||||||
|
deleteBlockIds
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orderedDeleteIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.store.transact(() => {
|
this.store.transact(() => {
|
||||||
const element = this.getElementById(id)!;
|
orderedDeleteIds.forEach(elementId => {
|
||||||
const group = this.getGroup(id);
|
const element = this.getElementById(elementId);
|
||||||
|
|
||||||
if (element instanceof GfxGroupLikeElementModel) {
|
if (!element) {
|
||||||
element.childIds.forEach(childId => {
|
return;
|
||||||
if (this.hasElementById(childId)) {
|
|
||||||
this.deleteElement(childId);
|
|
||||||
} else if (this.store.hasBlock(childId)) {
|
|
||||||
this.store.deleteBlock(this.store.getBlock(childId)!.model);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._removeFromParentGroupIfNeeded(element, deleteElementIds);
|
||||||
|
this.elements.getValue()!.delete(elementId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
deleteBlockIds.forEach(blockId => {
|
||||||
|
const block = this.store.getBlock(blockId)?.model;
|
||||||
|
|
||||||
|
if (!block) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
|
this._removeFromParentGroupIfNeeded(
|
||||||
group?.removeChild(element as GfxModel);
|
block as GfxModel,
|
||||||
|
deleteElementIds
|
||||||
this.elements.getValue()!.delete(id);
|
);
|
||||||
|
this.store.deleteBlock(block);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,18 +784,31 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getGroup(elem: string | GfxModel): GfxGroupModel | null {
|
getGroup(elem: string | GfxModel): GfxGroupModel | null {
|
||||||
elem =
|
const id = typeof elem === 'string' ? elem : elem.id;
|
||||||
|
const parentGroupId = this._parentGroupMap.get(id);
|
||||||
|
|
||||||
|
if (parentGroupId) {
|
||||||
|
const group = this._groupLikeModels.get(parentGroupId);
|
||||||
|
if (group) {
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._parentGroupMap.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const model =
|
||||||
typeof elem === 'string'
|
typeof elem === 'string'
|
||||||
? ((this.getElementById(elem) ??
|
? ((this.getElementById(elem) ??
|
||||||
this.store.getBlock(elem)?.model) as GfxModel)
|
this.store.getBlock(elem)?.model) as GfxModel)
|
||||||
: elem;
|
: elem;
|
||||||
|
|
||||||
if (!elem) return null;
|
if (!model) return null;
|
||||||
|
|
||||||
assertType<GfxModel>(elem);
|
assertType<GfxModel>(model);
|
||||||
|
|
||||||
for (const group of this._groupLikeModels.values()) {
|
for (const group of this._groupLikeModels.values()) {
|
||||||
if (group.hasChild(elem)) {
|
if (group.hasChild(model)) {
|
||||||
|
this._parentGroupMap.set(id, group.id);
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
blocksuite/framework/std/src/gfx/perf.ts
Normal file
31
blocksuite/framework/std/src/gfx/perf.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
let opMeasureSeq = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure operation cost via Performance API when available.
|
||||||
|
*
|
||||||
|
* Marks are always cleared, while measure entries are intentionally retained
|
||||||
|
* so callers can inspect them from Performance tools.
|
||||||
|
*/
|
||||||
|
export const measureOperation = <T>(name: string, fn: () => T): T => {
|
||||||
|
if (
|
||||||
|
typeof performance === 'undefined' ||
|
||||||
|
typeof performance.mark !== 'function' ||
|
||||||
|
typeof performance.measure !== 'function'
|
||||||
|
) {
|
||||||
|
return fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
const operationId = opMeasureSeq++;
|
||||||
|
const startMark = `${name}:${operationId}:start`;
|
||||||
|
const endMark = `${name}:${operationId}:end`;
|
||||||
|
performance.mark(startMark);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return fn();
|
||||||
|
} finally {
|
||||||
|
performance.mark(endMark);
|
||||||
|
performance.measure(name, startMark, endMark);
|
||||||
|
performance.clearMarks(startMark);
|
||||||
|
performance.clearMarks(endMark);
|
||||||
|
}
|
||||||
|
};
|
||||||
76
blocksuite/framework/std/src/gfx/raf-coalescer.ts
Normal file
76
blocksuite/framework/std/src/gfx/raf-coalescer.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
export interface RafCoalescer<T> {
|
||||||
|
cancel: () => void;
|
||||||
|
flush: () => void;
|
||||||
|
schedule: (payload: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FrameScheduler = (callback: FrameRequestCallback) => number;
|
||||||
|
type FrameCanceller = (id: number) => void;
|
||||||
|
|
||||||
|
const getFrameScheduler = (): FrameScheduler => {
|
||||||
|
if (typeof requestAnimationFrame === 'function') {
|
||||||
|
return requestAnimationFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
return callback => {
|
||||||
|
return globalThis.setTimeout(() => {
|
||||||
|
callback(
|
||||||
|
typeof performance !== 'undefined' ? performance.now() : Date.now()
|
||||||
|
);
|
||||||
|
}, 16) as unknown as number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrameCanceller = (): FrameCanceller => {
|
||||||
|
if (typeof cancelAnimationFrame === 'function') {
|
||||||
|
return cancelAnimationFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
return id => globalThis.clearTimeout(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coalesce high-frequency updates and only process the latest payload in one frame.
|
||||||
|
*/
|
||||||
|
export const createRafCoalescer = <T>(
|
||||||
|
apply: (payload: T) => void
|
||||||
|
): RafCoalescer<T> => {
|
||||||
|
const scheduleFrame = getFrameScheduler();
|
||||||
|
const cancelFrame = getFrameCanceller();
|
||||||
|
|
||||||
|
let pendingPayload: T | undefined;
|
||||||
|
let hasPendingPayload = false;
|
||||||
|
let rafId: number | null = null;
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
rafId = null;
|
||||||
|
if (!hasPendingPayload) return;
|
||||||
|
|
||||||
|
const payload = pendingPayload as T;
|
||||||
|
pendingPayload = undefined;
|
||||||
|
hasPendingPayload = false;
|
||||||
|
apply(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
schedule(payload: T) {
|
||||||
|
pendingPayload = payload;
|
||||||
|
hasPendingPayload = true;
|
||||||
|
|
||||||
|
if (rafId !== null) return;
|
||||||
|
rafId = scheduleFrame(run);
|
||||||
|
},
|
||||||
|
flush() {
|
||||||
|
if (rafId !== null) cancelFrame(rafId);
|
||||||
|
run();
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelFrame(rafId);
|
||||||
|
rafId = null;
|
||||||
|
}
|
||||||
|
pendingPayload = undefined;
|
||||||
|
hasPendingPayload = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -41,6 +41,10 @@ export function requestThrottledConnectedFrame<
|
|||||||
viewport: PropTypes.instanceOf(Viewport),
|
viewport: PropTypes.instanceOf(Viewport),
|
||||||
})
|
})
|
||||||
export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
|
export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
|
||||||
|
private static readonly VIEWPORT_REFRESH_PIXEL_THRESHOLD = 18;
|
||||||
|
|
||||||
|
private static readonly VIEWPORT_REFRESH_MAX_INTERVAL = 120;
|
||||||
|
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
gfx-viewport {
|
gfx-viewport {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -104,6 +108,14 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
|
|||||||
|
|
||||||
private _lastVisibleModels?: Set<GfxBlockElementModel>;
|
private _lastVisibleModels?: Set<GfxBlockElementModel>;
|
||||||
|
|
||||||
|
private _lastViewportUpdate?: { zoom: number; center: [number, number] };
|
||||||
|
|
||||||
|
private _lastViewportRefreshTime = 0;
|
||||||
|
|
||||||
|
private _pendingViewportRefreshTimer: ReturnType<
|
||||||
|
typeof globalThis.setTimeout
|
||||||
|
> | null = null;
|
||||||
|
|
||||||
private readonly _pendingChildrenUpdates: {
|
private readonly _pendingChildrenUpdates: {
|
||||||
id: string;
|
id: string;
|
||||||
resolve: () => void;
|
resolve: () => void;
|
||||||
@@ -115,26 +127,90 @@ export class GfxViewportElement extends WithDisposable(ShadowlessElement) {
|
|||||||
|
|
||||||
private _updatingChildrenFlag = false;
|
private _updatingChildrenFlag = false;
|
||||||
|
|
||||||
|
private _clearPendingViewportRefreshTimer() {
|
||||||
|
if (this._pendingViewportRefreshTimer !== null) {
|
||||||
|
clearTimeout(this._pendingViewportRefreshTimer);
|
||||||
|
this._pendingViewportRefreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _scheduleTrailingViewportRefresh() {
|
||||||
|
this._clearPendingViewportRefreshTimer();
|
||||||
|
this._pendingViewportRefreshTimer = globalThis.setTimeout(() => {
|
||||||
|
this._pendingViewportRefreshTimer = null;
|
||||||
|
this._lastViewportRefreshTime = performance.now();
|
||||||
|
this._refreshViewport();
|
||||||
|
}, GfxViewportElement.VIEWPORT_REFRESH_MAX_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _refreshViewportByViewportUpdate(update: {
|
||||||
|
zoom: number;
|
||||||
|
center: [number, number];
|
||||||
|
}) {
|
||||||
|
const now = performance.now();
|
||||||
|
const previous = this._lastViewportUpdate;
|
||||||
|
this._lastViewportUpdate = {
|
||||||
|
zoom: update.zoom,
|
||||||
|
center: [update.center[0], update.center[1]],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!previous) {
|
||||||
|
this._lastViewportRefreshTime = now;
|
||||||
|
this._refreshViewport();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoomChanged = Math.abs(previous.zoom - update.zoom) > 0.0001;
|
||||||
|
const centerMovedInPixel = Math.hypot(
|
||||||
|
(update.center[0] - previous.center[0]) * update.zoom,
|
||||||
|
(update.center[1] - previous.center[1]) * update.zoom
|
||||||
|
);
|
||||||
|
const timeoutReached =
|
||||||
|
now - this._lastViewportRefreshTime >=
|
||||||
|
GfxViewportElement.VIEWPORT_REFRESH_MAX_INTERVAL;
|
||||||
|
|
||||||
|
if (
|
||||||
|
zoomChanged ||
|
||||||
|
centerMovedInPixel >=
|
||||||
|
GfxViewportElement.VIEWPORT_REFRESH_PIXEL_THRESHOLD ||
|
||||||
|
timeoutReached
|
||||||
|
) {
|
||||||
|
this._clearPendingViewportRefreshTimer();
|
||||||
|
this._lastViewportRefreshTime = now;
|
||||||
|
this._refreshViewport();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._scheduleTrailingViewportRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
override connectedCallback(): void {
|
override connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
|
||||||
const viewportUpdateCallback = () => {
|
|
||||||
this._refreshViewport();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!this.enableChildrenSchedule) {
|
if (!this.enableChildrenSchedule) {
|
||||||
delete this.scheduleUpdateChildren;
|
delete this.scheduleUpdateChildren;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._hideOutsideAndNoSelectedBlock();
|
this._hideOutsideAndNoSelectedBlock();
|
||||||
this.disposables.add(
|
this.disposables.add(
|
||||||
this.viewport.viewportUpdated.subscribe(() => viewportUpdateCallback())
|
this.viewport.viewportUpdated.subscribe(update =>
|
||||||
|
this._refreshViewportByViewportUpdate(update)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
this.disposables.add(
|
this.disposables.add(
|
||||||
this.viewport.sizeUpdated.subscribe(() => viewportUpdateCallback())
|
this.viewport.sizeUpdated.subscribe(() => {
|
||||||
|
this._clearPendingViewportRefreshTimer();
|
||||||
|
this._lastViewportRefreshTime = performance.now();
|
||||||
|
this._refreshViewport();
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback(): void {
|
||||||
|
this._clearPendingViewportRefreshTimer();
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
return html``;
|
return html``;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
} from '../gfx/model/base.js';
|
} from '../gfx/model/base.js';
|
||||||
import type { GfxGroupModel, GfxModel } from '../gfx/model/model.js';
|
import type { GfxGroupModel, GfxModel } from '../gfx/model/model.js';
|
||||||
|
|
||||||
|
type BatchGroupContainer = GfxGroupCompatibleInterface & {
|
||||||
|
addChildren?: (elements: GfxModel[]) => void;
|
||||||
|
removeChildren?: (elements: GfxModel[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the top elements from the list of elements, which are in some tree structures.
|
* Get the top elements from the list of elements, which are in some tree structures.
|
||||||
*
|
*
|
||||||
@@ -26,19 +31,64 @@ import type { GfxGroupModel, GfxModel } from '../gfx/model/model.js';
|
|||||||
* The result should be `[G1, G4, E6]`
|
* The result should be `[G1, G4, E6]`
|
||||||
*/
|
*/
|
||||||
export function getTopElements(elements: GfxModel[]): GfxModel[] {
|
export function getTopElements(elements: GfxModel[]): GfxModel[] {
|
||||||
const results = new Set(elements);
|
const uniqueElements = [...new Set(elements)];
|
||||||
|
const selected = new Set(uniqueElements);
|
||||||
|
const topElements: GfxModel[] = [];
|
||||||
|
|
||||||
elements = [...new Set(elements)];
|
for (const element of uniqueElements) {
|
||||||
|
let ancestor = element.group;
|
||||||
|
let hasSelectedAncestor = false;
|
||||||
|
|
||||||
elements.forEach(e1 => {
|
while (ancestor) {
|
||||||
elements.forEach(e2 => {
|
if (selected.has(ancestor as GfxModel)) {
|
||||||
if (isGfxGroupCompatibleModel(e1) && e1.hasDescendant(e2)) {
|
hasSelectedAncestor = true;
|
||||||
results.delete(e2);
|
break;
|
||||||
|
}
|
||||||
|
ancestor = ancestor.group;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...results];
|
if (!hasSelectedAncestor) {
|
||||||
|
topElements.push(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return topElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function batchAddChildren(
|
||||||
|
container: GfxGroupCompatibleInterface,
|
||||||
|
elements: GfxModel[]
|
||||||
|
) {
|
||||||
|
const uniqueElements = [...new Set(elements)];
|
||||||
|
if (uniqueElements.length === 0) return;
|
||||||
|
|
||||||
|
const batchContainer = container as BatchGroupContainer;
|
||||||
|
if (batchContainer.addChildren) {
|
||||||
|
batchContainer.addChildren(uniqueElements);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uniqueElements.forEach(element => {
|
||||||
|
container.addChild(element);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function batchRemoveChildren(
|
||||||
|
container: GfxGroupCompatibleInterface,
|
||||||
|
elements: GfxModel[]
|
||||||
|
) {
|
||||||
|
const uniqueElements = [...new Set(elements)];
|
||||||
|
if (uniqueElements.length === 0) return;
|
||||||
|
|
||||||
|
const batchContainer = container as BatchGroupContainer;
|
||||||
|
if (batchContainer.removeChildren) {
|
||||||
|
batchContainer.removeChildren(uniqueElements);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uniqueElements.forEach(element => {
|
||||||
|
container.removeChild(element);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function traverse(
|
function traverse(
|
||||||
|
|||||||
@@ -235,6 +235,69 @@ describe('connector', () => {
|
|||||||
expect(model.getConnectors(id2)).toEqual([]);
|
expect(model.getConnectors(id2)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should update endpoint index when connector retargets', () => {
|
||||||
|
const id = model.addElement({
|
||||||
|
type: 'shape',
|
||||||
|
});
|
||||||
|
const id2 = model.addElement({
|
||||||
|
type: 'shape',
|
||||||
|
});
|
||||||
|
const id3 = model.addElement({
|
||||||
|
type: 'shape',
|
||||||
|
});
|
||||||
|
const connectorId = model.addElement({
|
||||||
|
type: 'connector',
|
||||||
|
source: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
id: id2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const connector = model.getElementById(connectorId)!;
|
||||||
|
|
||||||
|
expect(model.getConnectors(id).map(c => c.id)).toEqual([connector.id]);
|
||||||
|
expect(model.getConnectors(id2).map(c => c.id)).toEqual([connector.id]);
|
||||||
|
|
||||||
|
model.updateElement(connectorId, {
|
||||||
|
source: {
|
||||||
|
id: id3,
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
id: id2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.getConnectors(id)).toEqual([]);
|
||||||
|
expect(model.getConnectors(id3).map(c => c.id)).toEqual([connector.id]);
|
||||||
|
expect(model.getConnectors(id2).map(c => c.id)).toEqual([connector.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getConnectors should purge stale connector ids from endpoint cache', () => {
|
||||||
|
const shapeId = model.addElement({
|
||||||
|
type: 'shape',
|
||||||
|
});
|
||||||
|
const surfaceModel = model as any;
|
||||||
|
surfaceModel._connectorIdsByEndpoint.set(
|
||||||
|
shapeId,
|
||||||
|
new Set(['missing-connector-id'])
|
||||||
|
);
|
||||||
|
surfaceModel._connectorEndpoints.set('missing-connector-id', {
|
||||||
|
sourceId: shapeId,
|
||||||
|
targetId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.getConnectors(shapeId)).toEqual([]);
|
||||||
|
expect(
|
||||||
|
surfaceModel._connectorIdsByEndpoint
|
||||||
|
.get(shapeId)
|
||||||
|
?.has('missing-connector-id') ?? false
|
||||||
|
).toBe(false);
|
||||||
|
expect(surfaceModel._connectorEndpoints.has('missing-connector-id')).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('should return null if connector are deleted', async () => {
|
test('should return null if connector are deleted', async () => {
|
||||||
const id = model.addElement({
|
const id = model.addElement({
|
||||||
type: 'shape',
|
type: 'shape',
|
||||||
|
|||||||
@@ -2684,10 +2684,12 @@ __metadata:
|
|||||||
"@preact/signals-core": "npm:^1.8.0"
|
"@preact/signals-core": "npm:^1.8.0"
|
||||||
"@toeverything/theme": "npm:^1.1.23"
|
"@toeverything/theme": "npm:^1.1.23"
|
||||||
"@types/lodash-es": "npm:^4.17.12"
|
"@types/lodash-es": "npm:^4.17.12"
|
||||||
|
fractional-indexing: "npm:^3.2.0"
|
||||||
lit: "npm:^3.2.0"
|
lit: "npm:^3.2.0"
|
||||||
lodash-es: "npm:^4.17.23"
|
lodash-es: "npm:^4.17.23"
|
||||||
minimatch: "npm:^10.1.1"
|
minimatch: "npm:^10.1.1"
|
||||||
rxjs: "npm:^7.8.2"
|
rxjs: "npm:^7.8.2"
|
||||||
|
vitest: "npm:^3.2.4"
|
||||||
yjs: "npm:^13.6.27"
|
yjs: "npm:^13.6.27"
|
||||||
zod: "npm:^3.25.76"
|
zod: "npm:^3.25.76"
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
@@ -2814,6 +2816,7 @@ __metadata:
|
|||||||
lodash-es: "npm:^4.17.23"
|
lodash-es: "npm:^4.17.23"
|
||||||
minimatch: "npm:^10.1.1"
|
minimatch: "npm:^10.1.1"
|
||||||
rxjs: "npm:^7.8.2"
|
rxjs: "npm:^7.8.2"
|
||||||
|
vitest: "npm:^3.2.4"
|
||||||
yjs: "npm:^13.6.27"
|
yjs: "npm:^13.6.27"
|
||||||
zod: "npm:^3.25.76"
|
zod: "npm:^3.25.76"
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
|
|||||||
Reference in New Issue
Block a user